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.

  • Cloud Platforms
  • $vps-deploys
  • 190 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- vps-deploys

Installs to ~/.agents/skills/vps-deploys/ for every project on this machine.

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

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.

~/.agents/skills/vps-deploys/SKILL.md

--- name: vps-deploys description: Deploys web apps to a single VPS (Hetzner, DigitalOcean, Linode, OVH, Lightsail) with Docker Compose behind Caddy or Traefik with automatic HTTPS, or… license: MIT ---

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

  1. Read AGENTS.md, Dockerfile, compose.yaml (or docker-compose.yml), any deploy.sh, CI workflows and .env.example.
  2. Ask for: provider, OS (prefer Ubuntu LTS or Debian stable), the domain and DNS access, and whether a database runs on the same box.
  3. 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.
  4. 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

SituationApproach
One or two apps, team comfortable with ComposeDocker Compose plus Caddy, deployed by CI over SSH
Several apps, want a dashboard, preview deploys, managed databases on the boxCoolify or Dokploy
Multiple servers, rolling container deploys from a CLIKamal (see kamal-deploys)
Need autoscaling or multi-regionA 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 expose and 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 --wait returns only after health checks pass, and fails the job otherwise.
  • Store the server’s host key in known_hosts via a secret; do not disable host key checks.
  • The deploy user should be in the docker group 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_restore into a scratch database and run a sanity query.
  • restic forget --prune deletes 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 run docker system prune --volumes on 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/healthz returns 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 --wait and 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 -v deletes named volumes; never run it on production without explicit confirmation.
  • The latest tag 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem