Skip to content

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

  1. Introduction
  2. Multi-Stage Builds
  3. Layer Caching Optimization
  4. BuildKit and Build Cache
  5. Development vs Production Builds
  6. Security Best Practices
  7. Performance Optimization
  8. Language-Specific Patterns
  9. Docker Compose Patterns
  10. CI/CD Integration
  11. Troubleshooting
  12. 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:

  1. Build in a full-featured environment (compilers, dev tools)
  2. Deploy to a minimal runtime (only dependencies needed to run)

Result: 90%+ reduction in final image size.

Basic Pattern

# Stage 1: Build environment
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production runtime
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]

Advanced Multi-Stage Pattern

# Stage 1: Base dependencies (shared)
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Development dependencies
FROM base AS dev-deps
RUN npm ci
# Stage 3: Build
FROM dev-deps AS builder
COPY . .
RUN npm run build
RUN npm run test
# Stage 4: Production (minimal)
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
# Stage 5: Development (full tooling)
FROM dev-deps AS development
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]

Key Benefits:

  • base stage shared by all subsequent stages (cache reuse)
  • dev-deps includes testing/linting tools
  • production stage is minimal (no dev dependencies)
  • development stage includes hot-reload and debugging

Selecting Build Targets

Terminal window
# Build for production
docker build --target production -t myapp:prod .
# Build for development
docker 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:20
RUN apt-get update && apt-get install -y \
build-essential python3 git curl vim
COPY . .
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 builder
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["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:

  1. Each instruction (RUN, COPY, ADD) creates a new layer
  2. Layers are cached based on instruction + file content hash
  3. 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 /app
COPY Cargo.toml Cargo.lock ./
# 4. Dummy build to cache dependencies
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# 5. Source code (changes frequently)
COPY src ./src
# 6. Final build (only rebuilds if source changed)
RUN cargo build --release
# Production stage
FROM alpine:3.19
COPY --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 builder
WORKDIR /app
# Copy only dependency manifests first
COPY 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 application
RUN npm run build

Why This Works:

  • npm ci runs only when package.json or package-lock.json changes
  • Source code changes don’t trigger dependency reinstall
  • Typical speedup: 5 minutes → 30 seconds

Dependency Caching Pattern (Rust)

FROM rust:1.75-slim AS builder
WORKDIR /app
# Copy manifests
COPY Cargo.toml Cargo.lock ./
# Create dummy source to build dependencies
RUN mkdir src && \
echo "fn main() {println!(\"dummy\")}" > src/main.rs && \
cargo build --release && \
rm -rf src
# Copy real source
COPY src ./src
# Build real application (dependencies already cached)
RUN cargo build --release

Rust-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 builder
WORKDIR /app
# Install dependencies first
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY . .

.dockerignore File

Critical for cache efficiency: Exclude files that shouldn’t trigger rebuilds.

.dockerignore
.git
.gitignore
.dockerignore
.env
.env.*
*.md
LICENSE
# Build artifacts
node_modules
dist
build
target
*.pyc
__pycache__
# IDE files
.vscode
.idea
*.swp
# Logs
*.log
logs
# OS files
.DS_Store
Thumbs.db
# Test files (unless needed in container)
tests
*.test.js
*.spec.ts

Impact: Prevents cache invalidation from irrelevant file changes.

Layer Squashing (When Appropriate)

Terminal window
# 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
Terminal window
# Enable BuildKit (one-time setup)
export DOCKER_BUILDKIT=1
# Or use buildx (BuildKit as a plugin)
docker buildx create --use
# Verify
docker buildx version

Make it permanent (add to ~/.bashrc or ~/.zshrc):

Terminal window
export DOCKER_BUILDKIT=1

Cache 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 builder
WORKDIR /app
COPY package*.json ./
# Mount npm cache during install
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build

Impact: npm’s cache persists across builds, 3-5x faster npm installs.

Rust with Cache Mount

FROM rust:1.75-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
# Mount cargo registry and git cache
RUN --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-slim
COPY --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 builder
WORKDIR /app
COPY requirements.txt ./
# Mount pip cache
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt

Go with Cache Mount

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
# Mount Go module cache
RUN --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 app

Secret Mounts

Problem: Need to access private registries, API keys during build without leaking them into image layers.

Old (Insecure) Way:

# ❌ NEVER DO THIS
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
RUN npm install
RUN rm .npmrc # Too late! Token is in layer history

Secure Way with BuildKit:

# ✅ Secret never enters image layers
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
Terminal window
# Build with secret
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
-t myapp .

Git SSH Keys:

# Clone private repo without leaking SSH key
RUN --mount=type=ssh \
git clone git@github.com:myorg/private-repo.git
Terminal window
docker 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.

Terminal window
# Build and push cache to registry
docker 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=max

Impact: First build takes 10 minutes, subsequent builds take 1 minute.

Inline Cache

Simpler alternative: Embed cache metadata in the image itself.

Terminal window
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 base
WORKDIR /app
COPY package*.json ./
# ----- Development Target -----
FROM base AS development
# Install ALL dependencies (including devDependencies)
RUN npm install
# Copy source code
COPY . .
# Expose app and debugger ports
EXPOSE 3000 9229
# Hot-reload with nodemon
CMD ["npm", "run", "dev"]
# ----- Production Build -----
FROM base AS builder
# Install only production dependencies
RUN npm ci --only=production
# Copy source and build
COPY . .
RUN npm run build
# ----- Production Target -----
FROM node:20-alpine AS production
WORKDIR /app
# Security: run as non-root
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy production dependencies and built artifacts
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./
USER nodejs
EXPOSE 3000
CMD ["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.local

Development:

Terminal window
# Uses 'development' target by default
docker-compose up
# Hot-reload works via volume mount
# Debugger available on port 9229

Production:

Terminal window
# Use production target
BUILD_TARGET=production docker-compose up
# Or with explicit override
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up

docker-compose.prod.yml:

services:
app:
build:
target: production
volumes: [] # Remove volume mounts
ports:
- "3000:3000" # No debugger port

Development Features

Hot Reload (Node.js)

FROM node:20-alpine AS development
WORKDIR /app
# Install nodemon globally
RUN npm install -g nodemon
COPY package*.json ./
RUN npm install
COPY . .
# Use nodemon for hot-reload
CMD ["nodemon", "--watch", "src", "--ext", "ts,js", "--exec", "node", "src/index.js"]

Hot Reload (Python with Flask)

FROM python:3.12-slim AS development
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install -r requirements.txt -r requirements-dev.txt
COPY . .
# Flask development server with hot-reload
ENV FLASK_ENV=development
CMD ["flask", "run", "--host=0.0.0.0", "--reload"]

Debugging Configuration

FROM node:20-alpine AS development
# ...
EXPOSE 9229
CMD ["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 1

healthcheck.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 production
WORKDIR /app
# Install dumb-init (PID 1 signal forwarding)
RUN apk add --no-cache dumb-init
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
ENTRYPOINT ["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 production
WORKDIR /app
# Create user and group
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy files with correct ownership
COPY --chown=nodejs:nodejs --from=builder /app/dist ./dist
COPY --chown=nodejs:nodejs --from=builder /app/node_modules ./node_modules
# Switch to non-root user
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Verify:

Terminal window
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 ImageSizePackagesUse Case
ubuntu:22.0477 MB~200Legacy apps, need apt-get
debian:bookworm-slim74 MB~100Most apps, good compatibility
alpine:3.197 MB~15Size-critical, static binaries
distroless/static2 MB0Go, Rust (static binaries)
distroless/base20 MB~10Dynamic binaries, minimal runtime
scratch0 MB0Single 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 stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app
# Production stage with distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]

Distroless Benefits:

  • No shell, no package manager (can’t docker exec into it)
  • Minimal attack surface
  • Google-maintained security updates

Using Scratch (Empty Image)

# Build stage
FROM rust:1.75-alpine AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
# Production: literally empty
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]

Result: 5-10MB final image (only your binary).

Scanning for Vulnerabilities

Terminal window
# Scan image with Docker Scout
docker scout cves myapp:latest
# Scan with Trivy
trivy image myapp:latest
# Scan with Snyk
snyk container test myapp:latest

CI/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 vulnerabilities

Read-Only Root Filesystem

FROM alpine:3.19
# ...
USER nobody
# Mark filesystem as read-only (runtime flag)
Terminal window
docker run --read-only myapp

For apps that need tmp writes:

Terminal window
docker run --read-only --tmpfs /tmp myapp

Drop Capabilities

Terminal window
# Drop all capabilities except necessary ones
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

In Docker Compose:

services:
app:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE

Secret Management

❌ NEVER embed secrets in images:

# WRONG!
ENV API_KEY=abc123
ENV DATABASE_PASSWORD=secret

✅ Correct approaches:

1. Environment variables at runtime:

Terminal window
docker run -e API_KEY=abc123 myapp

2. Docker secrets (Swarm mode):

Terminal window
echo "secret_value" | docker secret create api_key -
docker service create --secret api_key myapp

3. Build-time secrets (BuildKit):

RUN --mount=type=secret,id=api_key \
API_KEY=$(cat /run/secrets/api_key) ./configure.sh
Terminal window
docker buildx build --secret id=api_key,src=./api_key.txt .

Performance Optimization

Parallel Builds with BuildKit

FROM alpine AS fetch-repo-a
RUN apk add git && git clone https://github.com/repo-a
FROM alpine AS fetch-repo-b
RUN apk add git && git clone https://github.com/repo-b
FROM alpine AS production
COPY --from=fetch-repo-a /repo-a /app/repo-a
COPY --from=fetch-repo-b /repo-b /app/repo-b

With 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 update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y vim
RUN 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 build

Use .dockerignore Aggressively

# Ignore large directories that invalidate cache
node_modules
.git
.pytest_cache
__pycache__
*.pyc
# Ignore editor files
.vscode
.idea
# Ignore logs and temporary files
*.log
*.tmp
.DS_Store

Impact:

  • Faster COPY operations
  • 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):

Terminal window
# Create multi-platform builder
docker buildx create --name multiplatform --use
# Build for both architectures
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myapp:latest \
--push \
.

Dockerfile considerations:

FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
# Cross-compile
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o app

Production-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

  1. Start from minimal base images:

    • Alpine Linux (~5MB) for compatibility
    • Distroless (~20MB) for maximum security
    • Scratch (0MB) for static binaries
  2. Use aggressive multi-stage builds:

    • Separate dependency installation from builds
    • Discard build tools in final image
    • Copy only runtime artifacts
  3. Leverage BuildKit cache mounts:

    • Persist package managers across builds
    • Share compilation caches
    • Reduce redundant downloads
  4. Optimize for layer caching:

    • Copy dependency manifests first
    • Install dependencies before source
    • Use .dockerignore aggressively
  5. Runtime optimizations:

    • Enable JIT/AOT compilation
    • Pre-compile assets
    • Strip debug symbols

Image Size Comparison

LanguageBasicOptimizedSavings
Node.js1.2GB90MB92%
PHP800MB65MB91%
Rust2.1GB8MB99%

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 handling
RUN apk add --no-cache dumb-init
WORKDIR /app
# Copy package files for layer caching
COPY package.json package-lock.json ./
# ----- Stage 2: Production Dependencies -----
FROM base AS prod-deps
# Install only production dependencies with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production --ignore-scripts && \
npm cache clean --force
# Remove unnecessary files
RUN rm -rf \
/root/.npm \
/tmp/* \
/var/cache/apk/*
# ----- Stage 3: Build Stage -----
FROM base AS builder
# Set Node memory limit for build
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Install ALL dependencies (including devDependencies)
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy source code
COPY . .
# Build TypeScript/assets
RUN npm run build && \
npm prune --production
# Remove source maps and other dev artifacts
RUN 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 environment
ENV NODE_ENV=production \
NODE_OPTIONS="--max-old-space-size=512 --enable-source-maps=false"
WORKDIR /app
# Copy dumb-init from alpine
COPY --from=base /usr/bin/dumb-init /usr/bin/dumb-init
# Copy production dependencies
COPY --from=prod-deps --chown=nonroot:nonroot /app/node_modules ./node_modules
# Copy built application
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --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 handling
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/nodejs/bin/node", "dist/index.js"]

Optimization Techniques:

  1. Dependency Layer Separation: Production deps cached separately from build deps
  2. Distroless Base: No shell, package manager, or unnecessary tools (~40MB smaller)
  3. Cache Mounts: npm cache persisted across builds (2-5x faster rebuilds)
  4. Source Map Removal: Strips debug artifacts (15-20% size reduction)
  5. Memory Limits: Constrains runtime to prevent OOM in containers

Build Command:

Terminal window
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 files
COPY composer.json composer.lock ./
# Install production dependencies with cache mount
RUN --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 files
COPY package.json package-lock.json ./
# Install node dependencies
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# Copy source for asset compilation
COPY resources ./resources
COPY public ./public
COPY vite.config.js tailwind.config.js postcss.config.js ./
# Build assets
RUN 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 production
RUN 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=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=0
opcache.fast_shutdown=1
opcache.jit=tracing
opcache.jit_buffer_size=100M
EOF
# PHP performance tuning
RUN cat << 'EOF' > $PHP_INI_DIR/conf.d/performance.ini
[performance]
realpath_cache_size=4096K
realpath_cache_ttl=600
max_execution_time=30
memory_limit=256M
upload_max_filesize=10M
post_max_size=10M
expose_php=Off
EOF
# Create app user and directory
RUN 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 stage
COPY --from=composer --chown=app:app /app/vendor ./vendor
# Copy built assets from assets stage
COPY --from=assets --chown=app:app /app/public/build ./public/build
# Copy application code
COPY --chown=app:app . .
# Laravel optimizations
RUN 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 permissions
RUN chown -R app:app /app && \
chmod -R 755 /app/storage /app/bootstrap/cache
# Switch to non-root user
USER app
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD php artisan health:check || exit 1
# FrankenPHP with worker mode for maximum performance
CMD ["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:

  1. FrankenPHP: Modern PHP server with worker mode (3-5x faster than php-fpm)
  2. OPcache JIT: Tracing JIT for 20-30% performance boost
  3. Laravel Caching: Pre-cache routes, config, views (eliminates filesystem reads)
  4. Aggressive OPcache: Disabled timestamp validation for maximum speed
  5. Asset Pre-compilation: Vite/Tailwind built at image build time
  6. Minimal Extensions: Only required PHP extensions installed

Build Command:

Terminal window
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 recipe
RUN cargo chef prepare --recipe-path recipe.json
# ----- Stage 3: Builder (Cache Dependencies) -----
FROM chef AS builder
# Install musl tools for static linking
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
# Copy recipe from planner
COPY --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 code
COPY . .
# Build application with optimizations
RUN --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 binary
RUN ldd /app/app 2>&1 | grep -q "not a dynamic executable"
# ----- Stage 4: Production Runtime (Scratch) -----
FROM scratch AS production
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binary
COPY --from=builder /app/app /app
# Create minimal passwd file for non-root user
COPY --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 size
lto = true # Link-time optimization
codegen-units = 1 # Better optimization
strip = true # Strip symbols
panic = "abort" # Smaller panic handler
[profile.release.package."*"]
opt-level = "z"

Optimization Techniques:

  1. Cargo Chef: Caches dependencies separately (10-50x faster rebuilds)
  2. Static Linking: No runtime dependencies, works on scratch
  3. musl libc: Enables fully static binary
  4. LTO: Link-time optimization reduces size by 20-30%
  5. Strip: Removes debug symbols (30-40% size reduction)
  6. Scratch Base: Literally zero OS overhead (0MB)

Build Command:

Terminal window
docker build -f infra/dockerfiles/rust-api/prod.dockerfile -t rust-app:production .

Alternative: Distroless (if you need libc compatibility):

# Instead of scratch, use distroless
FROM gcr.io/distroless/static-debian12:nonroot AS production
COPY --from=builder /app/app /app
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/app"]

Advanced Optimization Techniques

1. BuildKit Secret Mounts

For build-time secrets (API keys, private repos):

# Mount secrets without leaving them in layers
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && \
npm ci --only=production && \
rm ~/.npmrc

Build command:

Terminal window
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-arch
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
# Platform-specific optimizations
RUN case "$TARGETPLATFORM" in \
"linux/amd64") echo "x86_64 optimizations" ;; \
"linux/arm64") echo "ARM64 optimizations" ;; \
esac

Build command:

Terminal window
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):

Terminal window
# Build with squash
docker 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:

Terminal window
# Export cache
docker buildx build \
--cache-to type=registry,ref=myregistry/app:cache \
-t app:production .
# Import cache in CI
docker buildx build \
--cache-from type=registry,ref=myregistry/app:cache \
-t app:production .

5. Dive Analysis

Analyze layers with dive tool:

Terminal window
# Install dive
brew install dive # macOS
# or
docker pull wagoodman/dive
# Analyze image
dive app:production

Look for:

  • Large layers (>50MB)
  • Duplicate files
  • Unnecessary files (logs, cache, .git)

Security Hardening Checklist

All production Dockerfiles must include:

  • Non-root user: USER nonroot or USER nobody
  • Read-only root filesystem: --read-only flag in runtime
  • No shell: Use distroless or scratch
  • Minimal packages: Only runtime dependencies
  • Pinned versions: No latest tags
  • Vulnerability scanning: docker scout or trivy
  • 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:

Terminal window
# Scan with Docker Scout
docker scout cves app:production
# Scan with Trivy
trivy image app:production

CI/CD Integration

GitLab CI example for optimized builds:

.gitlab-ci.yml
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:
- main

Performance Benchmarks

Build Times (with cache)

LanguageFirst BuildCached RebuildCache Hit Rate
Node.js3m 20s15s95%
PHP2m 45s12s93%
Rust8m 10s25s98%

Image Sizes

LanguageUnoptimizedOptimizedCompression Ratio
Node.js1.2GB90MB13x
PHP800MB65MB12x
Rust2.1GB8MB262x

Runtime Performance

MetricStandardOptimizedImprovement
Startup time2.5s0.3s8x
Memory usage512MB128MB4x
RPS (requests/sec)1,2004,8004x

Benchmarks measured on AWS t3.medium instance


Troubleshooting Production Images

Image won’t start

Terminal window
# Debug distroless/scratch images
docker run -it --entrypoint /bin/sh app:production
# Error: no shell available
# Solution: Use debug variant
FROM gcr.io/distroless/nodejs20-debian12:debug AS debug
# Includes busybox shell
# Or use multi-stage debugging
docker build --target=builder -t app:debug .
docker run -it app:debug sh

Binary not found (scratch image)

Terminal window
# Check binary exists and is static
docker run app:production ls / # Won't work on scratch
# Solution: Verify in builder stage
FROM builder AS verify
RUN ldd /app/binary # Should say "not a dynamic executable"

High memory usage

Terminal window
# Add memory limits to Dockerfile
ENV NODE_OPTIONS="--max-old-space-size=512" # Node.js
# Or in docker-compose.yml
services:
app:
deploy:
resources:
limits:
memory: 512M

Next Steps

  1. Baseline: Measure current image sizes with docker images
  2. Optimize: Apply production Dockerfile for your language
  3. Compare: Use dive to analyze layer sizes
  4. Benchmark: Test build times and runtime performance
  5. Scan: Run security scans with trivy or docker scout
  6. 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 optimizations
ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=4096
WORKDIR /app
# Copy dependency manifests
COPY package.json package-lock.json ./
# Install dependencies with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production && \
npm cache clean --force
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# ----- Production Stage -----
FROM node:20-alpine AS production
# Install dumb-init for signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy production artifacts
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
# Security
USER nodejs
EXPOSE 3000
# Health check
HEALTHCHECK --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 development
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm install
# Copy source (or mount as volume)
COPY . .
# Expose app and debugger
EXPOSE 3000 9229
# Use nodemon or ts-node-dev
CMD ["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: development

Rust

Optimal Production Dockerfile

# ----- Build Stage -----
FROM rust:1.75-alpine AS builder
# Install musl tools for static linking
RUN apk add --no-cache musl-dev openssl-dev
WORKDIR /app
# Copy manifests
COPY Cargo.toml Cargo.lock ./
# Create dummy src to cache dependencies
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
cargo build --release && \
rm -rf src
# Copy real source
COPY src ./src
# Build with cache mounts
RUN --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 only
RUN apk add --no-cache ca-certificates libgcc
# Create non-root user
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001
WORKDIR /app
# Copy binary
COPY --from=builder --chown=app:app /myapp ./myapp
USER app
EXPOSE 8080
CMD ["./myapp"]

Static Binary with Scratch

FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY . .
# Build static binary
RUN cargo build --release --target x86_64-unknown-linux-musl
# Scratch: no OS, just binary
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]

Result: 5-15MB total image size.


Python

Optimal Production Dockerfile

# ----- Build Stage -----
FROM python:3.12-slim AS builder
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements
COPY requirements.txt ./
# Install to custom location with cache mount
RUN --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 only
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 && \
rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -u 1001 app
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder --chown=app:app /install /usr/local
# Copy application code
COPY --chown=app:app . .
USER app
EXPOSE 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 development
WORKDIR /app
# Install dev dependencies
COPY requirements.txt requirements-dev.txt ./
RUN pip install -r requirements.txt -r requirements-dev.txt
COPY . .
EXPOSE 8000
CMD ["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 dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source and build
COPY . .
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 HTTPS
RUN apk --no-cache add ca-certificates
# Create non-root user
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001
WORKDIR /app
COPY --from=builder --chown=app:app /app/app ./
USER app
EXPOSE 8080
CMD ["./app"]

Static Binary with Scratch

FROM golang:1.22-alpine AS builder
WORKDIR /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 binary
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]

Result: 8-20MB total image (depending on dependencies).


PHP

Optimal Production Dockerfile

# ----- Build Stage -----
FROM composer:2 AS builder
WORKDIR /app
# Copy composer files
COPY composer.json composer.lock ./
# Install dependencies with cache mount
RUN --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 extensions
RUN 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 configuration
RUN { \
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 builder
COPY --from=builder /app/vendor ./vendor
# Copy application
COPY . .
# Set permissions
RUN chown -R www-data:www-data /var/www/html
USER www-data
EXPOSE 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:
- app

Docker 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-stopped

Usage:

Terminal window
# Development
docker-compose up
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Testing 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 speed

Usage:

Terminal window
docker-compose -f docker-compose.test.yml run --rm test

Health 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: 10s

Ensures: 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.md

Pattern: 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: bridge

docker/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.yml

docker/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: bridge

docker/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.yml

docker/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: true

Composition Strategies

Strategy 1: Development (Minimal Stack)

Terminal window
# Start only app with its dependencies
docker-compose -f docker/compose/db.yml \
-f docker/compose/redis.yml \
-f docker/compose/app.yml \
up

Strategy 2: Full Development Stack

Terminal window
# All services for local development
docker-compose -f docker/compose/db.yml \
-f docker/compose/redis.yml \
-f docker/compose/app.yml \
-f docker/compose/nginx.yml \
up

Strategy 3: Production with Workers

Terminal window
# Production configuration
BUILD_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 -d

Strategy 4: Testing (Isolated)

Terminal window
# Just database for integration tests
docker-compose -f docker/compose/db.yml up -d
npm test
docker-compose -f docker/compose/db.yml down

Strategy 5: Monitoring Stack

Terminal window
# Add monitoring to existing services
docker-compose -f docker/compose/db.yml \
-f docker/compose/redis.yml \
-f docker/compose/app.yml \
-f docker/compose/monitoring.yml \
up -d

Helper Scripts

scripts/dev.sh (Development):

#!/bin/bash
set -e
export BUILD_TARGET=development
export NODE_ENV=development
docker-compose \
-f docker/compose/db.yml \
-f docker/compose/redis.yml \
-f docker/compose/app.yml \
up --build

scripts/prod.sh (Production):

#!/bin/bash
set -e
export BUILD_TARGET=production
export 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 --build

scripts/test.sh (Testing):

#!/bin/bash
set -e
# Start only dependencies
docker-compose -f docker/compose/db.yml up -d
# Wait for health check
until docker-compose -f docker/compose/db.yml ps | grep -q "healthy"; do
echo "Waiting for database..."
sleep 2
done
# Run tests
npm test
# Cleanup
docker-compose -f docker/compose/db.yml down -v

Makefile (Convenience):

.PHONY: dev up down clean test
# Base compose files
BASE := -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 services
down:
@docker-compose $(BASE) down
# Run tests
test:
@bash scripts/test.sh
# Clean up
clean:
@docker-compose $(BASE) down -v
@docker system prune -f
# Individual services
db:
@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 \
up

Benefits 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.yml

docker/.config/.env.shared (Common configuration):

Terminal window
# Shared configuration across all services
PROJECT_NAME=myapp
ENVIRONMENT=development
LOG_LEVEL=debug
# Network configuration
NETWORK_SUBNET=172.20.0.0/16
# Timezone
TZ=UTC
# Feature flags
ENABLE_MONITORING=false
ENABLE_METRICS=false

docker/.config/.env.secrets (Shared credentials):

Terminal window
# Database credentials (shared by app, worker, etc.)
DB_USER=myuser
DB_PASSWORD=mypassword
DB_NAME=myapp
DB_HOST=db
DB_PORT=5432
# Redis credentials
REDIS_PASSWORD=redispassword
REDIS_HOST=redis
REDIS_PORT=6379
# API keys (shared across services)
JWT_SECRET=your-jwt-secret-here
API_KEY=your-api-key-here
ENCRYPTION_KEY=your-encryption-key-here

docker/.config/.env.app (Application-specific):

Terminal window
# Application configuration
APP_NAME=myapp
APP_PORT=3000
APP_HOST=0.0.0.0
# Node.js specific
NODE_ENV=development
BUILD_TARGET=development
# Application features
ENABLE_DEBUG=true
ENABLE_HOT_RELOAD=true
MAX_UPLOAD_SIZE=10mb
# External services
SMTP_HOST=smtp.example.com
SMTP_PORT=587
STRIPE_API_KEY=sk_test_...

docker/.config/.env.db (Database-specific):

Terminal window
# PostgreSQL configuration
POSTGRES_USER=${DB_USER}
POSTGRES_PASSWORD=${DB_PASSWORD}
POSTGRES_DB=${DB_NAME}
# Performance tuning
POSTGRES_MAX_CONNECTIONS=100
POSTGRES_SHARED_BUFFERS=128MB
# Logging
POSTGRES_LOG_STATEMENT=all
POSTGRES_LOG_DURATION=on

docker/.config/.env.nginx (Nginx-specific):

Terminal window
# Nginx configuration
NGINX_WORKER_PROCESSES=auto
NGINX_WORKER_CONNECTIONS=1024
NGINX_KEEPALIVE_TIMEOUT=65
# Upstream
UPSTREAM_HOST=app
UPSTREAM_PORT=3000
# SSL (if enabled)
NGINX_SSL_CERT=/etc/nginx/ssl/cert.pem
NGINX_SSL_KEY=/etc/nginx/ssl/key.pem

docker/.config/.env.redis (Redis-specific):

Terminal window
# Redis configuration
REDIS_MAXMEMORY=256mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
REDIS_APPENDONLY=yes
REDIS_APPENDFSYNC=everysec

Referencing 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: bridge

docker/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: bridge

docker/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: true

Benefits:

1. Clear Separation:

  • Service-specific config isolated
  • Shared config in one place
  • Secrets centralized and easily rotated

2. No Duplication:

Terminal window
# 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.secrets

3. 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 config

docker/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-stopped

4. Security:

.gitignore
# Never commit secrets
docker/.config/.env.secrets
docker/.config/production/.env.secrets
# Commit templates
docker/.config/.env.secrets.template
docker/.config/.env.shared
docker/.config/.env.app

docker/.config/.env.secrets.template:

Terminal window
# Template for secrets - copy to .env.secrets and fill in
DB_USER=
DB_PASSWORD=
DB_NAME=
JWT_SECRET=
API_KEY=
ENCRYPTION_KEY=

5. Easy Rotation:

Terminal window
# Rotate database password
vim docker/.config/.env.secrets # Update DB_PASSWORD
# All services using secrets get new password
docker-compose -f docker/compose/app.yml \
-f docker/compose/worker.yml \
-f docker/compose/db.yml \
up -d --force-recreate

Baseline + 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 overrides

Key Development Features:

  1. Mount source code for hot-reload
  2. Volume mounts over dependency directories (node_modules, vendor, target)
  3. Expose additional ports (debuggers, profilers)
  4. Enable verbose logging
  5. 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: bridge

docker/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:

Terminal window
# 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 \
up

Example: 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: bridge

docker/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: bridge

docker/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: bridge

docker/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: true

docker/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/bash
set -e
# Start full development stack
docker-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 --build

scripts/prod.sh:

#!/bin/bash
set -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 --build

Makefile:

.PHONY: dev up down stop clean logs ps build
# Service variable (optional) - specify with: make dev s=app
s ?=
# Available services
ALL_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 below
BASELINE_SERVICES := db app
# Base compose files (baseline services only)
# Must match BASELINE_SERVICES list above
BASE_COMPOSE := -f docker/compose/db.yml \
-f docker/compose/app.yml
# Development overrides (baseline services only)
# Must match BASELINE_SERVICES list above
DEV_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 variable
ifdef s
BASE_FILES := -f docker/compose/$(s).yml
DEV_FILES := -f docker/compose/$(s).yml -f docker/compose/$(s).dev.yml
else
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 --build
else
@echo "🚀 Starting baseline services in development mode..."
@docker-compose $(DEV_FILES) up --build
endif
# 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 --build
else
@echo "🚀 Starting baseline services in production mode..."
@docker-compose $(BASE_FILES) up -d --build
endif
# Stop all services
down:
@echo "🛑 Stopping all services..."
@docker-compose $(BASE_COMPOSE) down
# Alias for down
stop: 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 -f
endif
# Show running containers
ps:
@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) build
else
@echo "🔨 Building images..."
@docker-compose $(BASE_FILES) build
endif
# Restart specific service
# Usage: make restart s=app
restart:
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 1
endif
# Database management
db-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 usage
stats:
@docker stats $$(docker-compose $(BASE_COMPOSE) ps -q)
# Help
help:
@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:

Terminal window
# 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 mode
make 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 mode
make up s=redis
# Stop all services
make down
# View logs from baseline services
make logs
# View logs from specific service
make logs s=app
# Build baseline service images
make build
# Build only specific service
make build s=nginx
# Restart specific service
make restart s=app
# Show what's running
make ps
# Clean everything (careful - deletes volumes!)
make clean
# Database operations
make db-shell # Open psql shell
make db-backup # Backup database to file
# Monitor resource usage
make stats
# See all available commands
make help

Workflow Examples:

Terminal window
# Daily development workflow
make dev # Start baseline services in dev mode (db + app)
# ... make code changes ...
# Changes auto-reload via mounted volumes
make down # Stop when done
# Working on specific service
make dev s=app # Start only app in dev mode
make logs s=app # Watch app logs
# ... make changes, see hot-reload ...
make restart s=app # Restart if needed
# Testing in production mode locally
make build # Build baseline production images (db + app)
make up # Start baseline services in production mode
make logs # Check logs
make down # Stop
# Testing only one service in production mode
make build s=nginx # Build only nginx
make up s=nginx # Start only nginx in prod mode
make logs s=nginx # Check nginx logs
# Database management
make dev s=db # Start only db in dev mode
make db-shell # Access database
# ... run queries ...
make db-backup # Backup before risky changes
# Resource monitoring
make stats # Watch resource usage of all services
# ctrl+c to exit stats
# Iterative development on frontend + backend
make dev s=app # Terminal 1: app with hot-reload
make dev s=nginx # Terminal 2: nginx for routing
make logs s=app # Terminal 3: watch app logs

Customizing 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 needs
BASELINE_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 := db

Why Baseline Services?

  • Resource Efficiency: Don’t spin up services you don’t need
  • Fast Startup: Minimal services = faster development cycles
  • Explicit Control: Use s=nginx to start additional services when needed
  • Flexible: Adjust BASELINE_SERVICES to 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_modules

3. Hot-Reload Without Rebuilding:

volumes:
- ../../src:/app/src:cached # Changes appear instantly
command: npm run dev # Watches for changes

4. Easy Debugging:

ports:
- "9229:9229" # Node.js debugger exposed
environment:
RUST_BACKTRACE: 1 # Detailed stack traces

5. Clean Separation:

Terminal window
# Production: Only baseline
docker-compose -f app.yml up -d
# Development: Baseline + overrides
docker-compose -f app.yml -f app.dev.yml up
# Testing: Baseline + test overrides
docker-compose -f app.yml -f app.test.yml run test

Common 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/pip

Debug Ports:

# Node.js
- "9229:9229" # --inspect
# Python
- "5678:5678" # debugpy
# Rust
- "5555:5555" # lldb-server
# PHP
- "9003:9003" # Xdebug
# Go
- "2345:2345" # delve

Log Mounts:

volumes:
- app_logs:/var/log/app
- nginx_logs:/var/log/nginx
- postgres_logs:/var/log/postgresql

Advanced: Dynamic Stack Composition

scripts/compose.sh (Smart composition):

#!/bin/bash
set -e
COMPOSE_FILES=()
# Always include base services
COMPOSE_FILES+=("-f" "docker/compose/db.yml")
COMPOSE_FILES+=("-f" "docker/compose/redis.yml")
COMPOSE_FILES+=("-f" "docker/compose/app.yml")
# Conditional services based on environment
if [ "$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 files
docker-compose "${COMPOSE_FILES[@]}" "$@"

Usage:

Terminal window
# 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 -d

Best 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}.yml for service definitions
  • {service}.{env}.yml for 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 .env for 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 structure
COPY 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 443
CMD ["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: true

Pattern: 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=3000
host=0.0.0.0
[security]
cors_enabled=true
rate_limit=100
[features]
enable_analytics=false
enable_monitoring=true

Dockerfile:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Create necessary directories
RUN mkdir -p /var/log/app /etc/app
# Copy application structure from host
COPY docker/system/app/ /
# Copy built artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Files are now at:
# /app/config.json
# /etc/app/app.conf
# /var/log/app/ (directory exists)
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Pattern: Database Service with Init Scripts

docker/system/postgres/etc/postgresql/postgresql.conf:

# PostgreSQL configuration
max_connections = 100
shared_buffers = 128MB
dynamic_shared_memory_type = posix
max_wal_size = 1GB
min_wal_size = 80MB
log_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 setup
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Create application user
CREATE USER app_user WITH PASSWORD 'app_password';
GRANT ALL PRIVILEGES ON DATABASE mydb TO app_user;
-- Create initial schema
CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION app_user;

docker/system/postgres/docker-entrypoint-initdb.d/02-seed.sql:

-- Seed data
SET 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 scripts
COPY 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 5432

Benefits of 1:1 Structure

1. Intuitive File Organization:

Terminal window
# 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 container

2. 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/nginx

4. 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 organized

5. Development Workflow:

Terminal window
# Edit config on host
vim 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 up

Complete 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: bridge

Best 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 structure
COPY docker/system/app/ /
# Runtime: mount for easy updates
volumes:
- ./docker/system/app/etc/app:/etc/app:ro

3. Version Control:

.gitignore
# Don't commit generated/sensitive files
docker/system/*/var/log/
docker/system/*/var/run/
docker/system/*/tmp/
# Do commit configuration templates
!docker/system/*/etc/
!docker/system/*/app/

4. Documentation:

docker/system/README.md
## 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 scripts

5. 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 app

Complete 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 script

Usage Examples:

Development:

Terminal window
# Full dev stack with all overrides
docker-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 tools

Production:

Terminal window
# 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 enabled

Testing (Isolated):

Terminal window
# Just database for integration tests
docker-compose -f docker/compose/db.yml up -d
npm test
docker-compose -f docker/compose/db.yml down

Key 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:

Terminal window
# Edit any config file
vim docker/system/nginx/etc/nginx/nginx.conf
# Immediately visible where it goes
# → /etc/nginx/nginx.conf in container
# Reference in compose file
volumes:
- ../../docker/system/nginx/etc/nginx:/etc/nginx:ro
# Environment from centralized location
env_file:
- ../.config/.env.shared
- ../.config/.env.secrets
- ../.config/.env.nginx

Result: 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_TAG

Multi-Stage CI/CD Pipeline

.github/workflows/complete-pipeline.yml
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:latest

Troubleshooting

Common Issues and Solutions

Issue: “Cache never hits”

Symptoms: Every build reinstalls dependencies, even when unchanged.

Causes:

  1. Files copied before dependencies (wrong order)
  2. .dockerignore not excluding volatile files
  3. COPY . before dependency install

Solution:

# ❌ Wrong order
COPY . .
RUN npm install
# ✅ Correct order
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Debug:

Terminal window
# See which layer invalidated cache
docker build --progress=plain .

Issue: “Build takes forever”

Causes:

  1. Not using BuildKit
  2. No cache mounts for package managers
  3. Copying large context (missing .dockerignore)
  4. Sequential builds instead of parallel

Solutions:

1. Enable BuildKit:

Terminal window
export DOCKER_BUILDKIT=1
docker buildx create --use

2. Add cache mounts:

RUN --mount=type=cache,target=/root/.npm npm ci

3. Create .dockerignore:

node_modules
.git
*.log

4. Parallelize independent stages:

FROM alpine AS stage-a
RUN long_operation_a
FROM alpine AS stage-b
RUN long_operation_b
FROM alpine
COPY --from=stage-a /output-a .
COPY --from=stage-b /output-b .

Issue: “Image is huge (5GB+)”

Causes:

  1. Not using multi-stage builds
  2. Including dev dependencies
  3. Not cleaning up after installs

Solutions:

1. Multi-stage:

FROM node:20 AS builder
RUN npm ci
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/dist ./dist

2. Production-only dependencies:

RUN npm ci --only=production

3. Clean up in same layer:

RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*

Debug size:

Terminal window
# See layer sizes
docker history myapp:latest
# Analyze with dive
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest myapp:latest

Issue: “Container exits immediately”

Symptoms: docker run exits with code 0 or 1 immediately.

Causes:

  1. CMD runs and exits (not a long-running process)
  2. Entrypoint script has errors
  3. User doesn’t have permissions

Debug:

Terminal window
# See logs
docker logs <container-id>
# Run interactively with shell
docker run -it --entrypoint /bin/sh myapp
# Override CMD to keep container alive
docker run -it myapp tail -f /dev/null

Solution:

# Ensure CMD is long-running
CMD ["node", "server.js"] # ✅ Long-running
CMD ["echo", "hello"] # ❌ Exits immediately

Issue: “Permission denied” errors

Causes:

  1. Files copied as root, but running as non-root user
  2. Trying to write to read-only directories

Solution:

# Copy with correct ownership
COPY --chown=nodejs:nodejs . .
# Or change ownership after copy
COPY . .
RUN chown -R nodejs:nodejs /app

Issue: “BuildKit errors”

Symptoms: failed to solve with frontend dockerfile.v0

Causes:

  1. Syntax error in Dockerfile
  2. BuildKit not enabled
  3. Old Docker version

Solutions:

Terminal window
# Enable BuildKit
export DOCKER_BUILDKIT=1
# Update Docker
# (macOS: Docker Desktop settings)
# (Linux: apt-get update && apt-get install docker-ce)
# Validate Dockerfile syntax
docker build --check .

Issue: “Out of disk space”

Symptoms: no space left on device

Solution:

Terminal window
# Remove unused images
docker image prune -a
# Remove build cache
docker builder prune
# Remove everything unused
docker system prune -a --volumes
# See disk usage
docker system df

Issue: “Cannot connect to database from container”

Causes:

  1. Using localhost instead of service name
  2. Database not ready when app starts
  3. Network isolation

Solution:

1. Use service names (Docker Compose):

services:
app:
environment:
# ❌ DATABASE_HOST=localhost
# ✅ Use service name
DATABASE_HOST=db
db:
image: postgres

2. Wait for database:

app:
depends_on:
db:
condition: service_healthy
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s

3. Check networks:

Terminal window
# Ensure containers on same network
docker network inspect <network-name>

Quick Reference

Essential Commands

Terminal window
# Build with BuildKit and cache
export DOCKER_BUILDKIT=1
docker build -t myapp:latest .
# Build specific target
docker build --target production -t myapp:prod .
# Build with cache from registry
docker 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 build
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myapp:latest \
--push \
.
# Build with secrets
docker 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 limits
docker run --cpus=0.5 --memory=512m myapp
# Check image size
docker images myapp
# Inspect layers
docker history myapp:latest
# Scan for vulnerabilities
docker scout cves myapp:latest
trivy image myapp:latest
# Clean up
docker system prune -a
docker builder prune

Dockerfile Template (Multi-Language)

# ----- Build Stage -----
FROM <base-builder-image> AS builder
WORKDIR /app
# Copy dependency manifests
COPY <dependency-files> ./
# Install dependencies with cache mount
RUN --mount=type=cache,target=<cache-dir> \
<install-command>
# Copy source
COPY . .
# Build application
RUN <build-command>
# ----- Production Stage -----
FROM <minimal-runtime-image> AS production
# Install runtime dependencies only
RUN <install-runtime-deps>
# Create non-root user
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001
WORKDIR /app
# Copy artifacts from builder
COPY --from=builder --chown=app:app <build-output> ./
# Security
USER app
EXPOSE <port>
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD <health-check-command>
# Run
CMD [<command>]

.dockerignore Template

# Version control
.git
.gitignore
.dockerignore
# Dependencies (will be installed in container)
node_modules
vendor
target
__pycache__
# Build artifacts
dist
build
*.exe
*.o
*.so
# IDE
.vscode
.idea
*.swp
*.swo
# Logs
*.log
logs/
# OS files
.DS_Store
Thumbs.db
# Environment
.env
.env.*
# Documentation (unless needed in container)
*.md
docs/
# Tests (unless running in container)
tests/
*.test.js
*.spec.ts

Docker 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

Advanced Topics


Appendix: Real-World Examples

Example 1: Next.js Application

# ----- Dependencies Stage -----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# ----- Builder Stage -----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ----- Production Stage -----
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Example 2: Rust Microservice (Static Binary)

FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY 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 ./src
RUN touch src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1001:1001
ENTRYPOINT ["/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:

  1. Test the pattern thoroughly
  2. Provide benchmarks (build time, image size)
  3. Include security considerations
  4. 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.