DEV Community

Cover image for Deploy a Docker app on a VPS with Caddy (keep Postgres off the internet)
Amorizz
Amorizz

Posted on

Deploy a Docker app on a VPS with Caddy (keep Postgres off the internet)

TL;DR

  • Most side projects die the same way on a cheap VPS: the app is up, Postgres is on :5432, and HTTPS is “later.”
  • The boring fix is one Compose file: Caddy on 80/443, your app on the Docker network, database unpublished.
  • Copy the stack below, point DNS, prove https://your.domain/health (or /), then ship.

Why your first VPS deploy feels cursed

You can docker compose up on a laptop in five minutes. On a public IP the same file often means:

  1. The database is reachable from the internet (you published 5432 “just for debugging”).
  2. No real HTTPS — you tested on http://IP:3000 and called it done.
  3. Secrets in the repo.env.example values made it to production.

None of that is exotic. It is the default path for a Node/Go/Python API with Postgres. This guide is the minimum production shape that fixes those three without Kubernetes.

You will end with:

Internet → :443 Caddy (TLS) → app:8080 (Docker net)
                              → db:5432 (Docker net only)
Enter fullscreen mode Exit fullscreen mode

Only Caddy listens on the public host. Everything else stays inside Compose.

What you need

  • A VPS (Ubuntu 22.04/24.04 is fine) with a public IPv4
  • A domain (or subdomain) whose A record points at that IP
  • Docker Engine + Compose plugin installed
  • Ports 80 and 443 open (and 22 for SSH). Do not open 5432.

If Docker is missing:

# official convenience script — or use your distro’s docker.io + compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
# log out/in, then:
docker compose version
Enter fullscreen mode Exit fullscreen mode

The stack (app + Postgres + Caddy)

Create a project folder:

mkdir -p ~/apps/myapp && cd ~/apps/myapp
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

Replace the app image with yours. The important bits are the network, no DB publish, and Caddy as the only public ports.

services:
  caddy:
    image: caddy:2.8
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      app:
        condition: service_healthy
    networks: [web]

  app:
    image: your-registry/your-app:1.2.3   # pin a tag — avoid :latest in prod
    restart: unless-stopped
    env_file: [.env]
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      PUBLIC_URL: https://app.example.com
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 6
    networks: [web]
    # no ports: — Caddy reaches it as http://app:8080 on the Docker network

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
    networks: [web]
    # CRITICAL: no ports: section — Postgres stays off the host

networks:
  web:

volumes:
  postgres_data:
  caddy_data:
  caddy_config:
Enter fullscreen mode Exit fullscreen mode

Caddyfile

app.example.com {
  encode gzip
  reverse_proxy app:8080
}
Enter fullscreen mode Exit fullscreen mode

Caddy talks to the service name app on the shared Compose network. Do not proxy to localhost here — inside the Caddy container, localhost is Caddy itself.

Persist /data (the caddy_data volume). Without it, every recreate re-issues certificates and can burn Let’s Encrypt rate limits.

.env (on the server only)

POSTGRES_PASSWORD=$(openssl rand -base64 32)
# put the same value in .env — chmod 600 .env — never commit it
Enter fullscreen mode Exit fullscreen mode
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -base64 32)" > .env
chmod 600 .env
Enter fullscreen mode Exit fullscreen mode

Point DNS for app.example.com at the VPS before you expect a trusted cert. While debugging certs, you can temporarily add:

{
  acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Enter fullscreen mode Exit fullscreen mode

…so a broken config does not eat your weekly production quota. Remove staging when it works.

Bring it up

docker compose up -d
docker compose ps
docker compose logs -f caddy
Enter fullscreen mode Exit fullscreen mode

Expected: caddy, app, and db healthy. Prove the boundary:

# public path
curl -sS -o /dev/null -w "%{http_code}\n" https://app.example.com/health

# Postgres must NOT be on the host
ss -lntp | grep 5432 || echo "good: nothing on host :5432"
docker compose port db 5432 2>/dev/null || echo "good: db has no published port"
Enter fullscreen mode Exit fullscreen mode

If /health is not your route, hit / or whatever your app exposes. The point is: TLS works on the public name, and 5432 is invisible from the host.

Firewall (keep it boring)

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Enter fullscreen mode Exit fullscreen mode

Do not ufw allow 5432. Note: Docker’s published ports can bypass UFW on some setups — another reason to never publish the DB in Compose at all.

Updates without drama

docker compose pull app
docker compose up -d app
docker image prune -f
Enter fullscreen mode Exit fullscreen mode

Pin image tags. Back up postgres_data (and caddy_data if you care about certs surviving a wipe) before you treat the VPS as disposable.

# example volume backup idea — adapt to your backup tool
docker run --rm -v myapp_postgres_data:/data -v "$(pwd)":/backup alpine \
  tar czf /backup/postgres_data.tgz -C /data .
Enter fullscreen mode Exit fullscreen mode

Confirm checklist

  • [ ] Only 80/443 (and SSH) intentional on the host
  • [ ] https://your.domain works; HTTP redirects to HTTPS
  • [ ] App reached via service name from Caddy, not a published app port
  • [ ] Postgres not published (docker compose ps / no host :5432)
  • [ ] .env is chmod 600, not in git
  • [ ] Named volumes for DB + Caddy data
  • [ ] Healthcheck so Caddy does not proxy a migrating app forever

Failure checklist (≥5)

  1. Published 5432 “temporarily” — scanners do not care that it was temporary. Remove the publish.
  2. reverse_proxy localhost:8080 inside Caddy-in-Docker — wrong localhost. Use app:8080 on the Compose network (or publish the app on 127.0.0.1 and run Caddy on the host).
  3. DNS not pointed yet — ACME fails; check docker compose logs caddy.
  4. Burned Let’s Encrypt quota — use staging CA while iterating.
  5. :latest in prod — surprise breakage on pull day. Pin tags.
  6. Reused a laptop volume on the server — passwords and schemas diverge; prefer a fresh volume for first prod boot.
  7. App still published on 0.0.0.0:3000 — you bypassed Caddy and skipped TLS. Drop the publish or bind 127.0.0.1 only if Caddy is on the host.

Where this pattern shows up next

Same shape — HTTPS edge, app private, Postgres private — applies to dashboards, APIs, and self-hosted tools. One public example of that two-container-behind-proxy idea for exception ingest (official Sentry SDKs + DSN host change, not APM/replay): self-hosting installation.

Discussion

When you first put a side project on a VPS, what bit you first — open Postgres, missing TLS, or secrets in git?

Top comments (0)