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.20
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vpseasy-0.0.20.tar.gz | 26.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vpseasy-0.0.20-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.8 kB
Release files / vpseasy-0.0.20.tar.gz
| Download URL | vpseasy-0.0.20.tar.gz |
|---|---|
| Size | 26.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fa4063a35bff63d67269d3792c35772910124cd99d5ac0beef8414e5f35e2055
|
|
BLAKE2b-256 checksum How to use checksums |
f36a4df303d4b5bf0554739bfd112554b948f40ae89a88cdce8f3a0509c38166
|
| 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.20-py3-none-any.whl
| Download URL | vpseasy-0.0.20-py3-none-any.whl |
|---|---|
| Size | 27.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dae3dabc8dab62b315e64d61c7005913cb8c857b71d496f8ab51a25c61842d26
|
|
BLAKE2b-256 checksum How to use checksums |
88eab12d79ddacaad8a66e6e540a9d0171a892043cedc5d9673235cc0f315a63
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|