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:
-
The database is reachable from the internet (you published
5432“just for debugging”). -
No real HTTPS — you tested on
http://IP:3000and called it done. -
Secrets in the repo —
.env.examplevalues 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)
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
The stack (app + Postgres + Caddy)
Create a project folder:
mkdir -p ~/apps/myapp && cd ~/apps/myapp
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:
Caddyfile
app.example.com {
encode gzip
reverse_proxy app:8080
}
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
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -base64 32)" > .env
chmod 600 .env
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
}
…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
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"
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
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
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 .
Confirm checklist
- [ ] Only 80/443 (and SSH) intentional on the host
- [ ]
https://your.domainworks; 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) - [ ]
.envischmod 600, not in git - [ ] Named volumes for DB + Caddy data
- [ ] Healthcheck so Caddy does not proxy a migrating app forever
Failure checklist (≥5)
-
Published
5432“temporarily” — scanners do not care that it was temporary. Remove the publish. -
reverse_proxy localhost:8080inside Caddy-in-Docker — wrong localhost. Useapp:8080on the Compose network (or publish the app on127.0.0.1and run Caddy on the host). -
DNS not pointed yet — ACME fails; check
docker compose logs caddy. - Burned Let’s Encrypt quota — use staging CA while iterating.
-
:latestin prod — surprise breakage on pull day. Pin tags. - Reused a laptop volume on the server — passwords and schemas diverge; prefer a fresh volume for first prod boot.
-
App still published on
0.0.0.0:3000— you bypassed Caddy and skipped TLS. Drop the publish or bind127.0.0.1only 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)
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.