create resources, deploy code in prod and dev. supports hetzner and multipass
Project description
vpseasy
Install
pip install 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, 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 AAAAC3NzaC1lZDI1NTE5AAAAIA8djqnLMaZGgYVtiPJCGtDutbid1rnHNuExJolxyXST 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 AAAAC3NzaC1lZDI1NTE5AAAAIGe5xfUhopf+J8VLThytJWd1yO5ad8sWenfkoAtTtj9I 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
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 65.21.147.20 ...
SSH check succeeded:
cloud-init status: running
cloud-init status: unknown
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: 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/tmplfmfw3uz/myapp/ deploy@65.21.147.20:/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: a8429b9a-7a14-49fa-9ee8-c18e71b015ac
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
Image nginx:alpine Pulling
612c0c1df4c5 Pulling fs layer 0B
aee4e54b3865 Pulling fs layer 0B
781ff50d2644 Pulling fs layer 0B
82736a35d0e7 Pulling fs layer 0B
453da7dbc73e Pulling fs layer 0B
583599bb7d38 Pulling fs layer 0B
4a8b0b2a5b19 Pulling fs layer 0B
6a0ac1617861 Pulling fs layer 0B
e8fc446e336c Download complete 0B
6192e1e6a438 Download complete 0B
6a0ac1617861 Downloading 2.097MB
781ff50d2644 Download complete 0B
612c0c1df4c5 Downloading 4.194MB
6a0ac1617861 Download complete 0B
aee4e54b3865 Download complete 0B
453da7dbc73e Download complete 0B
4a8b0b2a5b19 Download complete 0B
583599bb7d38 Download complete 0B
6a0ac1617861 Extracting 1B
82736a35d0e7 Download complete 0B
612c0c1df4c5 Downloading 11.53MB
6a0ac1617861 Extracting 1B
612c0c1df4c5 Download complete 0B
6a0ac1617861 Pull complete 0B
82736a35d0e7 Extracting 1B
781ff50d2644 Pull complete 0B
aee4e54b3865 Pull complete 0B
583599bb7d38 Pull complete 0B
82736a35d0e7 Pull complete 0B
453da7dbc73e Pull complete 0B
4a8b0b2a5b19 Pull complete 0B
612c0c1df4c5 Extracting 1B
612c0c1df4c5 Extracting 1B
612c0c1df4c5 Extracting 1B
612c0c1df4c5 Extracting 1B
612c0c1df4c5 Pull complete 0B
Image nginx:alpine Pulled
Network app_default Creating
Network app_default Created
Container app-app-1 Creating
Container app-app-1 Created
Container app-app-1 Starting
docker compose deployed with build
Deployed at myapp-prod, key: /Users/71293/.ssh/myapp-prod
Container app-app-1 Started
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']
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)
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 |
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 |
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file vpseasy-0.0.4.tar.gz.
File metadata
- Download URL: vpseasy-0.0.4.tar.gz
- Upload date:
- Size: 17.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e5d795c5cc6a1447fc388cf99e384df6d8674ea9c9e188fde97c2a294f2c157a
|
|
| MD5 |
b91ae1120dda02611c11534a39644c75
|
|
| BLAKE2b-256 |
672fdb5b2a344d35a59569c5b702b4227346aea963c4114824ddba892fdfad12
|
File details
Details for the file vpseasy-0.0.4-py3-none-any.whl.
File metadata
- Download URL: vpseasy-0.0.4-py3-none-any.whl
- Upload date:
- Size: 19.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b6b513d53c83c2b4317668d55a89410ee3e35f7f193749b5e122b857b666bc8
|
|
| MD5 |
5badd7400d46ae978b60b2f071db9d4b
|
|
| BLAKE2b-256 |
4d6f068734a2235422c0fda6f5e768ee8c3c22f1131d24d5fdb5ee9b37efebd2
|