vpseasy
A VPS (virtual private server) is a small Linux machine you rent from a cloud provider. Getting one ready for an app means the same chores every time. You create the server, add a user who logs in with your SSH key, turn off password logins, install Docker, copy your app over and start it. vpseasy does each chore with one function call, and vps_deploy does all of them in order.
The setup of a new server is written down in a cloud-init file. Cloud-init is the program that Ubuntu cloud images run on their first boot. It reads a YAML file supplied when the server is created, and from it installs packages, creates users and runs commands. vpseasy writes that file for you.
You can try the file on your own computer before paying for a server. Multipass starts Ubuntu virtual machines locally and accepts the same cloud-init file, so a mistake in the setup shows up on your laptop in a few minutes rather than on a live server.
Once a server is up, vpseasy copies your app directory to it with rsync and runs docker compose up. It can also write the Compose file for a common layout, with the app behind the Caddy web server and a Cloudflare tunnel, and add Litestream to back up SQLite databases to a storage bucket.
Install
pip install vpseasy # Hetzner and Multipass
pip install 'vpseasy[aws]' # adds boto3 for Lightsail
Hetzner needs an API token in HCLOUD_TOKEN. Lightsail uses your normal AWS credentials, for example AWS_PROFILE. Multipass is installed separately and must be on your PATH.
How the pieces fit
| Step | Function | What it does |
|---|---|---|
| 1 | vps_init or multi_init |
Write the cloud-init file for a server or a local VM |
| 2 | Multipass.launch |
Start a local Ubuntu VM from that file to test it |
| 3 | vps_deploy |
Create the server on Hetzner or Lightsail if it does not exist, wait for its setup, and deploy your app |
When you need more control than vps_deploy gives, call its parts yourself: Hetzner.create or Lightsail.create, then wait_ready, then deploy. caddy_stack writes the Compose file, and its litestream argument adds backups.
SSH keys
The server will accept logins only with an SSH key. load_pub_keys reads your existing public keys from ~/.ssh/id_*.pub, ready to hand to the cloud-init functions below. On the machine that built these docs it found four:
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
Two functions write a cloud-init file, one for each kind of machine. Both create a user called deploy who can use sudo without a password and can log in only with the SSH keys you pass. Both turn off root and password logins, and both install Docker unless you pass docker=False. Each returns the YAML text as yaml and the path of the matching private key as key.
multi_init is for a local Multipass VM and keeps the setup small so the VM starts quickly. Here docker=False also skips the reboot that follows a Docker install:
_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
vps_init is for a real server on the internet, so it adds protection. It installs fail2ban, which blocks addresses that keep failing to log in, and unattended-upgrades, which applies security updates by itself. It turns on the UFW firewall with only SSH (port 22) allowed, sets up log rotation and reboots once setup is done. It also adds a 4 GB swap file with a low swappiness, so a memory spike slows the server down instead of killing a container; swap=None leaves swap out. Extra packages go in pkgs and extra shell commands in cmds.
If you leave out pub_keys, either function generates a new key pair at ~/.ssh/<hostname>, replacing any key already there, and returns its path as key. The other functions find that key when you give them the server’s name, so this is the simplest way to start. When you pass your own public keys, key is None.
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
Multipass wraps the multipass command. Its launch method starts a VM and hands it the cloud-init file; pass it the object that multi_init or vps_init returned. The VM gets its own IP address on your machine, and ip returns it. A Docker install inside the VM adds about two minutes, for the install and the reboot after it.
The next cell makes a tiny app to deploy: a directory that holds only a docker-compose.yml running nginx.
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 copies a local directory into the VM and starts it with docker compose up -d. It logs in with the key at ~/.ssh/<vm name>, so it works when multi_init generated the key. The example above passes a placeholder public key; to run it for real, leave out pub_keys so a key is made at ~/.ssh/testvm. Compose is only started if Docker works on the VM and the directory has a docker-compose.yml. Otherwise the files are copied and nothing else happens, which is what docker=False gives you here. mp.rm deletes the VM when you are done.
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
Hetzner Cloud rents VPSes in Europe and the US. Set HCLOUD_TOKEN to an API token from its console. hetzner_deploy runs vps_deploy with a Hetzner provider and Hetzner’s defaults, Ubuntu 24.04 on a cx23 server.
vps_deploy does the whole job. If no server with that name exists, it creates one from a vps_init file with a new key pair at ~/.ssh/<name>. It then waits for SSH and for cloud-init to finish, copies src to /srv/app and runs docker compose up -d --build. If the server already exists, it skips the creation and logs in with the key at ~/.ssh/<name>, so running it again deploys your new code to the same server. It returns the server’s IP address, name and key path.
Each deploy keeps the image it replaces, tagged with the time, and deletes dangling images and build cache afterwards. If a new build misbehaves, rollback(ip) puts the previous image back and restarts the app; rollback(ip, to='undo') reverses that. It restores the image only, not the copied files or the data. wait_http waits until the site answers after a deploy, and server_health reports memory, swap, load, disk and each container.
Hetzner.servers lists your servers and Hetzner.delete removes one by name, as the second cell shows.
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
wait_http('https://myapp.example.com/health')
rollback(svr.ip, key=svr.key) # back to the image before the last deploy
server_health(svr.ip, key=svr.key)
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
Lightsail is Amazon’s fixed-price VPS service. Install the extra with pip install 'vpseasy[aws]' and make AWS credentials available in any of the usual ways, such as AWS_PROFILE. The region defaults to Sydney (ap-southeast-2); pass region= to Lightsail to change it.
Lightsail has the same methods as Hetzner, so vps_deploy accepts either. Keyword arguments that vps_deploy does not know go to create, which is how bundle= below picks the instance size. Lightsail’s create does more than Hetzner’s. It leaves an existing instance alone instead of failing, gives the instance a static IP address that stays the same when it restarts, closes every public port except SSH, and turns on a daily snapshot at 16:00 UTC. ports= and snapshots= change the last two. SSH alone is enough when web traffic arrives through a Cloudflare tunnel, as in the Compose layout further down.
ls = Lightsail()
r = vps_deploy('lh', _app, ls, bundle='small_3_2')
print(f'Deployed at {r.ip}, key: {r.key}')
Lightsail does not read cloud-init YAML. It runs the user data you give it as a shell script, so create converts the vps_init file with cloud_init_sh. The result is a bash script that does the same work: it sets the hostname, creates the users with their sudo rules and SSH keys, writes files, installs packages and runs the commands. It does not reboot. At the end it creates /var/lib/vpseasy/ready, or /var/lib/vpseasy/error if a step failed, and wait_ready watches for those files. Here is the start of a converted script:
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
Docker Compose runs several containers together from one docker-compose.yml file. A small web app behind a Cloudflare tunnel usually needs the same pieces. There is the app container, Caddy as the web server in front of it, and a cloudflared container that keeps the tunnel to Cloudflare open, all on a shared network called web. Caddy also needs two named volumes for its certificates and settings. The app is built from a Dockerfile made with dockeasy, which has recipes for FastHTML and other Python apps, Go, Rust and Node.
caddy_stack writes all of this from a domain name and a dockeasy Dockerfile object. It saves three files into root: Dockerfile, docker-compose.yml and Caddyfile. If you leave out root, they are written to the current directory. It returns the Compose object, which prints as YAML.
vols lists directories inside the container whose contents should stay on the server’s disk when the container is rebuilt. vols_to_binds turns each one into a bind mount from a folder of the same name next to the Compose file, so /app/data becomes ./data:/app/data. The app reads its settings and secrets from .env, and the cloudflared container takes its tunnel token from the CF_TUNNEL_TOKEN variable. Pass cloudflared=False to leave out the tunnel and have Caddy listen on ports 80 and 443 itself.
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
SQLite keeps a whole database in one file, which is convenient until the server is lost and the file with it. Litestream runs beside the app, copies every change into an S3-compatible storage bucket, and can rebuild the file from there. The bucket can be a Lightsail bucket, AWS S3 or Cloudflare R2. For R2, use endpoint='https://<account>.r2.cloudflarestorage.com' and region='auto'.
Lightsail.bucket creates a Lightsail bucket if it does not exist and makes a new access key for it. Put the key in .env as LITESTREAM_ACCESS_KEY_ID and LITESTREAM_SECRET_ACCESS_KEY. Copy the secret straight away, because Lightsail shows it only once. A bucket can have only two access keys, so a third call fails until you delete one.
b = Lightsail().bucket('lh-backups')
b.bucket, b.endpoint, b.key_id
litestream_conf writes the Litestream config file, litestream.yml, and returns its text. The database paths are paths inside the container, so they must sit under one of the app’s volumes. Each database is stored at <prefix>/<db path> in the bucket, which keeps two databases with the same file name apart. By default Litestream takes a full snapshot every 24 hours and keeps 72 hours of history; snapshot= and retention= change that.
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
You will usually pass the same settings to caddy_stack as litestream=dict(...) and let it write the config. It adds two services. litestream-restore runs once at startup and restores the database from the bucket when the file is missing and a copy exists. The app does not start until it has finished, so a brand new server gets its data back before the app can create an empty database. The litestream service starts after the app and keeps copying changes. With several databases there is one restore service for each. The output shows how the three services depend on each other:
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 the agent skill
vpseasy ships a SKILL.md that tells coding agents how to use it. mv_skill_md copies it to .agents/skills/vpseasy/ in the current project and to ~/.claude/skills/vpseasy/ for Claude Code. It only prints where the files would go unless you pass dry_run=False.
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 public keys from files, by default ~/.ssh/id_*.pub -> list of strings |
gen_key(slug, key_dir=None) |
Make an ed25519 key pair at ~/.ssh/<slug> -> AttrDict(key, pub, pub_str) |
multi_init(hostname, pub_keys, ...) |
Cloud-init for a Multipass VM -> AttrDict(yaml, key) |
vps_init(hostname, pub_keys, ...) |
Cloud-init for a public server -> AttrDict(yaml, key) |
Multipass |
Launch, list, run commands in and delete local Ubuntu VMs |
deploy_mp(name, src, path, build) |
Copy a directory into a Multipass VM and run docker compose up |
Hetzner |
Create, list and delete Hetzner Cloud servers |
Lightsail |
Create, list and delete AWS Lightsail instances; bucket() for backups (pip install 'vpseasy[aws]') |
vps_deploy(name, src, provider, ...) |
Create the server if needed, wait for its setup, deploy |
hetzner_deploy(name, src, ...) |
vps_deploy on Hetzner |
wait_ssh(host, u, k, tout) |
Wait until SSH accepts connections |
wait_ready(host, u, k, tout) |
Wait for SSH, then for cloud-init to finish |
chk_cloud_init(host, u, k) |
Report whether setup is done, running or failed with error |
chk_docker(host, u, k) |
True if Docker is running and usable |
run_ssh(host, *cmds, ...) |
Run commands over SSH |
sync(host, src, path, ...) |
Copy a local directory to the server with rsync |
deploy(host, src, path, ...) |
sync, then docker compose up -d if Docker and a Compose file are there |
vols_to_binds(vols) |
["/app/data"] -> ["./data:/app/data"] for Compose bind mounts |
caddy_stack(domain, df, ...) |
Compose file with app, Caddy, cloudflared, the web network and Caddy’s volumes |
litestream_conf(dbs, bucket, ...) |
Write a Litestream 0.5 config that backs up and restores SQLite databases |
litestream_svc(vols, conf, env_file, cmd) |
Litestream service arguments for Compose.svc() |
cloud_init_sh(yaml) |
Bash script that does what a vps_init() file does, for Lightsail |
mv_skill_md(dry_run, dir) |
Install the agent SKILL.md |
Metadata
Release files for vpseasy 0.0.23
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| vpseasy-0.0.23.tar.gz | 32.9 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vpseasy-0.0.23-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.8 kB
Release files / vpseasy-0.0.23.tar.gz
| Download URL | vpseasy-0.0.23.tar.gz |
|---|---|
| Size | 32.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c5dc7be88053615d39f5079b28e70e2796ae97744a9e412c7f636a67fde4826e
|
|
BLAKE2b-256 checksum How to use checksums |
03722d5b8f92e50a76c9ede581827cf075501a05d4534222778157f4e8dbaa42
|
| 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.23-py3-none-any.whl
| Download URL | vpseasy-0.0.23-py3-none-any.whl |
|---|---|
| Size | 33.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2d4a07ae0e6c1550db7fd2227601dd3c1ff5a255d4c6f7303bce06dd749c0e3c
|
|
BLAKE2b-256 checksum How to use checksums |
2edb1a37056ee8283c390b740bbed5d7f69194bf7b30460c9bba5e6488db55b1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Security for vpseasy 0.0.23
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.