Nginx reverse proxy
The common shape: Nginx takes the traffic and forwards it to an application container.
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- api
restart: unless-stopped
api:
image: myapi:latest
expose:
- "3000" # visible to the compose network only, not the host
restart: unless-stopped
In the matching nginx.conf, proxy_pass must use the service name:
location / {
proxy_pass http://api:3000; # not 127.0.0.1
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Services in one compose project share a network and resolve each other by name. Inside a container, 127.0.0.1 means that container, so writing it guarantees a 502.
Note that api uses expose rather than ports: it only needs to be reachable by Nginx, never from the host. One fewer open port is one fewer way in.
PostgreSQL
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD} # from .env, never hard-coded
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro # .sql/.sh run on first init
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
restart: unless-stopped
volumes:
pgdata:
Three things worth knowing:
The data must be on a volume. Without a mount at /var/lib/postgresql/data, docker compose down destroys the database along with the container. A named volume is used here rather than a bind mount: better performance, fewer permission headaches.
POSTGRES_PASSWORD only applies on first initialization. Once the volume contains a database, Postgres uses it as-is and ignores these variables completely. That is why changing the password appears to do nothing.
Scripts in /docker-entrypoint-initdb.d also run exactly once, when the data directory is empty. Re-running them means docker compose down -v, which deletes the volume and all its data. Never do that in production.
Redis
services:
cache:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
redisdata:
The official image ships with no password and no persistence.
If Redis is only a cache, skipping the volume and appendonly is perfectly reasonable — it will warm up again. The moment you keep sessions, job queues or leaderboards in it, mount a volume and enable --appendonly yes, or one restart wipes it.
And again: do not give it ports. A publicly exposed, password-less Redis is one of the most reliable ways to get compromised.
Full stack
The three services above, plus the application itself:
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
restart: unless-stopped
api:
build: .
expose:
- "3000"
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://:${REDIS_PASSWORD}@cache:6379
depends_on:
db:
condition: service_healthy # actually wait until the DB accepts connections
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
restart: unless-stopped
cache:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redisdata:/data
restart: unless-stopped
volumes:
pgdata:
redisdata:
The matching .env (add it to .gitignore):
DB_PASSWORD=a long random string
REDIS_PASSWORD=a different long random string
Design choices worth noticing:
- Only
proxydeclaresports. The whole stack exposes exactly one port to the world; the database and Redis sit on an internal network, untouchable from outside. apiwaits withcondition: service_healthy. Plaindepends_on: [db]waits only for the container to start, not for Postgres to accept connections, so startup would fail with connection refused.- Every password comes from
.envinterpolation, which keeps the compose file itself safe to commit.
Bring it up:
docker compose up -d --build
docker compose logs -f api
Next steps
- Generate files like these from checkboxes with the Docker Compose Generator;
- For the semantics of ports, volumes, depends_on and healthcheck, see the Docker Compose Guide;
- When it will not start, cannot connect, or lost its data, see Debugging Docker Compose Startup Failures;
- For the Nginx side of that config, see Nginx Config Examples.