This lesson walks through building a complete, production-quality three-tier application stack from scratch using Docker Compose. Every file is annotated so you understand exactly what each line does and why.
100%
Rendering interactive visual diagram...
Project Structure
Project Structure
myapp/
āāā docker-compose.yml
# Main compose fileāāā docker-compose.dev.yml
# Dev overridesāāā docker-compose.prod.yml
# Production overridesāāā .env
# Default env vars (committed ā no secrets)āāā .env.local
# Local overrides (gitignored)āāā .env.production
# Production secrets (never committed)āāā Dockerfile
# Multi-stage buildāāā nginx/
ā āāā nginx.conf
# Nginx configā āāā default.conf
# Virtual host configāāā db/
ā āāā init.sql
# First-time database setupāāā src/
# Application source codeStep 1: The Multi-Stage Dockerfile
A multi-stage Dockerfile keeps the production image lean ā no dev tools, no source maps, no node_modules for the entire dependency tree:
dockerfile
# Dockerfile
# āā Stage 1: Install dependencies āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files first (leverages Docker layer cache)
# Only re-runs npm install when package.json or lock file changes
COPY package.json package-lock.json ./
RUN npm ci --only=production # Production deps only
# āā Stage 2: Build the application āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
FROM node:20-alpine AS builder
WORKDIR /app
# Copy all deps (including dev) for build step
COPY package.json package-lock.json ./
RUN npm ci # All deps for build
COPY . .
RUN npm run build # Next.js / webpack build
# āā Stage 3: Production runtime image āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
FROM node:20-alpine AS runner
WORKDIR /app
# Security: don't run as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
ENV NODE_ENV=production
# Only copy what's needed at runtime (much smaller image)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
# Health check baked into the image
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node_modules/.bin/next", "start"]
Step 2: The .env File
bash
# .env ā default values (safe to commit ā no real secrets)
# Override with .env.local or environment-specific files
# Postgres
POSTGRES_USER=appuser
POSTGRES_DB=myapp
POSTGRES_PORT=5432
# App
NODE_ENV=development
APP_PORT=3000
LOG_LEVEL=info
# Redis
REDIS_PORT=6379
bash
# .env.local ā your personal local overrides (NEVER COMMIT ā add to .gitignore)
POSTGRES_PASSWORD=dev_local_password_change_me
bash
# .gitignore
.env.local
.env.production
.env.*.local
Step 3: The Nginx Config
nginx
# nginx/default.conf
upstream api_backend {
server api:3000; # 'api' is the Docker service name ā DNS resolves automatically
keepalive 32;
}
server {
listen 80;
server_name _;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1000;
# Proxy API requests to Next.js
location /api/ {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
# Serve Next.js pages
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# Health check endpoint for load balancers
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
Step 4: Database Init Script
sql
-- db/init.sql ā runs automatically on first container start
-- (Only runs when the data directory is empty ā not on every restart)
-- Create the application user with limited permissions
CREATE USER appuser WITH PASSWORD 'changeme_in_env';
-- Create the database
CREATE DATABASE myapp OWNER appuser;
-- Connect to the new database
\c myapp
-- Enable useful extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram matching for search
-- Set search path
ALTER ROLE appuser SET search_path TO public;
-- Grant permissions
GRANT ALL PRIVILEGES ON DATABASE myapp TO appuser;
GRANT ALL ON SCHEMA public TO appuser;
Step 5: The Base docker-compose.yml
yaml
# docker-compose.yml ā base config (shared across all environments)
services:
# āā Database āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
restart: unless-stopped
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
# āā Cache āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
cache:
image: redis:7-alpine
command: >
redis-server
--save 60 1
--loglevel warning
--requirepass ${REDIS_PASSWORD}
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
restart: unless-stopped
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
# āā Database Migration (runs once, then exits) āāāāāāāāāāāāāāāāāāāāāāāā
migrate:
build:
context: .
dockerfile: Dockerfile
target: builder # Use builder stage (has dev deps for Prisma CLI)
command: ["npx", "prisma", "migrate", "deploy"]
environment:
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
depends_on:
db:
condition: service_healthy
restart: "no" # Never restart ā it's a one-shot task
networks:
- backend
# āā Application API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
api:
build:
context: .
dockerfile: Dockerfile
target: runner
environment:
NODE_ENV: ${NODE_ENV:-production}
PORT: "3000"
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
REDIS_URL: "redis://:${REDIS_PASSWORD}@cache:6379"
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_healthy
restart: unless-stopped
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# āā Reverse Proxy āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
networks:
- backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"]
interval: 30s
timeout: 5s
retries: 3
networks:
backend:
driver: bridge
volumes:
postgres-data:
redis-data:
Step 6: Development Override File
yaml
# docker-compose.dev.yml ā development additions/overrides
services:
api:
build:
target: builder # Use builder stage (has dev tools like nodemon)
command: ["npm", "run", "dev"] # Hot reload dev server
environment:
NODE_ENV: development
LOG_LEVEL: debug
volumes:
- ./src:/app/src # Hot reload ā changes reflect immediately
- ./public:/app/public
ports:
- "3000:3000" # Also expose API port directly for development
db:
ports:
- "127.0.0.1:5432:5432" # Expose for DBeaver, TablePlus, pgAdmin
cache:
ports:
- "127.0.0.1:6379:6379" # Expose for Redis Insight, redis-cli
# Adminer: web-based DB admin UI (dev only)
adminer:
image: adminer:latest
ports:
- "127.0.0.1:8080:8080"
networks:
- backend
profiles: ["tools"] # Only starts with: docker compose --profile tools up
# Mailhog: catches all outgoing emails in development
mailhog:
image: mailhog/mailhog
ports:
- "127.0.0.1:1025:1025" # SMTP
- "127.0.0.1:8025:8025" # Web UI
networks:
- backend
profiles: ["tools"]
Step 7: Running the Stack
bash
# === Development ===
# Copy and fill in the .env.local
cp .env .env.local
# Edit .env.local: set POSTGRES_PASSWORD, REDIS_PASSWORD
# Start the full dev stack (builds images, runs migrations, starts everything)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# With dev tools (Adminer + Mailhog)
docker compose -f docker-compose.yml -f docker-compose.dev.yml \
--profile tools up -d
# Watch all logs
docker compose logs -f
# Watch just the API
docker compose logs -f api
# Open an interactive psql session
docker compose exec db psql -U appuser -d myapp
# Open a Redis CLI session
docker compose exec cache redis-cli -a ${REDIS_PASSWORD}
# Run a one-off command (e.g., Prisma Studio)
docker compose run --rm api npx prisma studio
# Tear everything down (keeps data)
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
# Tear everything down AND wipe data (fresh start)
docker compose -f docker-compose.yml -f docker-compose.dev.yml down -v
Startup Order Verification
bash
# After docker compose up, verify each service is healthy
docker compose ps
# NAME IMAGE STATUS PORTS
# myapp-db-1 postgres:16-alpine Up (healthy) 5432/tcp
# myapp-cache-1 redis:7-alpine Up (healthy) 6379/tcp
# myapp-migrate-1 myapp-api Exited (0) ā Migration completed
# myapp-api-1 myapp-api Up (healthy) 3000/tcp
# myapp-nginx-1 nginx:alpine Up (healthy) 0.0.0.0:80->80/tcp
# Verify the API is responding
curl http://localhost/api/health
# {"status":"ok","db":"connected","cache":"connected"}
# Check migration ran successfully
docker compose logs migrate
# Prisma schema loaded from prisma/schema.prisma
# Running 3 migration(s)... Done ā
Troubleshooting Common Issues
bash
# Container exits immediately ā check the logs
docker compose logs api
# Or check the exit code
docker compose ps -a
# Port already in use
docker compose down # Make sure nothing is already running
lsof -i :3000 # Find what's using the port on your host
# Database connection refused from API
# Almost always: API started before DB was ready
# Fix: ensure depends_on uses condition: service_healthy with a proper healthcheck
# Volume not updating (bind mount changes not reflected)
docker compose exec api ls /app/src # Verify the bind mount is correct
# Images are stale (not picking up your Dockerfile changes)
docker compose build --no-cache api
docker compose up -d
Summary
You've built a production-quality three-tier stack with:
- Multi-stage Dockerfile ā lean runtime image using only necessary build artifacts
- Nginx reverse proxy ā handles static files, proxies to Node.js, adds security headers and gzip
- Migration service ā runs Prisma migrations exactly once before the API starts, using
condition: service_completed_successfully - Startup ordering ā
db ā cache ā migrate ā api ā nginx, each waiting for real health checks - Override pattern ā
docker-compose.dev.ymladds hot reload, exposed ports for local clients, and optional dev tools via profiles - Security ā database not exposed to the internet, non-root container user, Redis password-protected
In the next lesson, you will master environment variables and secret management across development, staging, and production environments.