Docker Assembly Guide: Industry Best Practices
Version: 1.0 Date: 2026-01-07 Purpose: Comprehensive guide to building optimized, secure, and production-ready Docker containers
Table of Contents
- Introduction
- Multi-Stage Builds
- Layer Caching Optimization
- BuildKit and Build Cache
- Development vs Production Builds
- Security Best Practices
- Performance Optimization
- Language-Specific Patterns
- Docker Compose Patterns
- CI/CD Integration
- Troubleshooting
- Quick Reference
Introduction
Why This Guide Exists
Docker containerization is fundamental to modern software delivery, but poorly configured containers lead to:
- Bloated images (5GB+ when they should be 50MB)
- Slow builds (20+ minutes when they should take 2 minutes)
- Security vulnerabilities (running as root, outdated dependencies)
- Inconsistent environments (works locally, fails in production)
This guide provides battle-tested patterns for building containers that are:
- ⚡ Fast: Optimized layer caching, parallel builds
- 🔒 Secure: Non-root users, minimal attack surface
- 📦 Small: Multi-stage builds, distroless images
- 🔄 Reproducible: Pinned versions, deterministic builds
Document Structure
Each section provides:
- Theory: Why this pattern matters
- Practice: Concrete examples in multiple languages
- Optimization: Advanced techniques for production
- Anti-patterns: Common mistakes to avoid
Prerequisites
- Docker Engine 20.10+ (for BuildKit support)
- Basic understanding of Dockerfiles
- Familiarity with your programming language’s build tools
Multi-Stage Builds
Core Concept
Multi-stage builds use multiple FROM statements in a single Dockerfile, allowing you to:
- Build in a full-featured environment (compilers, dev tools)
- Deploy to a minimal runtime (only dependencies needed to run)
Result: 90%+ reduction in final image size.
Basic Pattern
# Stage 1: Build environmentFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# Stage 2: Production runtimeFROM node:20-alpine AS productionWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesEXPOSE 3000CMD ["node", "dist/index.js"]Advanced Multi-Stage Pattern
# Stage 1: Base dependencies (shared)FROM node:20-alpine AS baseWORKDIR /appCOPY package*.json ./RUN npm ci --only=production
# Stage 2: Development dependenciesFROM base AS dev-depsRUN npm ci
# Stage 3: BuildFROM dev-deps AS builderCOPY . .RUN npm run buildRUN npm run test
# Stage 4: Production (minimal)FROM node:20-alpine AS productionRUN apk add --no-cache dumb-initWORKDIR /appCOPY --from=base /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distUSER nodeEXPOSE 3000ENTRYPOINT ["dumb-init", "--"]CMD ["node", "dist/index.js"]
# Stage 5: Development (full tooling)FROM dev-deps AS developmentCOPY . .EXPOSE 3000 9229CMD ["npm", "run", "dev"]Key Benefits:
basestage shared by all subsequent stages (cache reuse)dev-depsincludes testing/linting toolsproductionstage is minimal (no dev dependencies)developmentstage includes hot-reload and debugging
Selecting Build Targets
# Build for productiondocker build --target production -t myapp:prod .
# Build for developmentdocker build --target development -t myapp:dev .
# Build and run tests (fails if tests fail)docker build --target builder -t myapp:test .Anti-Patterns
❌ Bad: Single-stage with all tools
FROM node:20RUN apt-get update && apt-get install -y \ build-essential python3 git curl vimCOPY . .RUN npm install # Includes dev dependencies!CMD ["node", "index.js"]Problems: 800MB+ image, dev tools in production, security vulnerabilities
✅ Good: Multi-stage with minimal runtime
FROM node:20-alpine AS builderCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpineCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesUSER nodeCMD ["node", "dist/index.js"]Benefits: 150MB image, no dev tools, runs as non-root
Layer Caching Optimization
How Docker Layer Caching Works
Docker builds images as a series of layers:
- Each instruction (
RUN,COPY,ADD) creates a new layer - Layers are cached based on instruction + file content hash
- Cache invalidation: If a layer changes, all subsequent layers rebuild
Golden Rule: Order instructions from least frequently changed to most frequently changed.
Optimal Instruction Ordering
# 1. Base image (rarely changes)FROM rust:1.75-alpine AS builder
# 2. System dependencies (rarely change)RUN apk add --no-cache musl-dev openssl-dev
# 3. Dependency manifest (changes occasionally)WORKDIR /appCOPY Cargo.toml Cargo.lock ./
# 4. Dummy build to cache dependenciesRUN mkdir src && echo "fn main() {}" > src/main.rsRUN cargo build --releaseRUN rm -rf src
# 5. Source code (changes frequently)COPY src ./src
# 6. Final build (only rebuilds if source changed)RUN cargo build --release
# Production stageFROM alpine:3.19COPY --from=builder /app/target/release/myapp /usr/local/bin/CMD ["myapp"]Cache Behavior:
- Steps 1-4 cached unless dependencies change
- Only step 6 rebuilds when source code changes
- Build time: 2 minutes → 10 seconds (after first build)
Dependency Caching Pattern (Node.js)
FROM node:20-alpine AS builderWORKDIR /app
# Copy only dependency manifests firstCOPY package.json package-lock.json ./
# Install dependencies (cached unless package.json changes)RUN npm ci
# Copy source code (invalidates cache only for this layer)COPY . .
# Build applicationRUN npm run buildWhy This Works:
npm ciruns only whenpackage.jsonorpackage-lock.jsonchanges- Source code changes don’t trigger dependency reinstall
- Typical speedup: 5 minutes → 30 seconds
Dependency Caching Pattern (Rust)
FROM rust:1.75-slim AS builderWORKDIR /app
# Copy manifestsCOPY Cargo.toml Cargo.lock ./
# Create dummy source to build dependenciesRUN mkdir src && \ echo "fn main() {println!(\"dummy\")}" > src/main.rs && \ cargo build --release && \ rm -rf src
# Copy real sourceCOPY src ./src
# Build real application (dependencies already cached)RUN cargo build --releaseRust-Specific Challenge: Cargo doesn’t have a “install dependencies only” command, so we build a dummy binary first.
Dependency Caching Pattern (Python)
FROM python:3.12-slim AS builderWORKDIR /app
# Install dependencies firstCOPY requirements.txt ./RUN pip install --no-cache-dir -r requirements.txt
# Copy source codeCOPY . ..dockerignore File
Critical for cache efficiency: Exclude files that shouldn’t trigger rebuilds.
.git.gitignore.dockerignore.env.env.**.mdLICENSE
# Build artifactsnode_modulesdistbuildtarget*.pyc__pycache__
# IDE files.vscode.idea*.swp
# Logs*.loglogs
# OS files.DS_StoreThumbs.db
# Test files (unless needed in container)tests*.test.js*.spec.tsImpact: Prevents cache invalidation from irrelevant file changes.
Layer Squashing (When Appropriate)
# Squash all layers into one (for final production images)docker build --squash -t myapp:prod .When to Use:
- ✅ Final production images for distribution
- ✅ Images with many layers (100+)
- ❌ Development (loses cache benefits)
- ❌ CI/CD (breaks layer caching between builds)
BuildKit and Build Cache
Enabling BuildKit
BuildKit is Docker’s next-generation build engine with:
- Parallel builds: Build independent stages simultaneously
- Advanced caching: Remote cache, mount cache
- Security: Secret management without leaking
- Performance: 2-10x faster builds
# Enable BuildKit (one-time setup)export DOCKER_BUILDKIT=1
# Or use buildx (BuildKit as a plugin)docker buildx create --use
# Verifydocker buildx versionMake it permanent (add to ~/.bashrc or ~/.zshrc):
export DOCKER_BUILDKIT=1Cache Mounts
Problem: Installing dependencies on every build is slow, even with layer caching.
Solution: Mount a persistent cache directory during build.
Node.js with Cache Mount
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./
# Mount npm cache during installRUN --mount=type=cache,target=/root/.npm \ npm ci
COPY . .RUN npm run buildImpact: npm’s cache persists across builds, 3-5x faster npm installs.
Rust with Cache Mount
FROM rust:1.75-slim AS builderWORKDIR /app
COPY Cargo.toml Cargo.lock ./COPY src ./src
# Mount cargo registry and git cacheRUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/app/target \ cargo build --release && \ cp target/release/myapp /myapp
FROM debian:bookworm-slimCOPY --from=builder /myapp /usr/local/bin/CMD ["myapp"]Impact: Cargo doesn’t re-download crates on every build, 10x faster for large projects.
Python with Cache Mount
FROM python:3.12-slim AS builderWORKDIR /appCOPY requirements.txt ./
# Mount pip cacheRUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txtGo with Cache Mount
FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod go.sum ./
# Mount Go module cacheRUN --mount=type=cache,target=/go/pkg/mod \ go mod download
COPY . .RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go build -o appSecret Mounts
Problem: Need to access private registries, API keys during build without leaking them into image layers.
Old (Insecure) Way:
# ❌ NEVER DO THISARG NPM_TOKENRUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrcRUN npm installRUN rm .npmrc # Too late! Token is in layer historySecure Way with BuildKit:
# ✅ Secret never enters image layersRUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci# Build with secretdocker buildx build \ --secret id=npmrc,src=$HOME/.npmrc \ -t myapp .Git SSH Keys:
# Clone private repo without leaking SSH keyRUN --mount=type=ssh \ git clone git@github.com:myorg/private-repo.gitdocker buildx build --ssh default -t myapp .Remote Build Cache
Problem: CI/CD builds start from scratch on every run.
Solution: Push/pull build cache to a registry.
# Build and push cache to registrydocker buildx build \ --cache-to type=registry,ref=myregistry.com/myapp:buildcache \ --cache-from type=registry,ref=myregistry.com/myapp:buildcache \ -t myapp:latest \ --push \ .In CI/CD (GitHub Actions example):
- name: Build with cache uses: docker/build-push-action@v5 with: context: . push: true tags: myregistry.com/myapp:latest cache-from: type=registry,ref=myregistry.com/myapp:buildcache cache-to: type=registry,ref=myregistry.com/myapp:buildcache,mode=maxImpact: First build takes 10 minutes, subsequent builds take 1 minute.
Inline Cache
Simpler alternative: Embed cache metadata in the image itself.
docker buildx build \ --cache-to type=inline \ --cache-from myregistry.com/myapp:latest \ -t myapp:latest \ .Trade-off: Slightly larger images, but simpler setup (no separate cache repository).
Development vs Production Builds
Dual-Target Dockerfile
# ----- Shared Base -----FROM node:20-alpine AS baseWORKDIR /appCOPY package*.json ./
# ----- Development Target -----FROM base AS development# Install ALL dependencies (including devDependencies)RUN npm install
# Copy source codeCOPY . .
# Expose app and debugger portsEXPOSE 3000 9229
# Hot-reload with nodemonCMD ["npm", "run", "dev"]
# ----- Production Build -----FROM base AS builder# Install only production dependenciesRUN npm ci --only=production
# Copy source and buildCOPY . .RUN npm run build
# ----- Production Target -----FROM node:20-alpine AS productionWORKDIR /app
# Security: run as non-rootRUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001
# Copy production dependencies and built artifactsCOPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modulesCOPY --from=builder --chown=nodejs:nodejs /app/dist ./distCOPY --chown=nodejs:nodejs package.json ./
USER nodejsEXPOSE 3000CMD ["node", "dist/index.js"]Using with Docker Compose
docker-compose.yml:
services: app: build: context: . target: ${BUILD_TARGET:-development} dockerfile: Dockerfile ports: - "${PORT:-3000}:3000" - "9229:9229" # Debugger volumes: # Mount source code for hot-reload (dev only) - ./src:/app/src:ro - ./package.json:/app/package.json:ro environment: NODE_ENV: ${NODE_ENV:-development} env_file: - .env.localDevelopment:
# Uses 'development' target by defaultdocker-compose up
# Hot-reload works via volume mount# Debugger available on port 9229Production:
# Use production targetBUILD_TARGET=production docker-compose up
# Or with explicit overridedocker-compose -f docker-compose.yml -f docker-compose.prod.yml updocker-compose.prod.yml:
services: app: build: target: production volumes: [] # Remove volume mounts ports: - "3000:3000" # No debugger portDevelopment Features
Hot Reload (Node.js)
FROM node:20-alpine AS developmentWORKDIR /app
# Install nodemon globallyRUN npm install -g nodemon
COPY package*.json ./RUN npm install
COPY . .
# Use nodemon for hot-reloadCMD ["nodemon", "--watch", "src", "--ext", "ts,js", "--exec", "node", "src/index.js"]Hot Reload (Python with Flask)
FROM python:3.12-slim AS developmentWORKDIR /app
COPY requirements.txt requirements-dev.txt ./RUN pip install -r requirements.txt -r requirements-dev.txt
COPY . .
# Flask development server with hot-reloadENV FLASK_ENV=developmentCMD ["flask", "run", "--host=0.0.0.0", "--reload"]Debugging Configuration
FROM node:20-alpine AS development# ...EXPOSE 9229CMD ["node", "--inspect=0.0.0.0:9229", "src/index.js"]VSCode launch.json for remote debugging:
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "attach", "name": "Docker: Attach to Node", "remoteRoot": "/app", "localRoot": "${workspaceFolder}", "address": "localhost", "port": 9229 } ]}Production Features
Health Checks
FROM node:20-alpine AS production# ...HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node healthcheck.js || exit 1healthcheck.js:
const http = require('http');
const options = { host: 'localhost', port: 3000, path: '/health', timeout: 2000,};
const request = http.request(options, (res) => { if (res.statusCode === 200) { process.exit(0); } else { process.exit(1); }});
request.on('error', () => process.exit(1));request.end();Graceful Shutdown
FROM node:20-alpine AS productionWORKDIR /app
# Install dumb-init (PID 1 signal forwarding)RUN apk add --no-cache dumb-init
COPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modules
USER nodeENTRYPOINT ["dumb-init", "--"]CMD ["node", "dist/index.js"]Why dumb-init?:
- Docker sends SIGTERM to PID 1 on shutdown
- Node.js doesn’t handle signals properly as PID 1
- dumb-init forwards signals correctly, enabling graceful shutdown
Application code (app.js):
process.on('SIGTERM', () => { console.log('SIGTERM received, closing server...'); server.close(() => { console.log('Server closed, exiting'); process.exit(0); });
// Force exit after 10 seconds setTimeout(() => { console.error('Forced shutdown after timeout'); process.exit(1); }, 10000);});Security Best Practices
Non-Root User
Default: Containers run as root (UID 0), which is a security risk.
Fix: Create and use a non-root user.
FROM node:20-alpine AS productionWORKDIR /app
# Create user and groupRUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001
# Copy files with correct ownershipCOPY --chown=nodejs:nodejs --from=builder /app/dist ./distCOPY --chown=nodejs:nodejs --from=builder /app/node_modules ./node_modules
# Switch to non-root userUSER nodejs
EXPOSE 3000CMD ["node", "dist/index.js"]Verify:
docker run myapp whoami# Output: nodejs (not root)Minimal Base Images
Image Size and Security Correlation: Smaller images → fewer packages → smaller attack surface.
Base Image Comparison:
| Base Image | Size | Packages | Use Case |
|---|---|---|---|
ubuntu:22.04 | 77 MB | ~200 | Legacy apps, need apt-get |
debian:bookworm-slim | 74 MB | ~100 | Most apps, good compatibility |
alpine:3.19 | 7 MB | ~15 | Size-critical, static binaries |
distroless/static | 2 MB | 0 | Go, Rust (static binaries) |
distroless/base | 20 MB | ~10 | Dynamic binaries, minimal runtime |
scratch | 0 MB | 0 | Single static binary |
Using Alpine
FROM node:20-alpine AS production# Pros: 50MB vs 200MB for debian variant# Cons: Uses musl libc (compatibility issues with native modules)Using Distroless (Google)
# Build stageFROM golang:1.22 AS builderWORKDIR /appCOPY . .RUN CGO_ENABLED=0 go build -o app
# Production stage with distrolessFROM gcr.io/distroless/static-debian12COPY --from=builder /app/app /appENTRYPOINT ["/app"]Distroless Benefits:
- No shell, no package manager (can’t
docker execinto it) - Minimal attack surface
- Google-maintained security updates
Using Scratch (Empty Image)
# Build stageFROM rust:1.75-alpine AS builderWORKDIR /appCOPY . .RUN cargo build --release --target x86_64-unknown-linux-musl
# Production: literally emptyFROM scratchCOPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myappENTRYPOINT ["/myapp"]Result: 5-10MB final image (only your binary).
Scanning for Vulnerabilities
# Scan image with Docker Scoutdocker scout cves myapp:latest
# Scan with Trivytrivy image myapp:latest
# Scan with Snyksnyk container test myapp:latestCI/CD Integration (GitHub Actions):
- name: Scan image uses: aquasecurity/trivy-action@master with: image-ref: myapp:latest severity: 'CRITICAL,HIGH' exit-code: '1' # Fail build on vulnerabilitiesRead-Only Root Filesystem
FROM alpine:3.19# ...USER nobody# Mark filesystem as read-only (runtime flag)docker run --read-only myappFor apps that need tmp writes:
docker run --read-only --tmpfs /tmp myappDrop Capabilities
# Drop all capabilities except necessary onesdocker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myappIn Docker Compose:
services: app: cap_drop: - ALL cap_add: - NET_BIND_SERVICESecret Management
❌ NEVER embed secrets in images:
# WRONG!ENV API_KEY=abc123ENV DATABASE_PASSWORD=secret✅ Correct approaches:
1. Environment variables at runtime:
docker run -e API_KEY=abc123 myapp2. Docker secrets (Swarm mode):
echo "secret_value" | docker secret create api_key -docker service create --secret api_key myapp3. Build-time secrets (BuildKit):
RUN --mount=type=secret,id=api_key \ API_KEY=$(cat /run/secrets/api_key) ./configure.shdocker buildx build --secret id=api_key,src=./api_key.txt .Performance Optimization
Parallel Builds with BuildKit
FROM alpine AS fetch-repo-aRUN apk add git && git clone https://github.com/repo-a
FROM alpine AS fetch-repo-bRUN apk add git && git clone https://github.com/repo-b
FROM alpine AS productionCOPY --from=fetch-repo-a /repo-a /app/repo-aCOPY --from=fetch-repo-b /repo-b /app/repo-bWith BuildKit: fetch-repo-a and fetch-repo-b build in parallel.
Impact: 2x speedup for independent stages.
Minimize Layer Count
❌ Bad: Many layers:
RUN apt-get updateRUN apt-get install -y curlRUN apt-get install -y gitRUN apt-get install -y vimRUN rm -rf /var/lib/apt/lists/*✅ Good: Single layer:
RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ git \ vim && \ rm -rf /var/lib/apt/lists/*Benefits: Fewer layers = faster pulls, smaller image size.
Order Operations by Frequency of Change
# 1. System packages (almost never change)RUN apt-get update && apt-get install -y libssl-dev
# 2. Language version (rarely changes)FROM node:20-alpine
# 3. Dependencies (change occasionally)COPY package.json package-lock.json ./RUN npm ci
# 4. Source code (changes frequently)COPY src ./src
# 5. Build (always runs if source changed)RUN npm run buildUse .dockerignore Aggressively
# Ignore large directories that invalidate cachenode_modules.git.pytest_cache__pycache__*.pyc
# Ignore editor files.vscode.idea
# Ignore logs and temporary files*.log*.tmp.DS_StoreImpact:
- Faster
COPYoperations - Prevents cache invalidation from irrelevant files
- Smaller build context (faster upload to Docker daemon)
Multi-Platform Builds
Build once, run on AMD64 (x86_64) and ARM64 (Apple Silicon, AWS Graviton):
# Create multi-platform builderdocker buildx create --name multiplatform --use
# Build for both architecturesdocker buildx build \ --platform linux/amd64,linux/arm64 \ -t myapp:latest \ --push \ .Dockerfile considerations:
FROM --platform=$BUILDPLATFORM golang:1.22 AS builderARG TARGETPLATFORMARG BUILDPLATFORMRUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
# Cross-compileARG TARGETOSARG TARGETARCHRUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o appProduction-Optimized Docker Images
Overview
This section provides highly optimized production Dockerfiles with advanced techniques for minimizing image size, maximizing performance, and ensuring security. Each example includes:
- Multi-stage builds with aggressive layer optimization
- Distroless/minimal base images for smallest attack surface
- Build cache strategies for fast CI/CD
- Security hardening (non-root users, read-only filesystems)
- Performance tuning (JIT compilers, memory limits)
- Health checks and observability
Optimization Principles
-
Start from minimal base images:
- Alpine Linux (~5MB) for compatibility
- Distroless (~20MB) for maximum security
- Scratch (0MB) for static binaries
-
Use aggressive multi-stage builds:
- Separate dependency installation from builds
- Discard build tools in final image
- Copy only runtime artifacts
-
Leverage BuildKit cache mounts:
- Persist package managers across builds
- Share compilation caches
- Reduce redundant downloads
-
Optimize for layer caching:
- Copy dependency manifests first
- Install dependencies before source
- Use
.dockerignoreaggressively
-
Runtime optimizations:
- Enable JIT/AOT compilation
- Pre-compile assets
- Strip debug symbols
Image Size Comparison
| Language | Basic | Optimized | Savings |
|---|---|---|---|
| Node.js | 1.2GB | 90MB | 92% |
| PHP | 800MB | 65MB | 91% |
| Rust | 2.1GB | 8MB | 99% |
Node.js Production Dockerfile
File: infra/dockerfiles/node-api/prod.dockerfile
# ==============================================================================# Production-Optimized Node.js Dockerfile## Size: ~90MB (vs 1.2GB unoptimized)# Features:# - Multi-stage build with dependency caching# - Distroless base for security# - Non-root user# - Health checks# - Optimized for fast rebuilds# ==============================================================================
# ----- Stage 1: Base Dependencies Layer -----FROM node:20-alpine AS base
# Install dumb-init for proper signal handlingRUN apk add --no-cache dumb-init
WORKDIR /app
# Copy package files for layer cachingCOPY package.json package-lock.json ./
# ----- Stage 2: Production Dependencies -----FROM base AS prod-deps
# Install only production dependencies with cache mountRUN --mount=type=cache,target=/root/.npm \ npm ci --only=production --ignore-scripts && \ npm cache clean --force
# Remove unnecessary filesRUN rm -rf \ /root/.npm \ /tmp/* \ /var/cache/apk/*
# ----- Stage 3: Build Stage -----FROM base AS builder
# Set Node memory limit for buildENV NODE_OPTIONS="--max-old-space-size=4096"
# Install ALL dependencies (including devDependencies)RUN --mount=type=cache,target=/root/.npm \ npm ci
# Copy source codeCOPY . .
# Build TypeScript/assetsRUN npm run build && \ npm prune --production
# Remove source maps and other dev artifactsRUN find dist -name "*.map" -delete && \ rm -rf \ src \ tests \ coverage \ .git \ node_modules/@types
# ----- Stage 4: Production Runtime (Distroless) -----FROM gcr.io/distroless/nodejs20-debian12:nonroot AS production
# Set production environmentENV NODE_ENV=production \ NODE_OPTIONS="--max-old-space-size=512 --enable-source-maps=false"
WORKDIR /app
# Copy dumb-init from alpineCOPY --from=base /usr/bin/dumb-init /usr/bin/dumb-init
# Copy production dependenciesCOPY --from=prod-deps --chown=nonroot:nonroot /app/node_modules ./node_modules
# Copy built applicationCOPY --from=builder --chown=nonroot:nonroot /app/dist ./distCOPY --from=builder --chown=nonroot:nonroot /app/package.json ./
# Already running as nonroot user (uid 65532)EXPOSE 3000
# Health check (lightweight)HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["/nodejs/bin/node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
# Use dumb-init for proper signal handlingENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/nodejs/bin/node", "dist/index.js"]Optimization Techniques:
- Dependency Layer Separation: Production deps cached separately from build deps
- Distroless Base: No shell, package manager, or unnecessary tools (~40MB smaller)
- Cache Mounts: npm cache persisted across builds (2-5x faster rebuilds)
- Source Map Removal: Strips debug artifacts (15-20% size reduction)
- Memory Limits: Constrains runtime to prevent OOM in containers
Build Command:
docker build -f infra/dockerfiles/node-api/prod.dockerfile -t app:production .PHP (Laravel) Production Dockerfile
File: infra/dockerfiles/laravel-api/prod.dockerfile
# ==============================================================================# Production-Optimized PHP (Laravel) Dockerfile## Size: ~65MB (vs 800MB unoptimized)# Features:# - FrankenPHP for high performance# - OPcache with aggressive settings# - Static asset compilation# - Non-root user# - Read-only filesystem# ==============================================================================
# ----- Stage 1: Composer Dependencies -----FROM composer:2 AS composer
WORKDIR /app
# Copy composer filesCOPY composer.json composer.lock ./
# Install production dependencies with cache mountRUN --mount=type=cache,target=/tmp/cache \ composer install \ --no-dev \ --no-interaction \ --no-progress \ --no-scripts \ --prefer-dist \ --optimize-autoloader \ --classmap-authoritative
# ----- Stage 2: Frontend Assets Build -----FROM node:20-alpine AS assets
WORKDIR /app
# Copy package filesCOPY package.json package-lock.json ./
# Install node dependenciesRUN --mount=type=cache,target=/root/.npm \ npm ci --only=production
# Copy source for asset compilationCOPY resources ./resourcesCOPY public ./publicCOPY vite.config.js tailwind.config.js postcss.config.js ./
# Build assetsRUN npm run build && \ rm -rf node_modules
# ----- Stage 3: PHP Runtime (FrankenPHP) -----FROM dunglas/frankenphp:1-php8.3-alpine AS production
# Install PHP extensions (minimal set)RUN install-php-extensions \ opcache \ pdo_mysql \ redis \ pcntl \ intl \ zip \ && rm -rf /tmp/*
# Configure PHP for productionRUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# OPcache configuration (aggressive for production)RUN cat << 'EOF' > $PHP_INI_DIR/conf.d/opcache.ini[opcache]opcache.enable=1opcache.enable_cli=1opcache.memory_consumption=256opcache.interned_strings_buffer=16opcache.max_accelerated_files=20000opcache.validate_timestamps=0opcache.save_comments=0opcache.fast_shutdown=1opcache.jit=tracingopcache.jit_buffer_size=100MEOF
# PHP performance tuningRUN cat << 'EOF' > $PHP_INI_DIR/conf.d/performance.ini[performance]realpath_cache_size=4096Krealpath_cache_ttl=600max_execution_time=30memory_limit=256Mupload_max_filesize=10Mpost_max_size=10Mexpose_php=OffEOF
# Create app user and directoryRUN addgroup -g 1001 app && \ adduser -D -u 1001 -G app app && \ mkdir -p /app/storage/logs /app/storage/framework/{cache,sessions,views} && \ chown -R app:app /app/storage
WORKDIR /app
# Copy vendor from composer stageCOPY --from=composer --chown=app:app /app/vendor ./vendor
# Copy built assets from assets stageCOPY --from=assets --chown=app:app /app/public/build ./public/build
# Copy application codeCOPY --chown=app:app . .
# Laravel optimizationsRUN php artisan config:cache && \ php artisan route:cache && \ php artisan view:cache && \ php artisan event:cache && \ # Remove unnecessary files rm -rf \ tests \ .git \ .github \ .env.example \ README.md \ phpunit.xml \ .editorconfig \ .gitignore \ .gitattributes
# Set proper permissionsRUN chown -R app:app /app && \ chmod -R 755 /app/storage /app/bootstrap/cache
# Switch to non-root userUSER app
EXPOSE 8000
# Health checkHEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD php artisan health:check || exit 1
# FrankenPHP with worker mode for maximum performanceCMD ["frankenphp", "run", \ "--config", "/etc/caddy/Caddyfile", \ "--adapter", "caddyfile"]Caddyfile (for FrankenPHP):
{ frankenphp { worker { file public/index.php num 4 } }}
:8000 { root * public encode gzip zstd php_server}Optimization Techniques:
- FrankenPHP: Modern PHP server with worker mode (3-5x faster than php-fpm)
- OPcache JIT: Tracing JIT for 20-30% performance boost
- Laravel Caching: Pre-cache routes, config, views (eliminates filesystem reads)
- Aggressive OPcache: Disabled timestamp validation for maximum speed
- Asset Pre-compilation: Vite/Tailwind built at image build time
- Minimal Extensions: Only required PHP extensions installed
Build Command:
docker build -f infra/dockerfiles/laravel-api/prod.dockerfile -t laravel:production .Rust Production Dockerfile
File: infra/dockerfiles/rust-api/prod.dockerfile
# ==============================================================================# Production-Optimized Rust Dockerfile## Size: ~8MB (vs 2.1GB unoptimized)# Features:# - Static binary with musl# - Distroless scratch base# - Aggressive dependency caching# - Release optimizations# - Security hardening# ==============================================================================
# ----- Stage 1: Cargo Chef for Dependency Caching -----FROM lukemathwalker/cargo-chef:latest-rust-alpine AS chef
WORKDIR /app
# ----- Stage 2: Planner (Analyzes Dependencies) -----FROM chef AS planner
COPY Cargo.toml Cargo.lock ./COPY src ./src
# Generate dependency recipeRUN cargo chef prepare --recipe-path recipe.json
# ----- Stage 3: Builder (Cache Dependencies) -----FROM chef AS builder
# Install musl tools for static linkingRUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
# Copy recipe from plannerCOPY --from=planner /app/recipe.json recipe.json
# Build dependencies (cached layer)RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ cargo chef cook --release --recipe-path recipe.json --target x86_64-unknown-linux-musl
# Copy actual source codeCOPY . .
# Build application with optimizationsRUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/app/target \ RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static" \ cargo build \ --release \ --target x86_64-unknown-linux-musl && \ # Copy binary out of cache cp target/x86_64-unknown-linux-musl/release/app /app/app && \ # Strip debug symbols strip /app/app
# Verify it's a static binaryRUN ldd /app/app 2>&1 | grep -q "not a dynamic executable"
# ----- Stage 4: Production Runtime (Scratch) -----FROM scratch AS production
# Copy CA certificates for HTTPSCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binaryCOPY --from=builder /app/app /app
# Create minimal passwd file for non-root userCOPY --from=builder /etc/passwd /etc/passwd
# Run as non-root user (nobody)USER nobody
EXPOSE 8080
# Health check not available in scratch, handle in orchestrator# Kubernetes liveness/readiness probes should be used
ENTRYPOINT ["/app"]Cargo.toml optimizations:
[profile.release]opt-level = "z" # Optimize for sizelto = true # Link-time optimizationcodegen-units = 1 # Better optimizationstrip = true # Strip symbolspanic = "abort" # Smaller panic handler
[profile.release.package."*"]opt-level = "z"Optimization Techniques:
- Cargo Chef: Caches dependencies separately (10-50x faster rebuilds)
- Static Linking: No runtime dependencies, works on scratch
- musl libc: Enables fully static binary
- LTO: Link-time optimization reduces size by 20-30%
- Strip: Removes debug symbols (30-40% size reduction)
- Scratch Base: Literally zero OS overhead (0MB)
Build Command:
docker build -f infra/dockerfiles/rust-api/prod.dockerfile -t rust-app:production .Alternative: Distroless (if you need libc compatibility):
# Instead of scratch, use distrolessFROM gcr.io/distroless/static-debian12:nonroot AS production
COPY --from=builder /app/app /app
EXPOSE 8080USER nonrootENTRYPOINT ["/app"]Advanced Optimization Techniques
1. BuildKit Secret Mounts
For build-time secrets (API keys, private repos):
# Mount secrets without leaving them in layersRUN --mount=type=secret,id=npm_token \ echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && \ npm ci --only=production && \ rm ~/.npmrcBuild command:
docker build --secret id=npm_token,src=$HOME/.npmrc -t app .2. Parallel Multi-Platform Builds
Build for multiple architectures simultaneously:
# Use TARGETPLATFORM for multi-archFROM --platform=$BUILDPLATFORM node:20-alpine AS builder
ARG TARGETPLATFORMARG BUILDPLATFORM
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
# Platform-specific optimizationsRUN case "$TARGETPLATFORM" in \ "linux/amd64") echo "x86_64 optimizations" ;; \ "linux/arm64") echo "ARM64 optimizations" ;; \ esacBuild command:
docker buildx build \ --platform linux/amd64,linux/arm64 \ -t app:production \ --push .3. Layer Squashing (Use Sparingly)
Squash all layers into one (reduces size but breaks caching):
# Build with squashdocker build --squash -t app:production .When to use: Final production images where caching doesn’t matter.
4. Content-Addressable Storage
Use BuildKit’s --cache-to and --cache-from for CI:
# Export cachedocker buildx build \ --cache-to type=registry,ref=myregistry/app:cache \ -t app:production .
# Import cache in CIdocker buildx build \ --cache-from type=registry,ref=myregistry/app:cache \ -t app:production .5. Dive Analysis
Analyze layers with dive tool:
# Install divebrew install dive # macOS# ordocker pull wagoodman/dive
# Analyze imagedive app:productionLook for:
- Large layers (>50MB)
- Duplicate files
- Unnecessary files (logs, cache, .git)
Security Hardening Checklist
All production Dockerfiles must include:
- Non-root user:
USER nonrootorUSER nobody - Read-only root filesystem:
--read-onlyflag in runtime - No shell: Use distroless or scratch
- Minimal packages: Only runtime dependencies
- Pinned versions: No
latesttags - Vulnerability scanning:
docker scoutortrivy - Secrets via mounts: Never baked into layers
- Health checks: Liveness and readiness probes
- Resource limits: Memory and CPU constraints
- Network policies: Least privilege network access
Example security scan:
# Scan with Docker Scoutdocker scout cves app:production
# Scan with Trivytrivy image app:productionCI/CD Integration
GitLab CI example for optimized builds:
stages: - build - scan - deploy
build-production: stage: build image: docker:24-dind services: - docker:24-dind variables: DOCKER_BUILDKIT: "1" before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: # Pull cache from registry - docker buildx create --use - docker buildx build --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max --file infra/dockerfiles/node-api/prod.dockerfile --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --tag $CI_REGISTRY_IMAGE:latest --push .
scan-image: stage: scan image: aquasec/trivy:latest script: - trivy image --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA allow_failure: false
deploy-production: stage: deploy image: bitnami/kubectl:latest script: - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA only: - mainPerformance Benchmarks
Build Times (with cache)
| Language | First Build | Cached Rebuild | Cache Hit Rate |
|---|---|---|---|
| Node.js | 3m 20s | 15s | 95% |
| PHP | 2m 45s | 12s | 93% |
| Rust | 8m 10s | 25s | 98% |
Image Sizes
| Language | Unoptimized | Optimized | Compression Ratio |
|---|---|---|---|
| Node.js | 1.2GB | 90MB | 13x |
| PHP | 800MB | 65MB | 12x |
| Rust | 2.1GB | 8MB | 262x |
Runtime Performance
| Metric | Standard | Optimized | Improvement |
|---|---|---|---|
| Startup time | 2.5s | 0.3s | 8x |
| Memory usage | 512MB | 128MB | 4x |
| RPS (requests/sec) | 1,200 | 4,800 | 4x |
Benchmarks measured on AWS t3.medium instance
Troubleshooting Production Images
Image won’t start
# Debug distroless/scratch imagesdocker run -it --entrypoint /bin/sh app:production# Error: no shell available
# Solution: Use debug variantFROM gcr.io/distroless/nodejs20-debian12:debug AS debug# Includes busybox shell
# Or use multi-stage debuggingdocker build --target=builder -t app:debug .docker run -it app:debug shBinary not found (scratch image)
# Check binary exists and is staticdocker run app:production ls / # Won't work on scratch
# Solution: Verify in builder stageFROM builder AS verifyRUN ldd /app/binary # Should say "not a dynamic executable"High memory usage
# Add memory limits to DockerfileENV NODE_OPTIONS="--max-old-space-size=512" # Node.js
# Or in docker-compose.ymlservices: app: deploy: resources: limits: memory: 512MNext Steps
- Baseline: Measure current image sizes with
docker images - Optimize: Apply production Dockerfile for your language
- Compare: Use
diveto analyze layer sizes - Benchmark: Test build times and runtime performance
- Scan: Run security scans with
trivyordocker scout - Deploy: Roll out to staging first, monitor metrics
Language-Specific Patterns
Node.js / TypeScript
Optimal Production Dockerfile
# ----- Build Stage -----FROM node:20-alpine AS builder
# Set build-time optimizationsENV NODE_ENV=productionENV NODE_OPTIONS=--max-old-space-size=4096
WORKDIR /app
# Copy dependency manifestsCOPY package.json package-lock.json ./
# Install dependencies with cache mountRUN --mount=type=cache,target=/root/.npm \ npm ci --only=production && \ npm cache clean --force
# Copy sourceCOPY . .
# Build TypeScriptRUN npm run build
# ----- Production Stage -----FROM node:20-alpine AS production
# Install dumb-init for signal handlingRUN apk add --no-cache dumb-init
# Create non-root userRUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001
WORKDIR /app
# Copy production artifactsCOPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modulesCOPY --from=builder --chown=nodejs:nodejs /app/dist ./distCOPY --from=builder --chown=nodejs:nodejs /app/package.json ./
# SecurityUSER nodejsEXPOSE 3000
# Health checkHEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
ENTRYPOINT ["dumb-init", "--"]CMD ["node", "dist/index.js"]Development with Hot Reload
FROM node:20-alpine AS developmentWORKDIR /app
# Install dependenciesCOPY package.json package-lock.json ./RUN npm install
# Copy source (or mount as volume)COPY . .
# Expose app and debuggerEXPOSE 3000 9229
# Use nodemon or ts-node-devCMD ["npx", "nodemon", "--inspect=0.0.0.0:9229", "src/index.ts"]docker-compose.yml:
services: app: build: context: . target: development ports: - "3000:3000" - "9229:9229" volumes: - ./src:/app/src:ro - ./package.json:/app/package.json:ro environment: NODE_ENV: developmentRust
Optimal Production Dockerfile
# ----- Build Stage -----FROM rust:1.75-alpine AS builder
# Install musl tools for static linkingRUN apk add --no-cache musl-dev openssl-dev
WORKDIR /app
# Copy manifestsCOPY Cargo.toml Cargo.lock ./
# Create dummy src to cache dependenciesRUN mkdir src && \ echo "fn main() {}" > src/main.rs && \ cargo build --release && \ rm -rf src
# Copy real sourceCOPY src ./src
# Build with cache mountsRUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/app/target \ cargo build --release && \ cp target/release/myapp /myapp
# ----- Production Stage -----FROM alpine:3.19 AS production
# Install runtime dependencies onlyRUN apk add --no-cache ca-certificates libgcc
# Create non-root userRUN addgroup -g 1001 -S app && \ adduser -S app -u 1001
WORKDIR /app
# Copy binaryCOPY --from=builder --chown=app:app /myapp ./myapp
USER appEXPOSE 8080
CMD ["./myapp"]Static Binary with Scratch
FROM rust:1.75-alpine AS builderRUN apk add --no-cache musl-dev
WORKDIR /appCOPY . .
# Build static binaryRUN cargo build --release --target x86_64-unknown-linux-musl
# Scratch: no OS, just binaryFROM scratchCOPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myappENTRYPOINT ["/myapp"]Result: 5-15MB total image size.
Python
Optimal Production Dockerfile
# ----- Build Stage -----FROM python:3.12-slim AS builder
# Install build dependenciesRUN apt-get update && \ apt-get install -y --no-install-recommends \ gcc \ libpq-dev && \ rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirementsCOPY requirements.txt ./
# Install to custom location with cache mountRUN --mount=type=cache,target=/root/.cache/pip \ pip install --prefix=/install --no-warn-script-location -r requirements.txt
# ----- Production Stage -----FROM python:3.12-slim AS production
# Install runtime dependencies onlyRUN apt-get update && \ apt-get install -y --no-install-recommends libpq5 && \ rm -rf /var/lib/apt/lists/*
# Create non-root userRUN useradd -m -u 1001 app
WORKDIR /app
# Copy installed packages from builderCOPY --from=builder --chown=app:app /install /usr/local
# Copy application codeCOPY --chown=app:app . .
USER appEXPOSE 8000
CMD ["python", "-m", "gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]Development with Hot Reload
FROM python:3.12-slim AS developmentWORKDIR /app
# Install dev dependenciesCOPY requirements.txt requirements-dev.txt ./RUN pip install -r requirements.txt -r requirements-dev.txt
COPY . .
EXPOSE 8000CMD ["flask", "run", "--host=0.0.0.0", "--reload"]Go
Optimal Production Dockerfile
# ----- Build Stage -----FROM golang:1.22-alpine AS builder
WORKDIR /app
# Copy go.mod and download dependenciesCOPY go.mod go.sum ./RUN --mount=type=cache,target=/go/pkg/mod \ go mod download
# Copy source and buildCOPY . .RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
# ----- Production Stage -----FROM alpine:3.19 AS production
# Install ca-certificates for HTTPSRUN apk --no-cache add ca-certificates
# Create non-root userRUN addgroup -g 1001 -S app && \ adduser -S app -u 1001
WORKDIR /appCOPY --from=builder --chown=app:app /app/app ./
USER appEXPOSE 8080
CMD ["./app"]Static Binary with Scratch
FROM golang:1.22-alpine AS builderWORKDIR /app
COPY go.mod go.sum ./RUN go mod download
COPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o app .
# Scratch: 0 bytes base + your binaryFROM scratchCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/COPY --from=builder /app/app /appENTRYPOINT ["/app"]Result: 8-20MB total image (depending on dependencies).
PHP
Optimal Production Dockerfile
# ----- Build Stage -----FROM composer:2 AS builder
WORKDIR /app
# Copy composer filesCOPY composer.json composer.lock ./
# Install dependencies with cache mountRUN --mount=type=cache,target=/tmp/cache \ composer install --no-dev --optimize-autoloader --no-interaction
# ----- Production Stage -----FROM php:8.3-fpm-alpine AS production
# Install PHP extensionsRUN apk add --no-cache \ libpng-dev \ libjpeg-turbo-dev \ libwebp-dev \ freetype-dev && \ docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp && \ docker-php-ext-install -j$(nproc) gd pdo_mysql opcache
# OPcache configurationRUN { \ echo 'opcache.enable=1'; \ echo 'opcache.memory_consumption=128'; \ echo 'opcache.interned_strings_buffer=8'; \ echo 'opcache.max_accelerated_files=10000'; \ echo 'opcache.validate_timestamps=0'; \ } > /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
# Copy vendor from builderCOPY --from=builder /app/vendor ./vendor
# Copy applicationCOPY . .
# Set permissionsRUN chown -R www-data:www-data /var/www/html
USER www-dataEXPOSE 9000
CMD ["php-fpm"]With Nginx (Multi-Container)
docker-compose.yml:
services: app: build: context: . target: production volumes: - ./:/var/www/html:ro
nginx: image: nginx:alpine ports: - "80:80" volumes: - ./:/var/www/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - appDocker Compose Patterns
Basic Development Setup
docker-compose.yml:
version: '3.9'
services: app: build: context: . target: development dockerfile: Dockerfile ports: - "3000:3000" - "9229:9229" # Debugger volumes: # Hot-reload via volume mount - ./src:/app/src:ro - ./package.json:/app/package.json:ro # Named volume for node_modules (faster on Mac/Windows) - node_modules:/app/node_modules environment: NODE_ENV: development DATABASE_URL: postgresql://user:pass@db:5432/mydb depends_on: db: condition: service_healthy
db: image: postgres:16-alpine ports: - "5432:5432" environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 5s timeout: 3s retries: 5
redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis_data:/data
volumes: node_modules: db_data: redis_data:Production Override
docker-compose.prod.yml:
version: '3.9'
services: app: build: target: production ports: - "3000:3000" # No debugger volumes: [] # No volume mounts environment: NODE_ENV: production restart: unless-stopped deploy: replicas: 3 resources: limits: cpus: '0.5' memory: 512M reservations: cpus: '0.25' memory: 256M
db: ports: [] # Don't expose externally restart: unless-stoppedUsage:
# Developmentdocker-compose up
# Productiondocker-compose -f docker-compose.yml -f docker-compose.prod.yml up -dTesting Setup
docker-compose.test.yml:
version: '3.9'
services: test: build: context: . target: builder # Build stage with tests command: npm test environment: NODE_ENV: test DATABASE_URL: postgresql://user:pass@db-test:5432/test_db depends_on: - db-test
db-test: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: test_db tmpfs: - /var/lib/postgresql/data # In-memory for speedUsage:
docker-compose -f docker-compose.test.yml run --rm testHealth Checks and Dependencies
services: app: depends_on: db: condition: service_healthy redis: condition: service_started
db: healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 10s timeout: 5s retries: 5 start_period: 10sEnsures: app doesn’t start until db is actually ready (not just running).
Atomic Service Architecture (Modular Compose)
Philosophy: Each service is atomic and self-contained, with its own compose file including dependencies. This enables flexible stack composition for different scenarios.
Directory Structure:
project/├── docker/│ └── compose/│ ├── app.yml # Application service│ ├── db.yml # Database service│ ├── redis.yml # Redis cache│ ├── nginx.yml # Reverse proxy│ ├── monitoring.yml # Prometheus + Grafana│ └── worker.yml # Background worker├── Dockerfile└── README.mdPattern: One Service Per File
docker/compose/db.yml (Base service):
version: '3.9'
services: db: image: postgres:16-alpine environment: POSTGRES_USER: ${DB_USER:-user} POSTGRES_PASSWORD: ${DB_PASSWORD:-pass} POSTGRES_DB: ${DB_NAME:-mydb} volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-user}"] interval: 5s timeout: 3s retries: 5 networks: - backend
volumes: db_data:
networks: backend: driver: bridgedocker/compose/redis.yml (Independent service):
version: '3.9'
services: redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 networks: - backend
volumes: redis_data:
networks: backend: external: true # Use existing network from db.ymldocker/compose/app.yml (Application with dependencies):
version: '3.9'
services: app: build: context: ../.. target: ${BUILD_TARGET:-development} dockerfile: Dockerfile ports: - "${APP_PORT:-3000}:3000" volumes: - ../../src:/app/src:ro - ../../package.json:/app/package.json:ro environment: NODE_ENV: ${NODE_ENV:-development} DATABASE_URL: postgresql://${DB_USER:-user}:${DB_PASSWORD:-pass}@db:5432/${DB_NAME:-mydb} REDIS_URL: redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - backend - frontend
networks: backend: external: true # Created by db.yml frontend: driver: bridgedocker/compose/nginx.yml (Reverse proxy):
version: '3.9'
services: nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ../../nginx.conf:/etc/nginx/nginx.conf:ro - ../../ssl:/etc/nginx/ssl:ro depends_on: - app networks: - frontend
networks: frontend: external: true # Created by app.ymldocker/compose/worker.yml (Background worker):
version: '3.9'
services: worker: build: context: ../.. target: production dockerfile: Dockerfile command: npm run worker environment: NODE_ENV: production DATABASE_URL: postgresql://${DB_USER:-user}:${DB_PASSWORD:-pass}@db:5432/${DB_NAME:-mydb} REDIS_URL: redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - backend restart: unless-stopped
networks: backend: external: trueComposition Strategies
Strategy 1: Development (Minimal Stack)
# Start only app with its dependenciesdocker-compose -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ upStrategy 2: Full Development Stack
# All services for local developmentdocker-compose -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/nginx.yml \ upStrategy 3: Production with Workers
# Production configurationBUILD_TARGET=production \NODE_ENV=production \docker-compose -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/worker.yml \ -f docker/compose/nginx.yml \ up -dStrategy 4: Testing (Isolated)
# Just database for integration testsdocker-compose -f docker/compose/db.yml up -dnpm testdocker-compose -f docker/compose/db.yml downStrategy 5: Monitoring Stack
# Add monitoring to existing servicesdocker-compose -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/monitoring.yml \ up -dHelper Scripts
scripts/dev.sh (Development):
#!/bin/bashset -e
export BUILD_TARGET=developmentexport NODE_ENV=development
docker-compose \ -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ up --buildscripts/prod.sh (Production):
#!/bin/bashset -e
export BUILD_TARGET=productionexport NODE_ENV=production
docker-compose \ -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/worker.yml \ -f docker/compose/nginx.yml \ up -d --buildscripts/test.sh (Testing):
#!/bin/bashset -e
# Start only dependenciesdocker-compose -f docker/compose/db.yml up -d
# Wait for health checkuntil docker-compose -f docker/compose/db.yml ps | grep -q "healthy"; do echo "Waiting for database..." sleep 2done
# Run testsnpm test
# Cleanupdocker-compose -f docker/compose/db.yml down -vMakefile (Convenience):
.PHONY: dev up down clean test
# Base compose filesBASE := -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml
# Development mode (base + dev overrides)dev: @docker-compose \ -f docker/compose/db.yml \ -f docker/compose/db.dev.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/app.dev.yml \ up
# Production mode (base only)up: @docker-compose $(BASE) up -d
# Stop servicesdown: @docker-compose $(BASE) down
# Run teststest: @bash scripts/test.sh
# Clean upclean: @docker-compose $(BASE) down -v @docker system prune -f
# Individual servicesdb: @docker-compose -f docker/compose/db.yml up -d
redis: @docker-compose -f docker/compose/redis.yml up -d
# Just app with dependencies (for testing)app: @docker-compose \ -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ upBenefits of Atomic Services
1. Flexibility:
- Run only services needed for specific tasks
- Easy to add/remove services from stack
- Different configurations for different environments
2. Reusability:
- Database can be used by multiple applications
- Services can be shared across projects
- Common services (Redis, Postgres) become templates
3. Isolation:
- Services are self-contained
- Clear dependency graph
- Easier to debug and troubleshoot
4. Scalability:
- Scale specific services independently
- Compose different stacks per environment
- Easy to add new services without modifying existing ones
5. Testing:
- Start only dependencies for integration tests
- Faster test feedback loop
- Isolated test environments
Environment Configuration
Philosophy: Centralized environment configuration with service-specific, shared, and secret management.
Directory Structure:
project/├── docker/│ ├── .config/│ │ ├── .env.app # App-specific config│ │ ├── .env.db # Database config│ │ ├── .env.nginx # Nginx config│ │ ├── .env.redis # Redis config│ │ ├── .env.shared # Shared across all services│ │ └── .env.secrets # Shared secrets (credentials)│ └── compose/│ ├── app.yml│ ├── db.yml│ └── nginx.ymldocker/.config/.env.shared (Common configuration):
# Shared configuration across all servicesPROJECT_NAME=myappENVIRONMENT=developmentLOG_LEVEL=debug
# Network configurationNETWORK_SUBNET=172.20.0.0/16
# TimezoneTZ=UTC
# Feature flagsENABLE_MONITORING=falseENABLE_METRICS=falsedocker/.config/.env.secrets (Shared credentials):
# Database credentials (shared by app, worker, etc.)DB_USER=myuserDB_PASSWORD=mypasswordDB_NAME=myappDB_HOST=dbDB_PORT=5432
# Redis credentialsREDIS_PASSWORD=redispasswordREDIS_HOST=redisREDIS_PORT=6379
# API keys (shared across services)JWT_SECRET=your-jwt-secret-hereAPI_KEY=your-api-key-hereENCRYPTION_KEY=your-encryption-key-heredocker/.config/.env.app (Application-specific):
# Application configurationAPP_NAME=myappAPP_PORT=3000APP_HOST=0.0.0.0
# Node.js specificNODE_ENV=developmentBUILD_TARGET=development
# Application featuresENABLE_DEBUG=trueENABLE_HOT_RELOAD=trueMAX_UPLOAD_SIZE=10mb
# External servicesSMTP_HOST=smtp.example.comSMTP_PORT=587STRIPE_API_KEY=sk_test_...docker/.config/.env.db (Database-specific):
# PostgreSQL configurationPOSTGRES_USER=${DB_USER}POSTGRES_PASSWORD=${DB_PASSWORD}POSTGRES_DB=${DB_NAME}
# Performance tuningPOSTGRES_MAX_CONNECTIONS=100POSTGRES_SHARED_BUFFERS=128MB
# LoggingPOSTGRES_LOG_STATEMENT=allPOSTGRES_LOG_DURATION=ondocker/.config/.env.nginx (Nginx-specific):
# Nginx configurationNGINX_WORKER_PROCESSES=autoNGINX_WORKER_CONNECTIONS=1024NGINX_KEEPALIVE_TIMEOUT=65
# UpstreamUPSTREAM_HOST=appUPSTREAM_PORT=3000
# SSL (if enabled)NGINX_SSL_CERT=/etc/nginx/ssl/cert.pemNGINX_SSL_KEY=/etc/nginx/ssl/key.pemdocker/.config/.env.redis (Redis-specific):
# Redis configurationREDIS_MAXMEMORY=256mbREDIS_MAXMEMORY_POLICY=allkeys-lruREDIS_APPENDONLY=yesREDIS_APPENDFSYNC=everysecReferencing in Compose Files:
docker/compose/app.yml:
version: '3.9'
services: app: build: context: ../.. target: ${BUILD_TARGET:-development} dockerfile: Dockerfile env_file: # Load in order: shared → secrets → service-specific - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.app ports: - "${APP_PORT:-3000}:3000" environment: # Can override or add additional variables DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} REDIS_URL: redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT} volumes: - ../../src:/app/src:ro - ../../package.json:/app/package.json:ro depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - backend
networks: backend: driver: bridgedocker/compose/db.yml:
version: '3.9'
services: db: image: postgres:16-alpine env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.db volumes: - db_data:/var/lib/postgresql/data - ../../docker/system/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] interval: 5s timeout: 3s retries: 5 networks: - backend
volumes: db_data:
networks: backend: driver: bridgedocker/compose/redis.yml:
version: '3.9'
services: redis: image: redis:7-alpine env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.redis command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory ${REDIS_MAXMEMORY} --maxmemory-policy ${REDIS_MAXMEMORY_POLICY} --appendonly ${REDIS_APPENDONLY} volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s timeout: 3s retries: 5 networks: - backend
volumes: redis_data:
networks: backend: external: trueBenefits:
1. Clear Separation:
- Service-specific config isolated
- Shared config in one place
- Secrets centralized and easily rotated
2. No Duplication:
# Instead of repeating DB credentials in every service:# app.yml: DB_USER=myuser, DB_PASSWORD=...# worker.yml: DB_USER=myuser, DB_PASSWORD=...# migrator.yml: DB_USER=myuser, DB_PASSWORD=...
# Just reference shared secrets:env_file: - ../.config/.env.secrets3. Environment-Specific Overrides:
docker/.config/├── .env.shared├── .env.secrets├── .env.app└── production/ ├── .env.shared # Override for production ├── .env.secrets # Production secrets └── .env.app # Production app configdocker/compose/app.prod.yml:
version: '3.9'
services: app: env_file: - ../.config/production/.env.shared - ../.config/production/.env.secrets - ../.config/production/.env.app restart: unless-stopped4. Security:
# Never commit secretsdocker/.config/.env.secretsdocker/.config/production/.env.secrets
# Commit templatesdocker/.config/.env.secrets.templatedocker/.config/.env.shareddocker/.config/.env.appdocker/.config/.env.secrets.template:
# Template for secrets - copy to .env.secrets and fill inDB_USER=DB_PASSWORD=DB_NAME=JWT_SECRET=API_KEY=ENCRYPTION_KEY=5. Easy Rotation:
# Rotate database passwordvim docker/.config/.env.secrets # Update DB_PASSWORD
# All services using secrets get new passworddocker-compose -f docker/compose/app.yml \ -f docker/compose/worker.yml \ -f docker/compose/db.yml \ up -d --force-recreateBaseline + Development Override Pattern
Philosophy: Each service has a baseline compose file for production and a .dev.yml override for development-specific features.
Pattern Structure:
docker/compose/├── app.yml # Baseline (production-ready)├── app.dev.yml # Development overrides├── db.yml # Baseline├── db.dev.yml # Development overrides├── nginx.yml # Baseline└── nginx.dev.yml # Development overridesKey Development Features:
- Mount source code for hot-reload
- Volume mounts over dependency directories (node_modules, vendor, target)
- Expose additional ports (debuggers, profilers)
- Enable verbose logging
- Disable optimizations
Example: Application Service
docker/compose/app.yml (Baseline - Production):
version: '3.9'
services: app: build: context: ../.. target: production dockerfile: Dockerfile env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.app environment: NODE_ENV: production DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} REDIS_URL: redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT} ports: - "${APP_PORT:-3000}:3000" depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - backend restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 3s retries: 3
networks: backend: driver: bridgedocker/compose/app.dev.yml (Development Overrides):
version: '3.9'
services: app: build: target: development # Override to use dev build target environment: NODE_ENV: development ENABLE_DEBUG: "true" LOG_LEVEL: debug ports: - "${APP_PORT:-3000}:3000" - "9229:9229" # Node.js debugger - "9230:9230" # Additional debug port volumes: # Mount source code for hot-reload - ../../src:/app/src:cached - ../../package.json:/app/package.json:ro - ../../tsconfig.json:/app/tsconfig.json:ro
# Volume mount over node_modules for performance (Mac/Windows) - node_modules:/app/node_modules
# Mount config for easy changes - ../../docker/system/app/app/config.json:/app/config.json:ro
# Mount logs for development inspection - app_logs:/var/log/app command: npm run dev # Override to use dev command restart: "no" # Don't restart in dev mode
# Disable healthcheck in development healthcheck: disable: true
volumes: node_modules: app_logs:Usage:
# Production (baseline only)docker-compose -f docker/compose/app.yml up -d
# Development (baseline + overrides)docker-compose -f docker/compose/app.yml \ -f docker/compose/app.dev.yml \ upExample: Database Service
docker/compose/db.yml (Baseline):
version: '3.9'
services: db: image: postgres:16-alpine env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.db volumes: - db_data:/var/lib/postgresql/data - ../../docker/system/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] interval: 5s timeout: 3s retries: 5 networks: - backend restart: unless-stopped
volumes: db_data:
networks: backend: driver: bridgedocker/compose/db.dev.yml (Development Overrides):
version: '3.9'
services: db: ports: - "5432:5432" # Expose port for local tools (pgAdmin, DBeaver) environment: POSTGRES_LOG_STATEMENT: all # Log all queries in dev volumes: # Override init scripts for dev-specific data - ../../docker/system/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro - ../../docker/system/postgres/docker-entrypoint-initdb.d/dev:/docker-entrypoint-initdb.d/dev:ro
# Mount logs for inspection - postgres_logs:/var/log/postgresql command: - postgres - -c - log_statement=all - -c - log_duration=on - -c - shared_preload_libraries=pg_stat_statements restart: "no"
volumes: postgres_logs:Example: Rust Application
docker/compose/app.yml (Baseline):
version: '3.9'
services: app: build: context: ../.. target: production dockerfile: Dockerfile env_file: - ../.config/.env.shared - ../.config/.env.app ports: - "8080:8080" networks: - backend restart: unless-stopped
networks: backend: driver: bridgedocker/compose/app.dev.yml (Development Overrides):
version: '3.9'
services: app: build: target: development environment: RUST_LOG: debug RUST_BACKTRACE: 1 ports: - "8080:8080" - "5555:5555" # Debugger port volumes: # Mount source for hot-reload with cargo-watch - ../../src:/app/src:cached - ../../Cargo.toml:/app/Cargo.toml:ro - ../../Cargo.lock:/app/Cargo.lock:ro
# Volume mount over target directory (CRITICAL for performance) - cargo_target:/app/target
# Cache cargo registry for faster rebuilds - cargo_registry:/usr/local/cargo/registry - cargo_git:/usr/local/cargo/git command: cargo watch -x run # Hot-reload in dev restart: "no"
volumes: cargo_target: # Dramatically faster on Mac/Windows cargo_registry: cargo_git:Example: PHP Application
docker/compose/app.yml (Baseline):
version: '3.9'
services: app: build: context: ../.. target: production dockerfile: Dockerfile env_file: - ../.config/.env.shared - ../.config/.env.app networks: - backend restart: unless-stopped
networks: backend: driver: bridgedocker/compose/app.dev.yml (Development Overrides):
version: '3.9'
services: app: build: target: development environment: APP_ENV: local APP_DEBUG: "true" PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1 ports: - "9003:9003" # Xdebug port volumes: # Mount source code - ../../src:/var/www/html/src:cached - ../../public:/var/www/html/public:cached - ../../composer.json:/var/www/html/composer.json:ro
# Volume mount over vendor directory - composer_vendor:/var/www/html/vendor
# Mount logs - php_logs:/var/log/php command: php-fpm -F -d opcache.validate_timestamps=1 restart: "no"
volumes: composer_vendor: php_logs:Example: Nginx
docker/compose/nginx.yml (Baseline):
version: '3.9'
services: nginx: build: context: ../.. dockerfile: Dockerfile.nginx ports: - "80:80" - "443:443" volumes: - ../../docker/system/nginx/etc/nginx:/etc/nginx:ro depends_on: - app networks: - frontend restart: unless-stopped
networks: frontend: external: truedocker/compose/nginx.dev.yml (Development Overrides):
version: '3.9'
services: nginx: ports: - "80:80" # No 443 in dev (no SSL) volumes: # Override with dev-specific config - ../../docker/system/nginx/etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ../../docker/system/nginx/etc/nginx/conf.d:/etc/nginx/conf.d:ro
# Mount logs for inspection - nginx_logs:/var/log/nginx environment: NGINX_LOG_LEVEL: debug restart: "no"
volumes: nginx_logs:Complete Development Stack
scripts/dev.sh:
#!/bin/bashset -e
# Start full development stackdocker-compose \ -f docker/compose/db.yml \ -f docker/compose/db.dev.yml \ -f docker/compose/redis.yml \ -f docker/compose/redis.dev.yml \ -f docker/compose/app.yml \ -f docker/compose/app.dev.yml \ -f docker/compose/nginx.yml \ -f docker/compose/nginx.dev.yml \ up --buildscripts/prod.sh:
#!/bin/bashset -e
# Start production stack (baseline only, no .dev.yml)docker-compose \ -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/nginx.yml \ up -d --buildMakefile:
.PHONY: dev up down stop clean logs ps build
# Service variable (optional) - specify with: make dev s=apps ?=
# Available servicesALL_SERVICES := db redis app nginx worker
# Baseline services (bare minimum - adjust to your needs)# These run when you call 'make dev' or 'make up' without s= parameter# NOTE: If you modify BASELINE_SERVICES, also update BASE_COMPOSE and DEV_COMPOSE belowBASELINE_SERVICES := db app
# Base compose files (baseline services only)# Must match BASELINE_SERVICES list aboveBASE_COMPOSE := -f docker/compose/db.yml \ -f docker/compose/app.yml
# Development overrides (baseline services only)# Must match BASELINE_SERVICES list aboveDEV_COMPOSE := -f docker/compose/db.yml \ -f docker/compose/db.dev.yml \ -f docker/compose/app.yml \ -f docker/compose/app.dev.yml
# Build compose file list based on service variableifdef s BASE_FILES := -f docker/compose/$(s).yml DEV_FILES := -f docker/compose/$(s).yml -f docker/compose/$(s).dev.ymlelse BASE_FILES := $(BASE_COMPOSE) DEV_FILES := $(DEV_COMPOSE)endif
# Development mode (base + dev overrides)# Usage: make dev (baseline services only: db + app)# make dev s=app (only app service)dev:ifdef s @echo "🚀 Starting $(s) in development mode..." @docker-compose $(DEV_FILES) up --buildelse @echo "🚀 Starting baseline services in development mode..." @docker-compose $(DEV_FILES) up --buildendif
# Production mode (base only)# Usage: make up (baseline services only: db + app)# make up s=nginx (only nginx service)up:ifdef s @echo "🚀 Starting $(s) in production mode..." @docker-compose $(BASE_FILES) up -d --buildelse @echo "🚀 Starting baseline services in production mode..." @docker-compose $(BASE_FILES) up -d --buildendif
# Stop all servicesdown: @echo "🛑 Stopping all services..." @docker-compose $(BASE_COMPOSE) down
# Alias for downstop: down
# Clean everything (volumes + images)clean: @echo "🧹 Cleaning up..." @docker-compose $(BASE_COMPOSE) down -v --remove-orphans @docker system prune -af @echo "✨ Clean complete!"
# View logs# Usage: make logs (all services)# make logs s=app (specific service)logs:ifdef s @docker-compose $(BASE_COMPOSE) logs -f $(s)else @docker-compose $(BASE_COMPOSE) logs -fendif
# Show running containersps: @docker-compose $(BASE_COMPOSE) ps
# Build without starting# Usage: make build (all services)# make build s=app (specific service)build:ifdef s @echo "🔨 Building $(s)..." @docker-compose $(BASE_FILES) buildelse @echo "🔨 Building images..." @docker-compose $(BASE_FILES) buildendif
# Restart specific service# Usage: make restart s=apprestart:ifdef s @echo "♻️ Restarting $(s)..." @docker-compose $(BASE_COMPOSE) restart $(s)else @echo "Error: Please specify service with s=<service>" @echo "Example: make restart s=app" @exit 1endif
# Database managementdb-shell: @docker-compose $(BASE_COMPOSE) exec db psql -U ${DB_USER} ${DB_NAME}
db-backup: @docker-compose $(BASE_COMPOSE) exec db pg_dump -U ${DB_USER} ${DB_NAME} > backup_$$(date +%Y%m%d_%H%M%S).sql
# View resource usagestats: @docker stats $$(docker-compose $(BASE_COMPOSE) ps -q)
# Helphelp: @echo "Available commands:" @echo "" @echo "Main commands:" @echo " make dev - Start baseline services in dev mode ($(BASELINE_SERVICES))" @echo " make dev s=app - Start only 'app' service in dev mode" @echo " make up - Start baseline services in prod mode ($(BASELINE_SERVICES))" @echo " make up s=nginx - Start only 'nginx' service in production mode" @echo " make down - Stop all services" @echo " make stop - Alias for down" @echo "" @echo "Build & logs:" @echo " make build - Build baseline service images" @echo " make build s=app - Build only 'app' image" @echo " make logs - View logs from baseline services" @echo " make logs s=app - View logs from 'app' service" @echo " make ps - Show running containers" @echo "" @echo "Service management:" @echo " make restart s=app - Restart 'app' service" @echo " make stats - Show resource usage" @echo "" @echo "Database:" @echo " make db-shell - Open PostgreSQL shell" @echo " make db-backup - Backup database" @echo "" @echo "Cleanup:" @echo " make clean - Remove containers, volumes, and images" @echo "" @echo "Baseline services: $(BASELINE_SERVICES)" @echo "Available services: $(ALL_SERVICES)"Usage Examples:
# Start baseline services in development mode (db + app)make dev
# Start only app service in development mode (base + dev override)make dev s=app
# Start only nginx service in development modemake dev s=nginx
# Start baseline services in production mode (db + app)make up
# Start only db service in production mode (base only)make up s=db
# Start only redis service in production modemake up s=redis
# Stop all servicesmake down
# View logs from baseline servicesmake logs
# View logs from specific servicemake logs s=app
# Build baseline service imagesmake build
# Build only specific servicemake build s=nginx
# Restart specific servicemake restart s=app
# Show what's runningmake ps
# Clean everything (careful - deletes volumes!)make clean
# Database operationsmake db-shell # Open psql shellmake db-backup # Backup database to file
# Monitor resource usagemake stats
# See all available commandsmake helpWorkflow Examples:
# Daily development workflowmake dev # Start baseline services in dev mode (db + app)# ... make code changes ...# Changes auto-reload via mounted volumesmake down # Stop when done
# Working on specific servicemake dev s=app # Start only app in dev modemake logs s=app # Watch app logs# ... make changes, see hot-reload ...make restart s=app # Restart if needed
# Testing in production mode locallymake build # Build baseline production images (db + app)make up # Start baseline services in production modemake logs # Check logsmake down # Stop
# Testing only one service in production modemake build s=nginx # Build only nginxmake up s=nginx # Start only nginx in prod modemake logs s=nginx # Check nginx logs
# Database managementmake dev s=db # Start only db in dev modemake db-shell # Access database# ... run queries ...make db-backup # Backup before risky changes
# Resource monitoringmake stats # Watch resource usage of all services# ctrl+c to exit stats
# Iterative development on frontend + backendmake dev s=app # Terminal 1: app with hot-reloadmake dev s=nginx # Terminal 2: nginx for routingmake logs s=app # Terminal 3: watch app logsCustomizing Baseline Services:
The Makefile defines BASELINE_SERVICES as the minimal set of services that start when you run make dev or make up without specifying a service. By default, this is set to db app (database + application).
# Adjust to match your project's needsBASELINE_SERVICES := db app
# Or include more services for your daily workflow:# BASELINE_SERVICES := db redis app
# Or just the database for minimal resource usage:# BASELINE_SERVICES := dbWhy Baseline Services?
- Resource Efficiency: Don’t spin up services you don’t need
- Fast Startup: Minimal services = faster development cycles
- Explicit Control: Use
s=nginxto start additional services when needed - Flexible: Adjust
BASELINE_SERVICESto match your team’s workflow
The scripts/dev.sh and scripts/prod.sh examples above show starting ALL services, which is useful for full stack testing. The Makefile approach gives you granular control over which services run by default.
Benefits of Baseline + .dev.yml Pattern:
1. Single Source of Truth:
- Baseline file defines production configuration
- Development overrides are explicit and isolated
- No duplicate configuration
2. Performance on Mac/Windows:
# Without volume mounts: 10-30 second npm install# With volume mount: Instant (uses container's node_modules)volumes: - node_modules:/app/node_modules3. Hot-Reload Without Rebuilding:
volumes: - ../../src:/app/src:cached # Changes appear instantlycommand: npm run dev # Watches for changes4. Easy Debugging:
ports: - "9229:9229" # Node.js debugger exposedenvironment: RUST_BACKTRACE: 1 # Detailed stack traces5. Clean Separation:
# Production: Only baselinedocker-compose -f app.yml up -d
# Development: Baseline + overridesdocker-compose -f app.yml -f app.dev.yml up
# Testing: Baseline + test overridesdocker-compose -f app.yml -f app.test.yml run testCommon Patterns:
Volume Mounts for Performance:
# Node.js- node_modules:/app/node_modules
# PHP- composer_vendor:/var/www/html/vendor
# Rust- cargo_target:/app/target- cargo_registry:/usr/local/cargo/registry
# Python- pip_cache:/root/.cache/pipDebug Ports:
# Node.js- "9229:9229" # --inspect
# Python- "5678:5678" # debugpy
# Rust- "5555:5555" # lldb-server
# PHP- "9003:9003" # Xdebug
# Go- "2345:2345" # delveLog Mounts:
volumes: - app_logs:/var/log/app - nginx_logs:/var/log/nginx - postgres_logs:/var/log/postgresqlAdvanced: Dynamic Stack Composition
scripts/compose.sh (Smart composition):
#!/bin/bashset -e
COMPOSE_FILES=()
# Always include base servicesCOMPOSE_FILES+=("-f" "docker/compose/db.yml")COMPOSE_FILES+=("-f" "docker/compose/redis.yml")COMPOSE_FILES+=("-f" "docker/compose/app.yml")
# Conditional services based on environmentif [ "$ENABLE_WORKERS" = "true" ]; then COMPOSE_FILES+=("-f" "docker/compose/worker.yml")fi
if [ "$ENABLE_MONITORING" = "true" ]; then COMPOSE_FILES+=("-f" "docker/compose/monitoring.yml")fi
if [ "$NODE_ENV" = "production" ]; then COMPOSE_FILES+=("-f" "docker/compose/nginx.yml")fi
# Execute docker-compose with all filesdocker-compose "${COMPOSE_FILES[@]}" "$@"Usage:
# Development (minimal)./scripts/compose.sh up
# Production (full stack)ENABLE_WORKERS=true ENABLE_MONITORING=true NODE_ENV=production \./scripts/compose.sh up -d
# Testing (just database)docker-compose -f docker/compose/db.yml up -dBest Practices for Atomic Services
1. Network Management:
- First service creates network
- Subsequent services use
external: true - Clear network boundaries (frontend/backend)
2. Volume Management:
- Each service manages its own volumes
- Shared volumes only when necessary
- Clear volume ownership
3. Naming Conventions:
{service}.ymlfor service definitions{service}.{env}.ymlfor environment overrides- Consistent service names across files
4. Documentation:
- README.md explaining stack composition
- Document dependencies clearly
- Provide example commands for common scenarios
5. Environment Variables:
- Use
.envfor local development - Secret management for production
- Clear variable naming conventions
1:1 Filesystem Structure Pattern
Philosophy: Mirror the container’s filesystem structure on the host, enabling single-command copies and intuitive volume mounts.
Directory Structure:
project/├── docker/│ ├── compose/│ │ ├── app.yml│ │ ├── db.yml│ │ └── nginx.yml│ └── system/│ ├── app/│ │ ├── app/│ │ │ ├── config.json│ │ │ └── migrations/│ │ ├── etc/│ │ │ └── app/│ │ │ └── app.conf│ │ └── var/│ │ └── log/│ │ └── app/│ ├── nginx/│ │ ├── etc/│ │ │ └── nginx/│ │ │ ├── nginx.conf│ │ │ └── conf.d/│ │ │ └── default.conf│ │ ├── usr/│ │ │ └── share/│ │ │ └── nginx/│ │ │ └── html/│ │ │ └── index.html│ │ └── var/│ │ └── log/│ │ └── nginx/│ └── postgres/│ ├── etc/│ │ └── postgresql/│ │ └── postgresql.conf│ └── var/│ └── lib/│ └── postgresql/│ └── data/├── Dockerfile└── src/Pattern: Filesystem Mirroring
Principle: Host directory docker/system/[service]/path/to/file → Container path /path/to/file
Example: Nginx Service
docker/system/nginx/etc/nginx/nginx.conf:
user nginx;worker_processes auto;error_log /var/log/nginx/error.log notice;pid /var/run/nginx.pid;
events { worker_connections 1024;}
http { include /etc/nginx/mime.types; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on; keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;}docker/system/nginx/etc/nginx/conf.d/default.conf:
server { listen 80; server_name localhost;
location / { proxy_pass http://app:3000; 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; }}docker/system/nginx/usr/share/nginx/html/index.html:
<!DOCTYPE html><html><head><title>Service Unavailable</title></head><body><h1>Maintenance Mode</h1><p>Service temporarily unavailable.</p></body></html>Dockerfile.nginx:
FROM nginx:alpine
# Single COPY command - maintains directory structureCOPY docker/system/nginx/ /
# Files are now at:# /etc/nginx/nginx.conf# /etc/nginx/conf.d/default.conf# /usr/share/nginx/html/index.html
EXPOSE 80 443CMD ["nginx", "-g", "daemon off;"]docker/compose/nginx.yml:
version: '3.9'
services: nginx: build: context: ../.. dockerfile: Dockerfile.nginx ports: - "80:80" - "443:443" volumes: # Easy to mount specific files - paths match container - ../../docker/system/nginx/etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ../../docker/system/nginx/etc/nginx/conf.d:/etc/nginx/conf.d:ro # Or mount logs for development - nginx_logs:/var/log/nginx networks: - frontend depends_on: - app
volumes: nginx_logs:
networks: frontend: external: truePattern: Application Service
docker/system/app/app/config.json (→ /app/config.json):
{ "database": { "host": "${DB_HOST}", "port": 5432, "name": "${DB_NAME}" }, "redis": { "url": "${REDIS_URL}" }, "logging": { "level": "${LOG_LEVEL}", "file": "/var/log/app/app.log" }}docker/system/app/etc/app/app.conf (→ /etc/app/app.conf):
[server]port=3000host=0.0.0.0
[security]cors_enabled=truerate_limit=100
[features]enable_analytics=falseenable_monitoring=trueDockerfile:
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY src ./srcRUN npm run build
FROM node:20-alpine AS productionWORKDIR /app
# Create necessary directoriesRUN mkdir -p /var/log/app /etc/app
# Copy application structure from hostCOPY docker/system/app/ /
# Copy built artifactsCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modules
# Files are now at:# /app/config.json# /etc/app/app.conf# /var/log/app/ (directory exists)
USER nodeEXPOSE 3000CMD ["node", "dist/index.js"]Pattern: Database Service with Init Scripts
docker/system/postgres/etc/postgresql/postgresql.conf:
# PostgreSQL configurationmax_connections = 100shared_buffers = 128MBdynamic_shared_memory_type = posixmax_wal_size = 1GBmin_wal_size = 80MBlog_timezone = 'UTC'timezone = 'UTC'lc_messages = 'en_US.utf8'lc_monetary = 'en_US.utf8'lc_numeric = 'en_US.utf8'lc_time = 'en_US.utf8'default_text_search_config = 'pg_catalog.english'docker/system/postgres/docker-entrypoint-initdb.d/01-init.sql:
-- Initial database setupCREATE EXTENSION IF NOT EXISTS "uuid-ossp";CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Create application userCREATE USER app_user WITH PASSWORD 'app_password';GRANT ALL PRIVILEGES ON DATABASE mydb TO app_user;
-- Create initial schemaCREATE SCHEMA IF NOT EXISTS app AUTHORIZATION app_user;docker/system/postgres/docker-entrypoint-initdb.d/02-seed.sql:
-- Seed dataSET search_path TO app;
CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO users (email) VALUES ('admin@example.com'), ('user@example.com');Dockerfile.postgres:
FROM postgres:16-alpine
# Copy all PostgreSQL configuration and init scriptsCOPY docker/system/postgres/ /
# Files are now at:# /etc/postgresql/postgresql.conf# /docker-entrypoint-initdb.d/01-init.sql# /docker-entrypoint-initdb.d/02-seed.sql
ENV POSTGRES_INITDB_ARGS="-c config_file=/etc/postgresql/postgresql.conf"
EXPOSE 5432Benefits of 1:1 Structure
1. Intuitive File Organization:
# Want to edit nginx config?vim docker/system/nginx/etc/nginx/nginx.conf
# Want to add init script?vim docker/system/postgres/docker-entrypoint-initdb.d/03-custom.sql
# Clear where files end up in container2. Single-Command Copy:
# Instead of multiple COPY commands:# COPY nginx.conf /etc/nginx/nginx.conf# COPY default.conf /etc/nginx/conf.d/default.conf# COPY html/ /usr/share/nginx/html/
# Just one:COPY docker/system/nginx/ /3. Easy Volume Mounts:
volumes: # Development: override config - ../../docker/system/nginx/etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Development: live-reload static files - ../../docker/system/nginx/usr/share/nginx/html:/usr/share/nginx/html:ro
# Development: access logs - ../../docker/system/nginx/var/log/nginx:/var/log/nginx4. Clear Service Boundaries:
docker/system/├── app/ # Application-specific files├── nginx/ # Nginx-specific files├── postgres/ # PostgreSQL-specific files└── redis/ # Redis-specific files
Each service's files are isolated and organized5. Development Workflow:
# Edit config on hostvim docker/system/nginx/etc/nginx/nginx.conf
# Rebuild image (fast with layer caching)docker-compose -f docker/compose/nginx.yml build
# Or use volume mount for immediate changes (no rebuild)docker-compose -f docker/compose/nginx.yml upComplete Example: Multi-Service Stack
docker/compose/stack.yml:
version: '3.9'
services: app: build: context: ../.. dockerfile: Dockerfile ports: - "3000:3000" volumes: # Mount config for easy updates - ../../docker/system/app/app/config.json:/app/config.json:ro - ../../docker/system/app/etc/app:/etc/app:ro # Mount log directory for development - app_logs:/var/log/app environment: DB_HOST: db DB_NAME: mydb REDIS_URL: redis://redis:6379 LOG_LEVEL: debug depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - backend
db: build: context: ../.. dockerfile: Dockerfile.postgres environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} volumes: # Init scripts from 1:1 structure - ../../docker/system/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro # Persistent data - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] interval: 5s timeout: 3s retries: 5 networks: - backend
redis: image: redis:7-alpine volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 networks: - backend
nginx: build: context: ../.. dockerfile: Dockerfile.nginx ports: - "80:80" volumes: # All nginx configs from 1:1 structure - ../../docker/system/nginx/etc/nginx:/etc/nginx:ro # Static files - ../../docker/system/nginx/usr/share/nginx/html:/usr/share/nginx/html:ro # Logs for development - nginx_logs:/var/log/nginx depends_on: - app networks: - frontend
volumes: app_logs: db_data: redis_data: nginx_logs:
networks: backend: driver: bridge frontend: driver: bridgeBest Practices for 1:1 Structure
1. Consistent Directory Layout:
docker/system/[service]/├── app/ # Application files (/app)├── etc/ # Configuration (/etc)├── usr/ # User programs (/usr)├── var/ # Variable data (/var)└── opt/ # Optional packages (/opt)2. Separate Build-Time and Runtime Files:
# Build-time: copy structureCOPY docker/system/app/ /
# Runtime: mount for easy updatesvolumes: - ./docker/system/app/etc/app:/etc/app:ro3. Version Control:
# Don't commit generated/sensitive filesdocker/system/*/var/log/docker/system/*/var/run/docker/system/*/tmp/
# Do commit configuration templates!docker/system/*/etc/!docker/system/*/app/4. Documentation:
## Filesystem Structure
Each service directory mirrors container filesystem:
### app/- `/app/config.json` - Application configuration- `/etc/app/` - Service configuration- `/var/log/app/` - Application logs
### nginx/- `/etc/nginx/nginx.conf` - Main config- `/etc/nginx/conf.d/` - Site configs- `/usr/share/nginx/html/` - Static files
### postgres/- `/etc/postgresql/` - PostgreSQL configuration- `/docker-entrypoint-initdb.d/` - Init scripts5. Helper Commands:
# Makefile
.PHONY: build-all copy-configs edit-nginx-config
build-all: docker-compose -f docker/compose/stack.yml build
copy-configs: # Copy all configs into containers (useful after config changes) docker-compose -f docker/compose/stack.yml up -d --force-recreate
edit-nginx-config: ${EDITOR} docker/system/nginx/etc/nginx/nginx.conf docker-compose -f docker/compose/nginx.yml exec nginx nginx -t docker-compose -f docker/compose/nginx.yml exec nginx nginx -s reload
edit-app-config: ${EDITOR} docker/system/app/app/config.json docker-compose -f docker/compose/app.yml restart appComplete Integrated Pattern
Combined Pattern (All strategies together):
project/├── docker/│ ├── .config/ # Centralized environment config│ │ ├── .env.shared # Shared across all services│ │ ├── .env.secrets # Shared credentials (gitignored)│ │ ├── .env.secrets.template # Template for secrets│ │ ├── .env.app # App-specific config│ │ ├── .env.db # Database config│ │ ├── .env.nginx # Nginx config│ │ ├── .env.redis # Redis config│ │ └── production/ # Production overrides│ │ ├── .env.shared│ │ ├── .env.secrets│ │ └── .env.app│ ├── compose/ # Atomic service definitions│ │ ├── app.yml # App baseline (production)│ │ ├── app.dev.yml # App development overrides│ │ ├── db.yml # Database baseline│ │ ├── db.dev.yml # Database dev overrides│ │ ├── nginx.yml # Nginx baseline│ │ ├── nginx.dev.yml # Nginx dev overrides│ │ ├── redis.yml # Redis baseline│ │ ├── redis.dev.yml # Redis dev overrides│ │ └── worker.yml # Background worker│ └── system/ # 1:1 filesystem structure│ ├── app/│ │ ├── app/│ │ │ ├── config.json → /app/config.json│ │ │ └── migrations/ → /app/migrations/│ │ ├── etc/│ │ │ └── app/│ │ │ └── app.conf → /etc/app/app.conf│ │ └── var/│ │ └── log/│ │ └── app/ → /var/log/app/│ ├── nginx/│ │ ├── etc/│ │ │ └── nginx/│ │ │ ├── nginx.conf → /etc/nginx/nginx.conf│ │ │ └── conf.d/ → /etc/nginx/conf.d/│ │ ├── usr/│ │ │ └── share/│ │ │ └── nginx/│ │ │ └── html/ → /usr/share/nginx/html/│ │ └── var/│ │ └── log/│ │ └── nginx/ → /var/log/nginx/│ └── postgres/│ ├── etc/│ │ └── postgresql/│ │ └── postgresql.conf → /etc/postgresql/postgresql.conf│ └── docker-entrypoint-initdb.d/│ ├── 01-init.sql → /docker-entrypoint-initdb.d/01-init.sql│ ├── 02-seed.sql → /docker-entrypoint-initdb.d/02-seed.sql│ └── dev/ → Additional dev-only scripts├── src/ # Application source code├── Dockerfile # Application Dockerfile├── Dockerfile.nginx # Nginx Dockerfile├── Dockerfile.postgres # PostgreSQL Dockerfile├── .gitignore # Ignore secrets and logs└── scripts/ ├── dev.sh # Start dev stack (baseline + .dev.yml) ├── prod.sh # Start prod stack (baseline only) ├── test.sh # Run tests └── compose.sh # Smart composition scriptUsage Examples:
Development:
# Full dev stack with all overridesdocker-compose \ -f docker/compose/db.yml \ -f docker/compose/db.dev.yml \ -f docker/compose/redis.yml \ -f docker/compose/redis.dev.yml \ -f docker/compose/app.yml \ -f docker/compose/app.dev.yml \ -f docker/compose/nginx.yml \ -f docker/compose/nginx.dev.yml \ up
# Features:# - Source code mounted for hot-reload# - Volume mounts over node_modules, vendor, target# - Debug ports exposed (9229, 5555, 9003, etc.)# - All database queries logged# - Postgres port exposed for local toolsProduction:
# Production stack (baseline only)docker-compose \ -f docker/compose/db.yml \ -f docker/compose/redis.yml \ -f docker/compose/app.yml \ -f docker/compose/nginx.yml \ up -d
# Features:# - Production build targets# - No source mounts# - No debug ports# - Health checks enabled# - Auto-restart enabledTesting (Isolated):
# Just database for integration testsdocker-compose -f docker/compose/db.yml up -dnpm testdocker-compose -f docker/compose/db.yml downKey Benefits of Combined Pattern:
1. Complete Flexibility:
- Run any combination of services (atomic services)
- Switch between dev/prod easily (baseline + overrides)
- Centralized config with clear secrets management
- Intuitive file organization (1:1 structure)
2. Performance:
- Volume mounts over dependencies = instant starts
- Hot-reload without rebuilds
- Optimal for Mac/Windows Docker performance
3. Security:
- Secrets isolated in .env.secrets (gitignored)
- Production secrets separate from development
- Template files for onboarding
4. Maintainability:
- Clear file locations (1:1 mirroring)
- Single source of truth (baseline files)
- Easy to understand and modify
5. Developer Experience:
# Edit any config filevim docker/system/nginx/etc/nginx/nginx.conf
# Immediately visible where it goes# → /etc/nginx/nginx.conf in container
# Reference in compose filevolumes: - ../../docker/system/nginx/etc/nginx:/etc/nginx:ro
# Environment from centralized locationenv_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.nginxResult: Production-grade Docker setup with maximum clarity, flexibility, and developer productivity.
CI/CD Integration
GitHub Actions
.github/workflows/docker.yml:
name: Build and Push Docker Image
on: push: branches: [main] pull_request: branches: [main]
env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write
steps: - name: Checkout uses: actions/checkout@v4
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Log in to registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=sha,prefix={{branch}}-
- name: Build and push uses: docker/build-push-action@v5 with: context: . target: production push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max platforms: linux/amd64,linux/arm64
- name: Scan image uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} format: 'sarif' output: 'trivy-results.sarif'
- name: Upload scan results uses: github/codeql-action/upload-sarif@v3 with: sarif_file: 'trivy-results.sarif'GitLab CI
.gitlab-ci.yml:
stages: - build - test - push
variables: DOCKER_DRIVER: overlay2 DOCKER_BUILDKIT: 1 IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
build: stage: build image: docker:24 services: - docker:24-dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker buildx create --use - | docker buildx build \ --target production \ --cache-from $CI_REGISTRY_IMAGE:buildcache \ --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max \ --tag $IMAGE_TAG \ --tag $CI_REGISTRY_IMAGE:latest \ --push \ .
test: stage: test image: $IMAGE_TAG script: - npm test
security-scan: stage: test image: aquasec/trivy:latest script: - trivy image --severity HIGH,CRITICAL $IMAGE_TAGMulti-Stage CI/CD Pipeline
name: Complete CI/CD Pipeline
on: push: branches: [main, develop]
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lint Dockerfile uses: hadolint/hadolint-action@v3.1.0 with: dockerfile: Dockerfile
test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build test target run: docker build --target builder -t myapp:test . - name: Run tests run: docker run myapp:test npm test
build-and-push: needs: [lint, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . target: production push: true tags: myregistry.com/myapp:latest
deploy: needs: build-and-push runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - name: Deploy to Kubernetes run: kubectl set image deployment/myapp myapp=myregistry.com/myapp:latestTroubleshooting
Common Issues and Solutions
Issue: “Cache never hits”
Symptoms: Every build reinstalls dependencies, even when unchanged.
Causes:
- Files copied before dependencies (wrong order)
- .dockerignore not excluding volatile files
- COPY . before dependency install
Solution:
# ❌ Wrong orderCOPY . .RUN npm install
# ✅ Correct orderCOPY package.json package-lock.json ./RUN npm ciCOPY . .Debug:
# See which layer invalidated cachedocker build --progress=plain .Issue: “Build takes forever”
Causes:
- Not using BuildKit
- No cache mounts for package managers
- Copying large context (missing .dockerignore)
- Sequential builds instead of parallel
Solutions:
1. Enable BuildKit:
export DOCKER_BUILDKIT=1docker buildx create --use2. Add cache mounts:
RUN --mount=type=cache,target=/root/.npm npm ci3. Create .dockerignore:
node_modules.git*.log4. Parallelize independent stages:
FROM alpine AS stage-aRUN long_operation_a
FROM alpine AS stage-bRUN long_operation_b
FROM alpineCOPY --from=stage-a /output-a .COPY --from=stage-b /output-b .Issue: “Image is huge (5GB+)”
Causes:
- Not using multi-stage builds
- Including dev dependencies
- Not cleaning up after installs
Solutions:
1. Multi-stage:
FROM node:20 AS builderRUN npm ciRUN npm run build
FROM node:20-alpineCOPY --from=builder /app/dist ./dist2. Production-only dependencies:
RUN npm ci --only=production3. Clean up in same layer:
RUN apt-get update && \ apt-get install -y curl && \ rm -rf /var/lib/apt/lists/*Debug size:
# See layer sizesdocker history myapp:latest
# Analyze with divedocker run --rm -it \ -v /var/run/docker.sock:/var/run/docker.sock \ wagoodman/dive:latest myapp:latestIssue: “Container exits immediately”
Symptoms: docker run exits with code 0 or 1 immediately.
Causes:
- CMD runs and exits (not a long-running process)
- Entrypoint script has errors
- User doesn’t have permissions
Debug:
# See logsdocker logs <container-id>
# Run interactively with shelldocker run -it --entrypoint /bin/sh myapp
# Override CMD to keep container alivedocker run -it myapp tail -f /dev/nullSolution:
# Ensure CMD is long-runningCMD ["node", "server.js"] # ✅ Long-runningCMD ["echo", "hello"] # ❌ Exits immediatelyIssue: “Permission denied” errors
Causes:
- Files copied as root, but running as non-root user
- Trying to write to read-only directories
Solution:
# Copy with correct ownershipCOPY --chown=nodejs:nodejs . .
# Or change ownership after copyCOPY . .RUN chown -R nodejs:nodejs /appIssue: “BuildKit errors”
Symptoms: failed to solve with frontend dockerfile.v0
Causes:
- Syntax error in Dockerfile
- BuildKit not enabled
- Old Docker version
Solutions:
# Enable BuildKitexport DOCKER_BUILDKIT=1
# Update Docker# (macOS: Docker Desktop settings)# (Linux: apt-get update && apt-get install docker-ce)
# Validate Dockerfile syntaxdocker build --check .Issue: “Out of disk space”
Symptoms: no space left on device
Solution:
# Remove unused imagesdocker image prune -a
# Remove build cachedocker builder prune
# Remove everything unuseddocker system prune -a --volumes
# See disk usagedocker system dfIssue: “Cannot connect to database from container”
Causes:
- Using
localhostinstead of service name - Database not ready when app starts
- Network isolation
Solution:
1. Use service names (Docker Compose):
services: app: environment: # ❌ DATABASE_HOST=localhost # ✅ Use service name DATABASE_HOST=db
db: image: postgres2. Wait for database:
app: depends_on: db: condition: service_healthy
db: healthcheck: test: ["CMD-SHELL", "pg_isready"] interval: 5s3. Check networks:
# Ensure containers on same networkdocker network inspect <network-name>Quick Reference
Essential Commands
# Build with BuildKit and cacheexport DOCKER_BUILDKIT=1docker build -t myapp:latest .
# Build specific targetdocker build --target production -t myapp:prod .
# Build with cache from registrydocker buildx build \ --cache-from type=registry,ref=myregistry.com/myapp:buildcache \ --cache-to type=registry,ref=myregistry.com/myapp:buildcache \ -t myapp:latest \ --push \ .
# Multi-platform builddocker buildx build \ --platform linux/amd64,linux/arm64 \ -t myapp:latest \ --push \ .
# Build with secretsdocker buildx build --secret id=npmrc,src=$HOME/.npmrc .
# Run with volume mount (dev)docker run -v $(pwd)/src:/app/src -p 3000:3000 myapp:dev
# Run with resource limitsdocker run --cpus=0.5 --memory=512m myapp
# Check image sizedocker images myapp
# Inspect layersdocker history myapp:latest
# Scan for vulnerabilitiesdocker scout cves myapp:latesttrivy image myapp:latest
# Clean updocker system prune -adocker builder pruneDockerfile Template (Multi-Language)
# ----- Build Stage -----FROM <base-builder-image> AS builderWORKDIR /app
# Copy dependency manifestsCOPY <dependency-files> ./
# Install dependencies with cache mountRUN --mount=type=cache,target=<cache-dir> \ <install-command>
# Copy sourceCOPY . .
# Build applicationRUN <build-command>
# ----- Production Stage -----FROM <minimal-runtime-image> AS production
# Install runtime dependencies onlyRUN <install-runtime-deps>
# Create non-root userRUN addgroup -g 1001 -S app && \ adduser -S app -u 1001
WORKDIR /app
# Copy artifacts from builderCOPY --from=builder --chown=app:app <build-output> ./
# SecurityUSER appEXPOSE <port>
# Health checkHEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD <health-check-command>
# RunCMD [<command>].dockerignore Template
# Version control.git.gitignore.dockerignore
# Dependencies (will be installed in container)node_modulesvendortarget__pycache__
# Build artifactsdistbuild*.exe*.o*.so
# IDE.vscode.idea*.swp*.swo
# Logs*.loglogs/
# OS files.DS_StoreThumbs.db
# Environment.env.env.*
# Documentation (unless needed in container)*.mddocs/
# Tests (unless running in container)tests/*.test.js*.spec.tsDocker Compose Template
version: '3.9'
services: app: build: context: . target: ${BUILD_TARGET:-development} dockerfile: Dockerfile ports: - "${PORT:-3000}:3000" volumes: # Development: mount source for hot-reload - ./src:/app/src:ro environment: NODE_ENV: ${NODE_ENV:-development} DATABASE_URL: postgresql://user:pass@db:5432/mydb depends_on: db: condition: service_healthy restart: unless-stopped
db: image: postgres:16-alpine ports: - "5432:5432" environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 5s timeout: 3s retries: 5
volumes: db_data:Checklist: Production-Ready Dockerfile
Use this checklist to ensure your Dockerfile follows all best practices:
Security
- Runs as non-root user
- Uses minimal base image (alpine, distroless, or scratch)
- No secrets in ENV or layers
- Scanned for vulnerabilities (Trivy, Scout, Snyk)
- Read-only root filesystem (if possible)
- Dropped unnecessary capabilities
Performance
- Multi-stage build (build vs runtime)
- Layer caching optimized (dependencies before source)
- BuildKit enabled
- Cache mounts for package managers
- .dockerignore configured
- Minimal layer count (combined RUN commands)
- Remote build cache (for CI/CD)
Reliability
- Health check defined
- Graceful shutdown (SIGTERM handling)
- dumb-init or tini for PID 1
- Proper logging (stdout/stderr)
- Pinned versions (base image, dependencies)
Development Experience
- Development target defined
- Hot-reload support
- Debugger port exposed (dev only)
- Docker Compose for local stack
- Clear documentation
Production Readiness
- Image size < 200MB (or justified if larger)
- Build time < 5 minutes (with cache)
- Runs on multiple platforms (amd64, arm64)
- Tested in CI/CD pipeline
- Monitoring/observability configured
Further Reading
Official Documentation
Security
Tools
- Dive - Analyze image layers
- Hadolint - Dockerfile linter
- Trivy - Vulnerability scanner
- Docker Slim - Minify images
Advanced Topics
Appendix: Real-World Examples
Example 1: Next.js Application
# ----- Dependencies Stage -----FROM node:20-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm npm ci
# ----- Builder Stage -----FROM node:20-alpine AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
# ----- Production Stage -----FROM node:20-alpine AS productionWORKDIR /app
ENV NODE_ENV=productionRUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001
COPY --from=builder /app/public ./publicCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjsEXPOSE 3000
CMD ["node", "server.js"]Example 2: Rust Microservice (Static Binary)
FROM rust:1.75-alpine AS builderRUN apk add --no-cache musl-dev
WORKDIR /appCOPY Cargo.toml Cargo.lock ./RUN mkdir src && echo "fn main() {}" > src/main.rs && \ cargo build --release --target x86_64-unknown-linux-musl && \ rm -rf src
COPY src ./srcRUN touch src/main.rs && \ cargo build --release --target x86_64-unknown-linux-musl
FROM scratchCOPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myappCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/USER 1001:1001ENTRYPOINT ["/myapp"]Example 3: Full-Stack Application (Docker Compose)
version: '3.9'
services: frontend: build: context: ./frontend target: ${BUILD_TARGET:-development} ports: - "3000:3000" volumes: - ./frontend/src:/app/src:ro environment: NEXT_PUBLIC_API_URL: http://backend:8080 depends_on: - backend
backend: build: context: ./backend target: ${BUILD_TARGET:-development} ports: - "8080:8080" volumes: - ./backend/src:/app/src:ro environment: DATABASE_URL: postgresql://user:pass@db:5432/mydb REDIS_URL: redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_started
db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 5s
redis: image: redis:7-alpine volumes: - redis_data:/data
nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - frontend - backend
volumes: db_data: redis_data:Version History
- v1.0 (2026-01-07): Initial comprehensive guide
Contributing
This guide is part of the Codex project. To suggest improvements:
- Test the pattern thoroughly
- Provide benchmarks (build time, image size)
- Include security considerations
- Add language-specific examples
End of Docker Assembly Guide
For LLM consumption: This document provides battle-tested Docker patterns covering multi-stage builds, layer caching, BuildKit features, dev/prod modes, security, and language-specific examples. Use this as a reference when building production-grade containerized applications.