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...
multi_init()
— local Multipass VMs (no UFW).
vps_init()
— production (UFW, fail2ban, Docker).
_vm = 'testvm'
mi = multi_init(_vm, docker=False) # docker=False: skip install+reboot, much faster for local testing
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 AAAAC3NzaC1lZDI1NTE5AAAAIG5vF0hxKfho9gZ9nWIp5GIq+UDkZTQ+/v1lgzp+bk5K 71293@MELMAC-71293
ci = vps_init('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 AAAAC3NzaC1lZDI1NTE5AAAAIH+XjqpWlA8Zcct/3Py1OasAupD8py5/oUlxI4359V8z 71293@MELMAC-71293
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
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)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']
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/tmp_dtwwgkg/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
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']
| 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 |
hetzner_deploy(name, src, ...) |
Full pipeline: provision -> wait -> deploy (idempotent) |
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 |
mv_skill_md(dry_run, dir) |
Install agent SKILL.md |