Metadata-Version: 2.5
Name: vpseasy
Version: 0.0.26
Summary: create resources, deploy code in prod and dev. supports Hetzner, Lightsail and Multipass
Project-URL: Repository, https://github.com/vedicreader/vpseasy
Project-URL: Documentation, https://vedicreader.github.io/vpseasy/
Author-email: Karthik <karthik.rajgopal@hotmail.com>
License: Apache-2.0
License-File: LICENSE
Keywords: nbdev
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Requires-Dist: dockeasy>=0.0.9
Requires-Dist: fastcloudinit>=0.0.5
Requires-Dist: fastcore>=1.12.31
Requires-Dist: hcloud>=2.17.0
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: truststore>=0.9
Provides-Extra: aws
Requires-Dist: boto3>=1.40; extra == 'aws'
Provides-Extra: dev
Requires-Dist: boto3>=1.40; extra == 'dev'
Description-Content-Type: text/markdown

# vpseasy


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

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`](https://vedicreader.github.io/vpseasy/core.html#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](https://multipass.run) 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

``` sh
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`](https://vedicreader.github.io/vpseasy/core.html#vps_init) or [`multi_init`](https://vedicreader.github.io/vpseasy/core.html#multi_init) | Write the cloud-init file for a server or a local VM |
| 2 | [`Multipass.launch`](https://vedicreader.github.io/vpseasy/core.html#multipass.launch) | Start a local Ubuntu VM from that file to test it |
| 3 | [`vps_deploy`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#vps_deploy) gives, call its parts yourself: [`Hetzner.create`](https://vedicreader.github.io/vpseasy/core.html#hetzner.create) or [`Lightsail.create`](https://vedicreader.github.io/vpseasy/core.html#lightsail.create), then [`wait_ready`](https://vedicreader.github.io/vpseasy/core.html#wait_ready), then [`deploy`](https://vedicreader.github.io/vpseasy/core.html#deploy). [`caddy_stack`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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:

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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:

``` python
_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`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#multi_init) or [`vps_init`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
import tempfile
_app = Path(tempfile.mkdtemp()) / 'myapp'
_app.mkdir()
(_app / 'docker-compose.yml').write_text('services:\n  app:\n    image: nginx:alpine\n')
```

    41

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
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

``` python
mp.rm(_vm)
```

## Provision and deploy on Hetzner

[Hetzner Cloud](https://www.hetzner.com/cloud) rents VPSes in Europe and the US. Set `HCLOUD_TOKEN` to an API token from its console. [`hetzner_deploy`](https://vedicreader.github.io/vpseasy/core.html#hetzner_deploy) runs [`vps_deploy`](https://vedicreader.github.io/vpseasy/core.html#vps_deploy) with a [`Hetzner`](https://vedicreader.github.io/vpseasy/core.html#hetzner) provider and Hetzner’s defaults, Ubuntu 24.04 on a `cx23` server.

[`vps_deploy`](https://vedicreader.github.io/vpseasy/core.html#vps_deploy) does the whole job. If no server with that name exists, it creates one from a [`vps_init`](https://vedicreader.github.io/vpseasy/core.html#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 images it builds, tagged with their build 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, 'prev')` reverses that. It restores the image only, not the copied files or the data. [`wait_http`](https://vedicreader.github.io/vpseasy/core.html#wait_http) waits until the site answers after a deploy, and [`server_health`](https://vedicreader.github.io/vpseasy/core.html#server_health) reports memory, swap, load, disk and each container.

[`Hetzner.servers`](https://vedicreader.github.io/vpseasy/core.html#hetzner.servers) lists your servers and [`Hetzner.delete`](https://vedicreader.github.io/vpseasy/core.html#hetzner.delete) removes one by name, as the second cell shows.

``` python
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

``` python
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)
```

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#lightsail) to change it.

[`Lightsail`](https://vedicreader.github.io/vpseasy/core.html#lightsail) has the same methods as [`Hetzner`](https://vedicreader.github.io/vpseasy/core.html#hetzner), so [`vps_deploy`](https://vedicreader.github.io/vpseasy/core.html#vps_deploy) accepts either. Keyword arguments that [`vps_deploy`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#vps_init) file with [`cloud_init_sh`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#wait_ready) watches for those files. Here is the start of a converted script:

``` python
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](https://github.com/Karthik777/dockeasy), which has recipes for FastHTML and other Python apps, Go, Rust and Node.

[`caddy_stack`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
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](https://litestream.io) 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`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
b = Lightsail().bucket('lh-backups')
b.bucket, b.endpoint, b.key_id
```

[`litestream_conf`](https://vedicreader.github.io/vpseasy/core.html#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.

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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:

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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`.

``` python
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`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#hetzner) | Create, list and delete Hetzner Cloud servers |
| [`Lightsail`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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`](https://vedicreader.github.io/vpseasy/core.html#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()`](https://vedicreader.github.io/vpseasy/core.html#vps_init) file does, for Lightsail |
| `mv_skill_md(dry_run, dir)` | Install the agent SKILL.md |
