Docker Compose Full Stack
A production-style docker-compose.yml running four services — Nginx reverse proxy, a Node.js web app, PostgreSQL, and Redis — with health-check-aware startup ordering, restart policies, and secrets loaded from a .env file.
Overview
This Compose file mirrors the architecture of many production web applications: an Nginx reverse proxy handles TLS termination and serves as the single entry point for HTTP/HTTPS traffic; a Node.js application server handles business logic and connects to a PostgreSQL database and a Redis cache; and all four services run as isolated containers on a shared Docker network created automatically by Compose. This is the same architecture you would find on a VM or a single-node Docker host running a real application.
Each service has a restart: unless-stopped policy, which means Docker will automatically restart crashed containers — essential for unattended deployments. The PostgreSQL and Redis services include healthchecks so that the web service waits for both datastores to be fully operational before starting, preventing connection errors during startup. Nginx depends only on the web service being running (not healthy, since the web app doesn't expose a healthcheck endpoint in this example).
Complete YAML
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- web
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
environment:
NODE_ENV: production
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
volumes:
postgres_data:
redis_data:
Environment variable substitution
The ${DB_PASSWORD} syntax in the Compose file is a shell-style variable substitution. When Compose reads the file, it replaces ${DB_PASSWORD} with the value of the DB_PASSWORD environment variable from the shell that ran docker compose up. Docker Compose also automatically reads a .env file in the same directory as the Compose file and loads any variables defined there — so you can create a .env file with DB_PASSWORD=supersecret and Compose will substitute it without any additional configuration.
This pattern keeps sensitive values like database passwords out of your Compose file (which is usually committed to version control) and into the .env file (which should be listed in .gitignore). The same password value flows into both the db service's POSTGRES_PASSWORD environment variable (used by PostgreSQL to set the user's password) and the web service's DATABASE_URL connection string. Changing the password in .env and restarting the stack updates both services consistently.
You can provide a default value using the ${VARIABLE:-default} syntax — for example, ${DB_PASSWORD:-changeme} would use "changeme" if DB_PASSWORD is not set. This is useful for development where you want the stack to start without requiring a .env file, while still requiring the variable to be set explicitly in production (by omitting the default).
Restart policies and Nginx mounts
The restart: unless-stopped policy tells the Docker daemon to restart a container automatically if it exits — whether due to an application crash, an out-of-memory kill, or a Docker daemon restart (e.g. after a system reboot). The container will not be restarted only if you explicitly stop it with docker compose stop or docker stop. This behaviour makes unless-stopped the right default for any container that should act as a long-running service.
The alternative policies are: always (restarts even if you manually stopped it, which can be surprising), on-failure (restarts only on non-zero exit codes, useful for one-shot job containers), and no (the default — never restart). For a production deployment, unless-stopped gives you automatic recovery without the "it restarts even when I asked it to stop" footgun of always.
The Nginx volumes use :ro (read-only) mount flags: ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro. This prevents the Nginx process from modifying the config file, which is a useful defence-in-depth measure — a compromised or misbehaving Nginx process cannot alter its own configuration or persist changes to disk. The SSL certificates directory is similarly mounted read-only. Nginx only needs to read these files, never write them, so read-only mounts are the correct and more secure choice.
Redis configuration and multi-service healthchecks
Redis configuration is typically done via a redis.conf file or via command-line flags. The Compose file uses the command key to override the container's default startup command and pass Redis server flags directly. --maxmemory 256mb caps Redis's memory usage at 256 MB — without a limit, Redis will consume as much memory as it can, which can starve other processes on the host. --maxmemory-policy allkeys-lru tells Redis to evict the least-recently-used keys when the memory limit is reached, which is the correct policy for a general-purpose cache where you want the working set to stay hot.
The Redis healthcheck uses ["CMD", "redis-cli", "ping"] — the exec form without a shell. redis-cli ping sends a PING command to the Redis server and exits 0 if it receives a PONG response. This is the standard Redis liveness probe, analogous to pg_isready for PostgreSQL. The retries: 3 and interval: 10s give Redis 30 seconds to become healthy before Compose considers the service unhealthy and the web container's startup is blocked.
The web service's depends_on lists both db and redis with condition: service_healthy. Compose waits for both services to pass their healthchecks before starting the web container — the two healthchecks run in parallel, so the total wait time is the longer of the two, not their sum. This multi-dependency pattern is straightforward to extend: add more services to depends_on and Compose handles the parallel health-check waiting automatically.
POSTGRES_PASSWORD: ${DB_PASSWORD} environment variable with Docker secrets (secrets: block) for stronger isolation. Docker secrets are mounted as files in the container rather than environment variables, making them inaccessible to child processes that shouldn't need them and keeping them out of docker inspect output.