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) and backend (isolated database layer). The database sees only the backend network. Zero exposure surface.
  • Health-chained dependenciesdepends_on.condition: service_healthy waits for PostgreSQL's pg_isready to 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 ./api are mirrored into /app instantly. No rebuild needed.
  • rebuild — Changes to package.json trigger 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:

  1. Nginx reverse proxy in front of the API service for SSL termination, rate limiting, and static asset serving.
  2. Resource constraints — CPU and memory limits on every service so one runaway container can't OOM the host.
  3. Restart policiesrestart: unless-stopped on stateful services (db, cache) and restart: on-failure on 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.yaml references ${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