Skip to main content

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. 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.

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
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.22

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vpseasy 0.0.22
File Size Uploaded Provenance
vpseasy-0.0.22.tar.gz 29.1 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for vpseasy 0.0.22
File Interpreter ABI Platform
vpseasy-0.0.22-py3-none-any.whl Python 3 none any Details

Total release size: 59.3 kB

Release files / vpseasy-0.0.22.tar.gz

Download URL vpseasy-0.0.22.tar.gz
Size 29.1 kB
Tags Source
SHA-256 checksum
How to use checksums
3d7922cb7bf46ed4f1fb98e8a3951f9cc614684b8cdaac9a6e2c09923aca3f90
BLAKE2b-256 checksum
How to use checksums
37caef8b0897958ce3d5d4b4b5e7613a7f14907a0bd47c41b2baf60dbefa5769
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.22-py3-none-any.whl

Download URL vpseasy-0.0.22-py3-none-any.whl
Size 30.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
04390862fc1fe3f5cb9d3a47816b1c01aba81a0ebb345d4541ac9ea8944f4be5
BLAKE2b-256 checksum
How to use checksums
612653ceb22b325aa28e333dc92e074df20d92ed32e97d6feaa7db3a334584d2
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.22

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page