Build Log: 3-Tier Docker Compose Stack — PostgreSQL, Redis, Node.js & Workers
The Bottom Line
Docker Compose in 2026 (Compose Spec v5 + Docker Bake) cuts multi-service startup by ~40% over legacy setups. This build walks through a production-grade 3-tier stack — PostgreSQL 17, Redis 7, Node.js API, and a Python worker — with health-chained dependencies, profiled services, and hot-reload development. One docker compose up boots everything.
Why a 3-Tier Compose Stack?
Every project that outgrows a single container needs service orchestration. A 3-tier pattern — database → cache → application → workers — is the sweet spot: complex enough to teach real-world patterns (health checks, dependency ordering, resource limits, network isolation), simple enough to fit in one compose.yaml file.
I built this for toolbrain.net's internal tooling pipeline. The same file runs on my dev machine, on the VPS, and in CI — zero changes between environments.
Step 1: Project Layout
Structure matters. Get it right once and every service knows where to find its config, init scripts, and Dockerfiles.
taskflow/
├── compose.yaml # Single source of truth
├── .env # Secrets (never committed)
├── api/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
├── worker/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py
└── db/
└── init/
└── schema.sql # Auto-loaded by PostgreSQL
Note the compose.yaml filename — Compose Spec v5.0.0 prefers this over docker-compose.yml. The legacy docker-compose (hyphenated binary) is fully deprecated as of 2025.
Step 2: The compose.yaml — Core Architecture
This is the heart of the build. Every service declares its image, ports, environment, network access, and — critically — its dependency conditions.
name: taskflow
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskflow"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
command: redis-server --appendonly yes --maxmemory 128mb
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
api:
build: ./api
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://taskuser:${POSTGRES_PASSWORD}@db:5432/taskflow
REDIS_URL: redis://cache:6379
depends_on:
db: { condition: service_healthy }
cache: { condition: service_started }
networks:
- frontend
- backend
develop:
watch:
- action: sync
path: ./api
target: /app
- action: rebuild
path: ./api/package.json
worker:
build: ./worker
environment:
DATABASE_URL: postgres://taskuser:${POSTGRES_PASSWORD}@db:5432/taskflow
REDIS_URL: redis://cache:6379
depends_on:
db: { condition: service_healthy }
cache: { condition: service_started }
networks:
- backend
profiles:
- with-worker
deploy:
replicas: 2
resources:
limits:
cpus: "0.50"
memory: 256M
networks:
frontend: { driver: bridge }
backend: { driver: bridge }
volumes:
pgdata:
redisdata:
Three design decisions worth calling out:
- Network segregation — The API service sits on both
frontend(exposed to the host) andbackend(isolated database layer). The database sees only the backend network. Zero exposure surface. - Health-chained dependencies —
depends_on.condition: service_healthywaits for PostgreSQL'spg_isreadyto pass, not just for the container to start. This eliminates the "PostgreSQL not ready" race condition that plagues naive Compose setups. - Profiled workers — The worker service uses
profiles: [with-worker]. It only starts when you explicitly pass--profile with-worker. Great for CI where you don't need background workers, or for saving memory on dev laptops.
Step 3: Development Workflow — Hot Reload
The develop.watch block in the API service is the killer feature from Compose Spec v5. It replaces the old pattern of mounting volumes with nodemon or similar watchers:
docker compose watch
This single command starts all services and watches for file changes:
- sync — Changed files in
./apiare mirrored into/appinstantly. No rebuild needed. - rebuild — Changes to
package.jsontrigger a full image rebuild. The watcher detects the transitive dependency change automatically.
Step 4: Production Optimizations
For production, add a compose.prod.yaml override with three changes:
- Nginx reverse proxy in front of the API service for SSL termination, rate limiting, and static asset serving.
- Resource constraints — CPU and memory limits on every service so one runaway container can't OOM the host.
- Restart policies —
restart: unless-stoppedon stateful services (db, cache) andrestart: on-failureon stateless ones (api, worker).
# compose.prod.yaml
services:
api:
restart: on-failure
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
db:
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
Run with: docker compose -f compose.yaml -f compose.prod.yaml up -d.
Key Takeaways
- Health checks are not optional. Without them, your API will crash-loop because the database isn't accepting connections yet.
- Profile your workers. Not every environment needs background task runners. Profiles let you opt in.
- Use .env files for secrets. The
compose.yamlreferences${POSTGRES_PASSWORD}— the actual value lives in.env(gitignored). - Network isolation matters. The database should never be on the public-facing network. Two networks cost nothing and prevent whole classes of exposure.
Resources
📖 Related Reads
- NiteAgent — AI agent development, frameworks, and production patterns
Cross-links automatically generated from ToolBrain.
← Back to all posts