Skip to main content

vpseasy

pub_keys = load_pub_keys()
print(f'{len(pub_keys)} key(s) found')
if pub_keys: print(pub_keys[0][:3] + '...')
4 key(s) found
ssh...

Cloud-init

multi_init() — local Multipass VMs (no UFW). vps_init() — production (UFW, fail2ban, Docker).

_vm = 'testvm'
mi = multi_init(_vm, ['ssh-ed25519 AAAA... me'], docker=False)   # omit pub_keys to generate ~/.ssh/testvm; docker=False is faster locally
print(mi.yaml)
#cloud-config
hostname: testvm
preserve_hostname: false
packages:
- curl
package_update: true
package_upgrade: true
disable_root: true
ssh_pwauth: false
users:
- name: deploy
  groups:
  - sudo
  shell: /bin/bash
  sudo:
  - ALL=(ALL) NOPASSWD:ALL
  ssh_authorized_keys:
  - ssh-ed25519 AAAA... me
ci = vps_init('demo-prod', ['ssh-ed25519 AAAA... me'])   # omit pub_keys to generate ~/.ssh/demo-prod
print(ci.yaml)
#cloud-config
hostname: demo-prod
preserve_hostname: false
packages:
- curl
- fail2ban
- unattended-upgrades
package_update: true
package_upgrade: true
disable_root: true
ssh_pwauth: false
users:
- name: deploy
  groups:
  - sudo
  shell: /bin/bash
  sudo:
  - ALL=(ALL) NOPASSWD:ALL
  ssh_authorized_keys:
  - ssh-ed25519 AAAA... me
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker deploy
- systemctl enable --now docker
- ufw default deny incoming
- ufw default allow outgoing
- ufw logging off
- ufw allow 22/tcp
- ufw --force enable
apt:
  conf: 'APT::Periodic::Update-Package-Lists "1";

    APT::Periodic::Download-Upgradeable-Packages "1";

    APT::Periodic::AutocleanInterval "7";

    APT::Periodic::Unattended-Upgrade "0";

    Unattended-Upgrade::Automatic-Reboot "false";

    '
write_files:
- path: /etc/logrotate.d/00-cloud-init-global
  owner: root:root
  permissions: '0644'
  content: "/var/log/*.log {\n    weekly\n    rotate 7\n    compress\n    su root adm\n    create\n    missingok\n}\n"
power_state:
  mode: reboot
  message: Rebooting
  timeout: 1
  condition: true

Local testing with Multipass

Requires Multipass installed. Pass cloud_init=mi directly to mp.launch(). Use docker=True in multi_init() if your app needs Docker pre-installed (adds ~2 min for install + reboot).

import tempfile
_app = Path(tempfile.mkdtemp()) / 'myapp'
_app.mkdir()
(_app / 'docker-compose.yml').write_text('services:\n  app:\n    image: nginx:alpine\n')
41
mp = Multipass()
try: mp.rm(_vm, purge=True)
except: pass
vm = mp.launch(_vm, image='24.04', cpus=1, memory='1G', disk='10G', cloud_init=mi)
ip = mp.ip(vm.name)
print(f'VM at {ip}, key: {vm.key}')
Creating testvm  Configuring testvm  Starting testvm  Waiting for initialization to complete  Launched: testvm
VM at 192.168.2.56, key: /Users/71293/.ssh/testvm
deploy_mp(_vm, src=_app)
Resolved SSH key from name slug: /Users/71293/.ssh/testvm

Warning: Permanently added '192.168.2.56' (ED25519) to the list of known hosts.

Ensured remote path /srv/app exists and is writable by deploy
Resolved SSH key from name slug: /Users/71293/.ssh/testvm
Running rsync: rsync -az --delete -e ssh -o StrictHostKeyChecking=accept-new -i /Users/71293/.ssh/testvm /var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmp8vrta37_/myapp/ deploy@192.168.2.56:/srv/app/
Rsync completed successfully
Resolved SSH key from name slug: /Users/71293/.ssh/testvm
mp.rm(_vm)

Provision and deploy on Hetzner

Set HCLOUD_TOKEN in your environment. hetzner_deploy() provisions the server, waits for cloud-init, and deploys in one call. It’s idempotent — re-running against an existing server just redeploys.

hz = Hetzner()
svr = hetzner_deploy('myapp-prod',_app, hz) # hz is not required
print(f'Deployed at {svr.name}, key: {svr.key}')
Server myapp-prod provisioning at 95.216.194.42 ...
SSH to host 95.216.194.42 check succeeded
cloud-init status: running
cloud-init status: running
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: done
Ensured remote path /srv/app exists and is writable by deploy
Running rsync: rsync -az --delete -e ssh -o StrictHostKeyChecking=accept-new -i /Users/71293/.ssh/myapp-prod /var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmpo9ofr56s/myapp/ deploy@95.216.194.42:/srv/app/
Rsync completed successfully
Docker info: Client: Docker Engine - Community
 Version:    29.4.3
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.33.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v5.1.3
    Path:     /usr/libexec/docker/cli-plugins/docker-compose
  model: Docker Model Runner (Docker Inc.)
    Version:  v1.1.37
    Path:     /usr/libexec/docker/cli-plugins/docker-model

Server:
 Containers: 0
  Running: 0
  Paused: 0
  Stopped: 0
 Images: 0
 Server Version: 29.4.3
 Storage Driver: overlayfs
  driver-type: io.containerd.snapshotter.v1
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 CDI spec directories:
  /etc/cdi
  /var/run/cdi
 Swarm: inactive
 Runtimes: runc io.containerd.runc.v2
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 77c84241c7cbdd9b4eca2591793e3d4f4317c590
 runc version: v1.3.5-0-g488fc13e
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.8.0-111-generic
 Operating System: Ubuntu 24.04.4 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 2
 Total Memory: 3.73GiB
 Name: myapp-prod
 ID: 16846d84-4424-459d-925b-2df55ed21703
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  ::1/128
  127.0.0.0/8
 Live Restore Enabled: false
 Firewall Backend: iptables
docker-compose check output: /srv/app/docker-compose.yml
docker compose ran with build → 
Deployed at myapp-prod, key: /Users/71293/.ssh/myapp-prod
o = lambda: [s['name'] for s in hz.servers()]
print('servers: ', o())
hz.delete('myapp-prod')
print('Deleted server.')
print('servers', o())
servers:  ['vedicreader-cx32-hel', 'myapp-prod']
Deleted server.
servers ['vedicreader-cx32-hel']

Lightsail

pip install 'vpseasy[aws]' and set AWS_PROFILE (any standard AWS credential source works). Lightsail has the same methods as Hetzner, so vps_deploy() takes either. create() is idempotent, attaches a static IP, opens only port 22 (traffic arrives through the Cloudflare tunnel) and turns on daily snapshots. Region defaults to Sydney (ap-southeast-2).

ls = Lightsail()
r = vps_deploy('lh', _app, ls, bundle='small_3_2')
print(f'Deployed at {r.ip}, key: {r.key}')

Lightsail runs user data as a shell script rather than cloud-config, so create() converts vps_init() YAML with cloud_init_sh(). The script keeps the YAML verbatim, applies the same settings, and touches /var/lib/vpseasy/ready for wait_ready():

ud = cloud_init_sh(vps_init('demo', ['ssh-ed25519 AAAA... demo']).yaml)
print(ud[ud.index('hostnamectl'):][:600])
hostnamectl set-hostname demo
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/01-vpseasy.conf
id -u deploy >/dev/null 2>&1 || useradd -m -s /bin/bash deploy
usermod -aG sudo deploy
cat > /etc/sudoers.d/90-vpseasy-deploy <<'VPSEASY_EOF'
deploy ALL=(ALL) NOPASSWD:ALL
VPSEASY_EOF
chmod 440 /etc/sudoers.d/90-vpseasy-deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cat > /home/deploy/.ssh/authorized_keys <<'VPSEASY_EOF'
ssh-ed25519 AAAA... demo
VPSEASY_EOF
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
mkdir -p /etc/logrotat

Docker Compose helpers

Any app that dockeasy can build — FastHTML, FastAPI, Go, Rust, Node — follows the same production Compose shape when deployed behind Cloudflare Tunnel: an app service, a caddy reverse proxy, a cloudflared tunnel container, a shared web network, and two named volumes for Caddy state.

caddy_stack() generates that structure from a domain and any dockeasy Dockerfile object. vols_to_binds() converts absolute container paths to local bind mounts. The root= argument saves all three files (Dockerfile, docker-compose.yml, Caddyfile); without it the Compose object is returned without writing anything.

d = Path(tempfile.mkdtemp())
df = fasthtml_app(pkgs=['sqlite3'], vols=['/app/data'], healthcheck='/health')
c = caddy_stack('myapp.example.com', df, vols=['/app/data'], root=d)
print(c)
services:
  app:
    build: .
    volumes:
    - ./data:/app/data
    env_file:
    - .env
    restart: unless-stopped
    networks:
    - web
  caddy:
    image: caddy:2
    depends_on:
    - app
    volumes:
    - /private/var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmpfs6cqxh2/Caddyfile:/etc/caddy/Caddyfile
    - caddy_data:/data
    - caddy_config:/config
    networks:
    - web
    restart: unless-stopped
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --url http://caddy
    environment:
    - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}
    networks:
    - web
    restart: unless-stopped
networks:
  web: null
volumes:
  caddy_data: null
  caddy_config: null

Litestream backups

Litestream streams each SQLite db to an S3-compatible bucket: a Lightsail bucket, AWS S3 or Cloudflare R2 (endpoint='https://<account>.r2.cloudflarestorage.com', region='auto'). Lightsail.bucket() creates the bucket and an access key; put them in .env as LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY. Lightsail allows two keys per bucket and shows each secret once.

b = Lightsail().bucket('lh-backups')
b.bucket, b.endpoint, b.key_id

litestream_conf() writes litestream.yml. Db paths are container paths inside the app’s volumes; each replica lives under <prefix>/<db path>:

d = Path(tempfile.mkdtemp())
print(litestream_conf(['/app/data/app.db'], 'lh-backups', endpoint='https://s3.ap-southeast-2.amazonaws.com',
                      region='ap-southeast-2', prefix='lh', conf=d/'litestream.yml'))
snapshot:
  interval: 24h
  retention: 72h
validation:
  interval: 12h
dbs:
- path: /app/data/app.db
  replica:
    type: s3
    bucket: lh-backups
    path: lh/app/data/app.db
    region: ap-southeast-2
    access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
    secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
    endpoint: https://s3.ap-southeast-2.amazonaws.com

caddy_stack(..., litestream=...) adds two services. A one-shot litestream-restore must finish before app starts, so a fresh server gets its db back before the app can create an empty one; the litestream replicate service starts after app:

c = caddy_stack('lh.example.com', fasthtml_app(vols=['/app/data']), vols=['/app/data'], root=d,
                litestream=dict(dbs=['/app/data/app.db'], bucket='lh-backups', region='ap-southeast-2', prefix='lh',
                                endpoint='https://s3.ap-southeast-2.amazonaws.com'))
svcs = c.to_dict()['services']
print(yaml.safe_dump({k: {o: v[o] for o in ('image', 'command', 'depends_on', 'restart') if o in v} for k,v in svcs.items()
                      if k in ('app', 'litestream-restore', 'litestream')}, sort_keys=False))
app:
  depends_on:
    litestream-restore:
      condition: service_completed_successfully
  restart: unless-stopped
litestream-restore:
  image: litestream/litestream:0.5
  command: restore -if-db-not-exists -if-replica-exists /app/data/app.db
  restart: 'no'
litestream:
  image: litestream/litestream:0.5
  command: replicate
  depends_on:
  - app
  restart: unless-stopped

Install agent skill

Copies SKILL.md to .agents/skills/vpseasy/ (project-local) and ~/.claude/skills/vpseasy/ (global Claude Code).

mv_skill_md(dry_run=True)
Would copy to: ['.agents/skills/vpseasy/SKILL.md', '/Users/71293/.claude/skills/vpseasy/SKILL.md']

API reference

Symbol Description
load_pub_keys(paths=None) Read ~/.ssh/id_*.pub -> list of strings
gen_key(slug, key_dir=None) Generate ed25519 pair -> AttrDict(key, pub, pub_str)
multi_init(hostname, pub_keys, ...) Multipass cloud-init YAML -> AttrDict(yaml, key)
vps_init(hostname, pub_keys, ...) Production cloud-init YAML -> AttrDict(yaml, key)
Multipass Launch / list / exec / delete local Ubuntu VMs
deploy_mp(name, src, path, build) Sync dir + docker compose up in Multipass VM
Hetzner Create / list / delete Hetzner Cloud servers
Lightsail Create / list / delete AWS Lightsail instances; bucket() for backups (pip install 'vpseasy[aws]')
vps_deploy(name, src, provider, ...) Full pipeline on any provider: provision -> wait -> deploy (idempotent)
hetzner_deploy(name, src, ...) vps_deploy on Hetzner
wait_ssh(host, u, k, tout) Poll until SSH accepts connections
wait_ready(host, u, k, tout) Poll SSH then cloud-init until done
chk_cloud_init(host, u, k) Return cloud-init status string
chk_docker(host, u, k) Verify Docker daemon running
run_ssh(host, *cmds, ...) Run commands over SSH
sync(host, src, path, ...) Rsync local dir to remote
deploy(host, src, path, ...) sync + docker compose up -d
vols_to_binds(vols) ["/app/data"] -> ["./data:/app/data"] for Compose bind mounts
caddy_stack(domain, df, ...) Compose file: app + caddy + cloudflared + web network + caddy volumes
litestream_conf(dbs, bucket, ...) Write a Litestream 0.5 config that streams and restores SQLite dbs
litestream_svc(vols, conf, env_file, cmd) Litestream service kwargs for Compose.svc()
cloud_init_sh(yaml) Shell-script equivalent of a vps_init() cloud-config (Lightsail user data)
mv_skill_md(dry_run, dir) Install agent SKILL.md

Metadata

Release files for vpseasy 0.0.19

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vpseasy 0.0.19
File Size Uploaded
vpseasy-0.0.19.tar.gz 26.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vpseasy 0.0.19
File Interpreter ABI Platform
vpseasy-0.0.19-py3-none-any.whl Python 3 none any Details

Total release size: 53.7 kB

Release files / vpseasy-0.0.19.tar.gz

Download URL vpseasy-0.0.19.tar.gz
Size 26.2 kB
Tags Source
SHA-256 checksum
How to use checksums
970de40172cc45d006d10282bc81e74c42a9de2a74f6ea9c679bd1b9ec5f5a71
BLAKE2b-256 checksum
How to use checksums
c0865e29a9e63aeb370b1ef9c176ede89c80daa226d3d126b660629c45216ab5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / vpseasy-0.0.19-py3-none-any.whl

Download URL vpseasy-0.0.19-py3-none-any.whl
Size 27.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0fc0f293d14a37537354a8f766df783dee906ab62d9340f7cbe334499cfb3624
BLAKE2b-256 checksum
How to use checksums
fa9e8a2886d1f4f3b89c82b0f3591fda036b2893b4d3314176d5568b8dea9cde
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

0.0.20

2 release files

This release

0.0.19 This release

2 release files

0.0.18

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page