Cloud Platforms Editor's pick

VPS Deployment skill for Claude Code

One server, done properly: TLS, backups, no-drop deploys. Claude sets up a VPS the way an experienced operator would: key-only SSH, a firewall that Docker cannot bypass, Compose services on internal networks behind Caddy with automatic TLS, and a deploy script that starts a healthy replica before draining the old one. Nightly restic and pg_dump backups go off-site with a restore test, and exposure is verified from outside the server.

  • namevps-deployment
  • size272 lines · ≈2,700 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Deploys and runs production apps on a single VPS (Hetzner, DigitalOcean, Linode, Vultr, Lightsail) with Docker Compose behind Caddy or Traefik - server hardening, SSH keys, firewall rules that Docker cannot bypass, automatic TLS, health-checked zero-downtime updates, secrets on disk, restic and pg_dump backups with restore tests, log rotation, unattended upgrades, uptime monitoring, and when to move to Kamal or a managed platform. Use when setting up a new server, deploying with docker compose to a VPS, exposing an app with HTTPS, planning backups, or when a self-hosted app goes down during deploys.

Try asking

  • Set up a fresh Hetzner server to run our app with Docker Compose and HTTPS
  • Our site goes down for 20 seconds on every deploy to the VPS. Make updates zero-downtime
  • Add nightly off-site backups for the Postgres container and prove we can restore

Use it when

  • Provisioning or hardening a new VPS for production
  • Deploying containers to one server with Compose and a reverse proxy
  • Deploys on a self-hosted server drop requests
  • Setting up backups, monitoring and a runbook for a single host

Skip it when

How to check the result

An external nmap shows only ports 22, 80 and 443, a deploy under a request loop returns no 5xx, and a restic backup restores into a scratch database that answers a smoke query.

The common mistake it prevents

Publishing a database port in compose.yaml and trusting UFW to block it, while Docker's own iptables rules expose it to the internet.

~/.claude/skills/vps-deployment/SKILL.md

--- name: vps-deployment description: Deploys and runs production apps on a single VPS (Hetzner, DigitalOcean, Linode, Vultr, Lightsail) with Docker Compose behind Caddy or… license: MIT ---

A single-server deployment is done when SSH is key-only, only ports 22/80/443 are reachable from the internet (verified from outside), the app runs as Compose services behind a reverse proxy with automatic TLS, updates swap containers without dropped requests, backups run nightly off-site and a restore was tested, and an external monitor pages when the site is down.

Ground rules

  • Verify exposure from outside the server (nmap from your laptop). Docker-published ports bypass UFW/firewalld rules; only the proxy publishes ports.
  • Databases and internal services never publish ports to 0.0.0.0. Bind admin ports to 127.0.0.1 and reach them over an SSH tunnel.
  • A backup that has never been restored is not a backup. Test restores on a schedule.
  • Never lock yourself out: keep an existing SSH session open while changing SSH or firewall settings, and test a new login before closing it.
  • Reboots, docker compose down, volume removal (docker volume rm, down -v) and docker system prune -a --volumes on production need explicit user confirmation. down -v deletes the database volume.

Workflow

- [ ] 1. Provision and harden the server (users, SSH, firewall, updates)
- [ ] 2. Install Docker Engine + Compose plugin from the official repo
- [ ] 3. Lay out /srv/<app>: compose.yaml, Caddyfile, .env, backups
- [ ] 4. Reverse proxy with automatic TLS; internal networks only
- [ ] 5. Deploy flow: pull image, migrate, health-checked swap
- [ ] 6. Backups off-site + restore test; logs; monitoring
- [ ] 7. Verify from outside; document runbook

1. Harden the server

Full step-by-step commands (user creation, sshd config, UFW, Docker/UFW interaction, unattended upgrades, fail2ban, swap): read references/server-setup.md on a fresh server. Minimum bar:

  • Non-root sudo user with SSH key; PermitRootLogin no, PasswordAuthentication no.
  • Firewall default deny inbound; allow 22 (or your SSH port), 80, 443 (and 443/udp for HTTP/3). Provider cloud firewall in front as a second layer.
  • Automatic security updates; reboot window agreed with the user.
  • Time sync on, swap file sized to RAM on small instances, hostname and DNS set.

2-3. Layout

/srv/acme/
├── compose.yaml
├── Caddyfile
├── .env                 # mode 600, owned by deploy user; not in Git
├── secrets/             # files mounted as Docker secrets, mode 600
└── backups/             # local staging only; real copies go off-site
# compose.yaml
name: acme
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [web]

  app:
    image: ghcr.io/acme/app:${APP_TAG:?set APP_TAG}
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL_FILE: /run/secrets/database_url
    secrets: [database_url]
    expose: ["8080"]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    depends_on:
      db: { condition: service_healthy }
    networks: [web, internal]
    logging:
      driver: local
      options: { max-size: "20m", max-file: "5" }

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: acme
      POSTGRES_DB: acme
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U acme -d acme"]
      interval: 10s
      retries: 5
    networks: [internal]
    shm_size: 256mb

volumes: { caddy_data: {}, caddy_config: {}, pgdata: {} }
networks:
  web: {}
  internal: { internal: true }
secrets:
  database_url: { file: ./secrets/database_url }
  db_password: { file: ./secrets/db_password }

Notes: the app must support *_FILE env vars or read the secret file itself; otherwise use .env with mode 600. Postgres 18 images changed the default data directory layout; when using 18+, mount the volume at the path its image docs specify. For Compose syntax details see docker-compose.

4. Reverse proxy and TLS

# Caddyfile
{
  email [email protected]
}

acme.com, www.acme.com {
  encode zstd gzip
  reverse_proxy {
    dynamic a app 8080 {
      refresh 5s
    }
    lb_policy round_robin
    lb_try_duration 10s
    fail_duration 10s
  }
  header {
    Strict-Transport-Security "max-age=63072000; includeSubDomains"
    X-Content-Type-Options nosniff
    Referrer-Policy strict-origin-when-cross-origin
  }
}
  • DNS A/AAAA must point at the server before Caddy can obtain certificates (see dns-tls-certificates).
  • dynamic a app 8080 re-resolves the Compose service name so Caddy sees every replica during a rollout; lb_try_duration retries requests while a replica is swapped, and fail_duration enables passive health checks (backends that fail are skipped). Active health checks (health_uri) are simplest with static upstreams; check Caddy’s docs for dynamic-upstream support in your version before combining them.
  • Traefik is the alternative when you prefer labels on containers for routing; nginx when you need its specific modules (see nginx-reverse-proxy).

5. Deploy flow (zero downtime)

#!/usr/bin/env bash
# /srv/acme/deploy.sh <tag>   -- run as deploy user; production use needs user confirmation
set -euo pipefail
cd /srv/acme
TAG="$1"
PREV=$(grep -E '^APP_TAG=' .deploy-state 2>/dev/null | cut -d= -f2 || true)
export APP_TAG="$TAG"
docker compose pull app
docker compose run --rm app ./bin/migrate          # backward-compatible migrations only
docker compose up -d --no-deps --scale app=2 --no-recreate app   # start new replica beside old
for i in $(seq 1 30); do                           # wait until the new container is healthy
  NEW=$(docker compose ps -q app | tail -1)
  [ "$(docker inspect -f '{{.State.Health.Status}}' "$NEW")" = healthy ] && break
  sleep 2
done
[ "$(docker inspect -f '{{.State.Health.Status}}' "$NEW")" = healthy ] || { echo "new replica unhealthy"; docker rm -f "$NEW"; exit 1; }
OLD=$(docker compose ps -q app | head -1)
docker stop -t 30 "$OLD" && docker rm "$OLD"       # SIGTERM, drain, remove
docker compose up -d --no-deps --scale app=1 --no-recreate app
echo "APP_TAG=$TAG" > .deploy-state; echo "PREV_TAG=$PREV" >> .deploy-state

The third-party docker rollout CLI plugin automates the same scale-up, health wait and old-container removal; Kamal 2 (kamal-proxy) handles multi-server deploys, builds and secrets if you outgrow scripts. Rollback = run the script with PREV_TAG.

CI: build and push the image (tagged with the git SHA) to a registry, then SSH with a dedicated deploy key restricted to running the deploy script (forced command in authorized_keys) instead of giving CI a full shell.

6. Backups, logs, monitoring

Nightly backup (cron or systemd timer, see systemd-services):

#!/usr/bin/env bash
set -euo pipefail
cd /srv/acme
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
docker compose exec -T db pg_dump -U acme -d acme -Fc > "backups/acme-$STAMP.dump"
export RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/acme-backups/restic"
export RESTIC_PASSWORD_FILE=/root/.restic-pass AWS_SHARED_CREDENTIALS_FILE=/root/.aws-backup
restic backup backups/ /srv/acme/Caddyfile /srv/acme/compose.yaml --tag nightly
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
find backups/ -name '*.dump' -mtime +2 -delete
curl -fsS -m 10 --retry 3 https://hc-ping.com/<uuid> > /dev/null   # dead-man's switch
  • Off-site (different provider or region), encrypted (restic does this), with a bucket credential that cannot delete (object lock or append-only where possible).
  • Monthly restore test: restic restore latest --target /tmp/restore, then pg_restore into a scratch container and run a smoke query.
  • Provider snapshots are a convenience, not the backup plan (same account, same provider failure domain, often crash-consistent only).
  • Logs: Docker local driver with rotation (above) or ship to a hosted log service; journald retention configured.
  • Monitoring: an external uptime check on /healthz with alerting (UptimeRobot, Better Stack, healthchecks.io for cron), disk usage alert at 80%, and cert expiry checks.

7. Verify from outside

nmap -Pn -p- --min-rate 2000 acme.com            # from your laptop: expect 22, 80, 443 only
curl -sI https://acme.com | head -5
ssh -o PasswordAuthentication=yes -o PubkeyAuthentication=no [email protected]   # must be refused
docker compose ps                                 # all healthy

Run a deploy while a load loop hits the site (while true; do curl -s -o /dev/null -w '%{http_code}\n' https://acme.com/healthz; done | sort | uniq -c) and confirm no 5xx.

When to leave the single VPS

SignalMove to
Downtime from host failure is unacceptableSecond server + load balancer, or managed platform
Database needs PITR, replicas, minimal opsManaged Postgres, keep app on VPS
Several servers to deploy toKamal, Ansible (see ansible-playbooks)
Team lacks ops timePaaS (see paas-deploys)

Gotchas

  • ports: "5432:5432" exposes Postgres to the internet even with UFW denying 5432; Docker writes its own iptables rules ahead of UFW. Use 127.0.0.1:5432:5432 or no ports at all.
  • restart: always plus a crash-looping container fills logs; use unless-stopped and rotated logs.
  • Running docker compose up -d after editing .env recreates services; plan it as a deploy.
  • Disk fills from images and build cache: schedule docker image prune -af --filter "until=168h" (not volume prune) and alert on disk usage.
  • Small VPS instances with no swap OOM-kill the database first during builds; build images in CI, not on the server.
  • Provider “rescue” consoles are your only way back after a firewall mistake; know how to reach them before changing rules.

Output

**Server:** Hetzner CX32, Ubuntu 24.04, deploy user, key-only SSH, UFW + cloud firewall (22/80/443)
**Stack:** caddy (TLS auto), app (ghcr.io/acme/app:<sha>), postgres 17 (internal network only)
**Deploy:** deploy.sh <sha>: pull, migrate, scale to 2, health wait, drain old; rollback PREV_TAG
**Backups:** nightly pg_dump + restic to S3 eu-central-1, 7/4/12 retention; restore tested 2026-03-04
**Monitoring:** uptime check /healthz (1 min), healthchecks.io for backup, disk 80% alert
**Verified:** nmap shows 22/80/443 only; zero 5xx across a deploy under load

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/server-setup.md 137 lines · 4.4 KB
# Fresh server setup (Ubuntu/Debian)

Read on a newly provisioned VPS. Keep the initial root session open until a new login
as the deploy user works. Commands assume Ubuntu 24.04 LTS or Debian 12/13; adjust
package names elsewhere.

## 1. Users and SSH

```bash
# as root
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys && chmod 600 /home/deploy/.ssh/authorized_keys
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy   # or require a password; team choice
chmod 440 /etc/sudoers.d/deploy
```

```bash
# /etc/ssh/sshd_config.d/10-hardening.conf
cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
X11Forwarding no
EOF
sshd -t && systemctl reload ssh     # service may be named sshd on some distros
```

Test from a second terminal: `ssh deploy@server` works, `ssh root@server` fails. Only
then close the root session.

Changing the SSH port reduces log noise but is not a security control; if you change it,
update the firewall first.

## 2. Firewall

```bash
apt-get update && apt-get install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH          # or: ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp          # HTTP/3
ufw --force enable
ufw status verbose
```

Also configure the provider's cloud firewall with the same allowlist. It sits outside
the host, so Docker cannot bypass it.

### Docker and UFW

Docker inserts iptables rules for published ports that are evaluated before UFW's
rules, so `ports: "8080:8080"` is reachable from the internet regardless of UFW.

Choose one:

1. Publish only the reverse proxy's ports; everything else uses `expose:` or internal
   networks (recommended).
2. Bind published ports to loopback: `"127.0.0.1:5432:5432"`.
3. Add rules to the `DOCKER-USER` chain (persist them) or use a maintained helper such
   as ufw-docker, if containers must publish ports to limited sources.

Verify with `nmap` from outside after every compose change that touches `ports:`.

## 3. Updates and time

```bash
apt-get install -y unattended-upgrades apt-listchanges
dpkg-reconfigure -f noninteractive unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades: set Automatic-Reboot "true" and
# Automatic-Reboot-Time "04:00" only if the user agrees to unattended reboots
timedatectl set-timezone UTC
timedatectl status          # System clock synchronized: yes
```

## 4. Swap (small instances)

```bash
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
```

## 5. Docker Engine (official repository)

Install Docker Engine and the Compose plugin from Docker's apt repository following the
current docs.docker.com instructions for your distro (not the distro's older
`docker.io` package and not the convenience script on production). Then:

```bash
usermod -aG docker deploy      # docker group = root-equivalent; only the deploy user
docker version && docker compose version
cat > /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "local",
  "log-opts": { "max-size": "20m", "max-file": "5" },
  "live-restore": true
}
EOF
systemctl restart docker
```

`live-restore` keeps containers running during Docker daemon upgrades.

## 6. Optional: fail2ban

With key-only SSH, brute force cannot succeed, but fail2ban reduces log noise:

```bash
apt-get install -y fail2ban
cat > /etc/fail2ban/jail.d/sshd.local <<'EOF'
[sshd]
enabled = true
maxretry = 5
bantime = 1h
EOF
systemctl enable --now fail2ban && fail2ban-client status sshd
```

## 7. Checklist before going live

- [ ] `ssh root@` refused; password auth refused
- [ ] `nmap -Pn -p- <ip>` from outside shows only 22, 80, 443
- [ ] `ufw status` and cloud firewall match
- [ ] unattended-upgrades enabled; reboot policy agreed
- [ ] Docker from official repo; log rotation configured
- [ ] DNS A/AAAA records point at the server (AAAA removed if IPv6 is not configured)
- [ ] Backups configured and one restore tested
- [ ] External uptime monitor and disk alert configured
- [ ] Runbook written: how to deploy, roll back, restore, and reach the rescue console

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc