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...
4 key(s) found
ssh...
multi_init() — local Multipass VMs (no UFW). vps_init() — production (UFW, fail2ban, Docker).
#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
#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).
41
Creating testvm Configuring testvm Starting testvm Waiting for initialization to complete Launched: testvm
VM at 192.168.2.56, key: /Users/71293/.ssh/testvm
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
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.
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
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.
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).
| 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 |