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