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.
- name
vps-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
- You want a managed platform instead of a server — use paas-deploys
- You manage a fleet of hosts declaratively — use ansible-playbooks
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.
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 (
nmapfrom 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 to127.0.0.1and 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) anddocker system prune -a --volumeson production need explicit user confirmation.down -vdeletes 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 8080re-resolves the Compose service name so Caddy sees every replica during a rollout;lb_try_durationretries requests while a replica is swapped, andfail_durationenables 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, thenpg_restoreinto 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
localdriver with rotation (above) or ship to a hosted log service; journald retention configured. - Monitoring: an external uptime check on
/healthzwith 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
| Signal | Move to |
|---|---|
| Downtime from host failure is unacceptable | Second server + load balancer, or managed platform |
| Database needs PITR, replicas, minimal ops | Managed Postgres, keep app on VPS |
| Several servers to deploy to | Kamal, Ansible (see ansible-playbooks) |
| Team lacks ops time | PaaS (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. Use127.0.0.1:5432:5432or no ports at all.restart: alwaysplus a crash-looping container fills logs; useunless-stoppedand rotated logs.- Running
docker compose up -dafter editing.envrecreates 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
# 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.