VPS Deploys skill for Codex
One server, done properly, with tested backups. Codex sets up a single-server deployment that can be rebuilt from the repo: images built in CI with immutable tags, Compose services with health checks and log rotation, Caddy terminating TLS, and no database port published past the firewall. It chooses between Compose, Coolify and Kamal by situation, ships backups off the box with restic and pg_dump, and insists on a restore test.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- vps-deploys Installs to ~/.agents/skills/vps-deploys/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install vps-deploys The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project vps-deploys Installs to .agents/skills/vps-deploys/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/vps-deploys.zipunzip -o vps-deploys.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) vps-deploys PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $vps-deploys, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Deploys web apps to a single VPS (Hetzner, DigitalOcean, Linode, OVH, Lightsail) with Docker Compose behind Caddy or Traefik with automatic HTTPS, or through a self-hosted PaaS such as Coolify or Dokploy - image builds in CI, health checks, near-zero-downtime updates, log rotation, firewall gotchas with Docker, and off-server backups with restic and pg_dump that are restore-tested. Use when moving an app to a VPS, setting up Coolify, a container port is exposed to the internet by accident, the disk fills up, or there is no backup plan.
Use it when
- Moving an app from a PaaS or local machine onto a VPS
- Setting up Docker Compose with a reverse proxy and automatic TLS
- Installing or configuring Coolify or Dokploy
- A single server has no backup or no tested restore
Use something else when
- You deploy containers to several servers with rolling updates: use kamal-deploys
- You only need SSH, firewall and user hardening on the box: use linux-server-hardening
How to check the result
The health URL returns 200 over HTTPS with a valid certificate, an external scan shows only ports 22, 80 and 443, and a backup restores into a scratch database.
The mistake it prevents
Publishing a database port in Compose and trusting ufw, while Docker's own iptables rules expose it to the internet.
A VPS deployment is production-grade when the server can be rebuilt from the repo and a backup in under an hour, only ports 22, 80 and 443 are reachable, TLS renews itself, deploys do not require SSH-ing in and editing files, and backups leave the machine and have been restored at least once.
Inspect first
- Read
AGENTS.md,Dockerfile,compose.yaml(ordocker-compose.yml), anydeploy.sh, CI workflows and.env.example. - Ask for: provider, OS (prefer Ubuntu LTS or Debian stable), the domain and DNS access, and whether a database runs on the same box.
- SSH into the server, package installs and DNS changes need network and the user’s credentials. Codex should prepare files and commands; the user runs or approves anything that touches the live server.
- Baseline server security (SSH keys only, unattended upgrades, fail2ban) is covered by linux-server-hardening; do that first on a fresh box.
Choose the approach
| Situation | Approach |
|---|---|
| One or two apps, team comfortable with Compose | Docker Compose plus Caddy, deployed by CI over SSH |
| Several apps, want a dashboard, preview deploys, managed databases on the box | Coolify or Dokploy |
| Multiple servers, rolling container deploys from a CLI | Kamal (see kamal-deploys) |
| Need autoscaling or multi-region | A VPS is the wrong tool; use a PaaS or Kubernetes |
Compose plus Caddy baseline
# compose.yaml
services:
app:
image: ghcr.io/acme/web:${APP_TAG:-latest}
restart: unless-stopped
env_file: .env
expose: ["3000"] # internal only, no host port
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5
depends_on:
db: { condition: service_healthy }
logging: &logging
driver: json-file
options: { max-size: "10m", max-file: "5" }
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
POSTGRES_DB: app
secrets: [pg_password]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
logging: *logging
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
logging: *logging
volumes: { pgdata: {}, caddy_data: {}, caddy_config: {} }
secrets:
pg_password: { file: ./secrets/pg_password }
# Caddyfile
app.example.com {
encode zstd gzip
reverse_proxy app:3000 {
health_uri /healthz
}
}
Caddy obtains and renews certificates automatically once DNS points at the server and
ports 80 and 443 are open. Persist /data, or every restart re-requests certificates and
can hit Let’s Encrypt rate limits.
The Docker firewall trap
Docker writes its own iptables rules, so a published port (ports: ["5432:5432"]) is
reachable from the internet even when ufw says it is denied.
- Do not publish database or internal ports at all; use
exposeand the Compose network. - If a host port is needed for local tools, bind it to loopback:
"127.0.0.1:5432:5432". - Add a provider-level cloud firewall (Hetzner, DigitalOcean, AWS security groups) allowing only 22, 80, 443 as a second layer that Docker cannot bypass.
Verify from outside the server: nc -zv <server-ip> 5432 must fail.
Deploy pipeline
Build images in CI, push to a registry, pull on the server. Never build on the production box.
# .github/workflows/deploy.yml (excerpt)
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/acme/web:${{ github.sha }}
- name: Deploy
run: |
ssh -o StrictHostKeyChecking=yes deploy@${{ secrets.HOST }} \
"cd /srv/app && APP_TAG=${{ github.sha }} docker compose pull app \
&& APP_TAG=${{ github.sha }} docker compose up -d --wait app"
docker compose up -d --waitreturns only after health checks pass, and fails the job otherwise.- Store the server’s host key in
known_hostsvia a secret; do not disable host key checks. - The deploy user should be in the
dockergroup only; note that this group is effectively root. - Production deploys from CI should run on a protected branch or environment with required approval.
Compose recreates the container, causing a few seconds of 502s. For near-zero downtime:
run two replicas behind Caddy (reverse_proxy app-blue:3000 app-green:3000 with health
checks) and update one at a time, or use Kamal or Coolify, which handle the swap.
Coolify or Dokploy
- Install with the official script from the project docs on a fresh server (needs network and root). Read the script before running it and get the user’s go-ahead.
- Put the dashboard behind its own domain with HTTPS and strong auth; it controls Docker on the host.
- Connect the Git repository, set build pack (Dockerfile or Nixpacks), env vars and health check path in the UI; enable automatic deploys per branch.
- Configure its built-in database backups to S3-compatible storage, and still test a restore.
Backups
# Nightly via systemd timer or cron on the host
docker compose exec -T db pg_dump -U postgres -Fc app > /var/backups/app-$(date +%F).dump
restic -r s3:s3.eu-central-1.amazonaws.com/acme-backups backup /var/backups /srv/app/uploads
restic -r s3:s3.eu-central-1.amazonaws.com/acme-backups forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
- Backups go off the server and ideally off the provider. Provider snapshots are a convenience, not a backup strategy.
- Restore test monthly:
pg_restoreinto a scratch database and run a sanity query. restic forget --prunedeletes old snapshots; confirm the retention policy with the user before enabling it.
Operations checklist
- Unattended security upgrades on; reboot policy decided.
- Log rotation on every container (
max-size), journald capped. - Disk alert at 80%;
docker image prune -a --filter "until=168h"scheduled. Never rundocker system prune --volumeson a server with data. - Uptime check from outside on the health URL.
- Runbook: how to rebuild the server from scratch and restore the latest backup.
Verification
-
curl -I https://app.example.com/healthzreturns 200 with a valid certificate. - External port scan of the server shows only 22, 80, 443 (scan only servers the user owns).
- A deploy from CI completes with
--waitand the old version is replaced. - A backup has been restored to a scratch database successfully.
Gotchas
- Running Postgres in Compose without a volume loses data on
docker compose down.docker compose down -vdeletes named volumes; never run it on production without explicit confirmation. - The
latesttag makes rollbacks guesswork. Deploy immutable tags (commit SHA) and keep the previous tag for rollback. - Small VPSes run out of memory during builds and when Postgres and Node share 1 GB. Add swap and size the box for peak, not idle.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "VPS Deploys"
short_description: "One server, done properly, with tested backups"
default_prompt: "$vps-deploys Deploy this app to my Hetzner VPS with Docker Compose and HTTPS" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.