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 (1)

Collapse
 
nikhil_patel_10 profile image
Nikhil Patel •

The idea of keeping Postgres completely off the public host is a great reminder that a secure deployment often starts with the network boundaries. Using Caddy as the only public entry point and keeping the app and database on the Docker network makes the architecture much easier to reason about.