Skip to content

Add ship(to='hetzner') auto-provisioning deployment target#7

Draft
Copilot wants to merge 2 commits intocopilot/add-cloud-resource-provisioningfrom
copilot/add-hetzner-deployment-target
Draft

Add ship(to='hetzner') auto-provisioning deployment target#7
Copilot wants to merge 2 commits intocopilot/add-cloud-resource-provisioningfrom
copilot/add-hetzner-deployment-target

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 25, 2026

Hetzner is the cheapest production path (€4/mo cx22) but required manual vps_init()create()ship() workflow. This adds ship(to='hetzner') as a first-class deployment target with auto-provisioning.

Changes

  • Added **kw to ship() signature - enables provider-specific parameters (server_type, location, deploy_path, etc.)

  • New Hetzner deployment branch - auto-provisions servers via hcloud CLI:

    • Checks server existence (idempotent)
    • Generates cloud-init with Docker installation
    • Auto-detects SSH keys from ~/.ssh/
    • Polls cloud-init completion (configurable timeout)
    • Optional Cloudflare DNS configuration
    • Deploys via docker-compose
  • VPS branch enhancement - accepts to='hetzner' with host parameter as alias for existing server deployment

  • Fixed VPS deploy bug - corrects vps.deploy() call signature (now passes compose as first positional arg)

Usage

# Auto-provision new server
ship('.', to='hetzner', domain='app.example.com')
# → Creates cx22 in nbg1, installs Docker, deploys

# Custom configuration
ship('.', to='hetzner', server_type='cx32', location='hel1')

# Use existing server (same as to='vps')
ship('.', to='hetzner', host='1.2.3.4')

Parameters (via **kw)

server_name, server_type (default: cx22), location (default: nbg1), image (default: ubuntu-24.04), ssh_keys, pub_keys, deploy_path, wait_timeout, cf_token, proxied

Stats: 118 lines added, 12 modified, 1 file changed (fastops/ship.py)

Original prompt

Overview

Currently ship(to='vps') requires the user to manually provision a Hetzner server first (vps_init()create()) and then pass the host=ip to ship(). This PR adds ship(to='hetzner') as a first-class deployment target that auto-provisions a Hetzner server, installs Docker, configures DNS, and deploys — all in one call.

This is a critical UX gap: Hetzner is the cheapest production path (€4/mo for cx22) and should be as easy as ship('.', to='azure').

Branch off copilot/add-cloud-resource-provisioning.


Changes to fastops/ship.py

1. Add hetzner as a deployment target

In the ship() function signature, the existing parameters already cover what's needed (host, user, key, cloud). Add a new elif to == 'hetzner': branch.

2. Add the hetzner branch in ship()

After the existing elif to == 'aws': block (around line 261), add:

elif to == 'hetzner':
    print('Deploying to Hetzner...')
    from .vps import vps_init, create, deploy as vps_deploy, server_ip, servers, hcloud_auth
    
    # Server name from app name
    server_name = kw.get('server_name', app_name)
    server_type = kw.get('server_type', 'cx22')       # €4/mo default — cheapest usable
    location = kw.get('location', 'nbg1')              # Nuremberg, Germany — good EU default
    image = kw.get('image', 'ubuntu-24.04')             # Latest LTS
    ssh_keys = kw.get('ssh_keys', [])
    pub_keys = kw.get('pub_keys', '')
    
    # Read SSH public key from default location if not provided
    if not pub_keys:
        import os
        for key_path in ['~/.ssh/id_ed25519.pub', '~/.ssh/id_rsa.pub']:
            expanded = os.path.expanduser(key_path)
            if os.path.exists(expanded):
                pub_keys = open(expanded).read().strip()
                break
    
    # Check if server already exists
    existing = None
    try:
        existing_servers = servers()
        for s in existing_servers:
            if s['name'] == server_name:
                existing = s
                break
    except Exception:
        pass  # hcloud CLI might not be configured yet
    
    if existing:
        print(f'Server {server_name} already exists at {existing["ip"]}')
        ip = existing['ip']
    else:
        # Generate cloud-init
        print(f'Provisioning Hetzner {server_type} in {location}...')
        
        # Build cloud-init packages list
        init_packages = ['git', 'htop', 'curl']
        
        # Generate cloud-init YAML
        cloud_init_yaml = vps_init(
            server_name,
            pub_keys=pub_keys,
            username=user,
            docker=True,
            packages=init_packages,
            cf_token=kw.get('cf_token'),
        )
        
        # Create the server
        ip = create(
            server_name,
            image=image,
            server_type=server_type,
            location=location,
            cloud_init=cloud_init_yaml,
            ssh_keys=ssh_keys,
        )
        
        # Wait for server to be ready (cloud-init takes ~60-90s)
        print(f'Server created at {ip}. Waiting for cloud-init to complete...')
        import time
        max_wait = kw.get('wait_timeout', 180)  # 3 minutes default
        waited = 0
        interval = 10
        ready = False
        while waited < max_wait:
            time.sleep(interval)
            waited += interval
            try:
                from .vps import run_ssh
                result = run_ssh(ip, 'cloud-init status --wait 2>/dev/null || echo done',
                               user=user, key=key)
                if 'done' in result or 'status: done' in result:
                    ready = True
                    break
            except Exception:
                pass  # SSH not ready yet
            print(f'  Waiting... ({waited}s)')
        
        if not ready:
            print(f'  Warning: cloud-init may not have completed after {max_wait}s. Proceeding anyway.')
    
    # Configure DNS if domain provided
    if domain:
        try:
            from .cloudflare import dns_record
            print(f'Configuring DNS: {domain}{ip}')
            dns_record(domain.split('.')[-2] + '.' + domain.split('.')[-1],
                      domain.split('.')[0] if '.' in domain and len(domain.split('.')) > 2 else '@',
                      ip, proxied=kw.get('proxied', False))
        except Exception as e:
            print(f'  DNS configuration skipped: {e}')
    
    # Deploy the compose stack
    print(f'Deploying to {server_name} ({ip})...')
    from .vps import deploy as vps_deploy
    deploy_path = kw.get('deploy_path', f'/srv/{app_name}')
    vps_deploy(compose, ip, user=user, key=key, path=deploy_path)
    
    result['status'] = 'deployed'
    result['target'] = 'hetzner'
    result['host'] = ip
    result['server_name'] = server_name
    result['server_type'] = server_type
    result['location'] = location
    result['deploy_path'] = deploy_path
    result['url...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

*This pull request was created from Copilot chat.*
>

<!-- START COPILOT CODING AGENT TIPS -->
---

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security)

Co-authored-by: Karthik777 <7102951+Karthik777@users.noreply.github.com>
Copilot AI changed the title [WIP] Add Hetzner as a deployment target for ship() function Add ship(to='hetzner') auto-provisioning deployment target Feb 25, 2026
Copilot AI requested a review from Karthik777 February 25, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants