
Docker
- 92 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with devops & ci/cd tasks during AI-assisted development.
About
docker is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted coding.
- docker
- DevOps & CI/CD
- AI-coding skill
Docker by the numbers
- 92 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #565 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill dockerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 92 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with devops & ci/cd tasks during AI-assisted development.
Files
Docker
Overview
Docker packages applications into isolated containers that run consistently across environments. A Dockerfile defines the image build steps, Compose orchestrates multi-container services, and production patterns ensure small, secure, performant images.
When to use: Containerizing applications, creating reproducible dev environments, orchestrating multi-service stacks, deploying to container platforms (ECS, Kubernetes, Fly.io, Railway, Coolify).
When NOT to use: Simple static sites with no backend (use CDN deploy), single-binary CLI tools (distribute the binary), or when the target platform has native buildpacks (Heroku, Vercel) and you don't need container control.
Quick Reference
| Pattern | Approach | Key Points |
|---|---|---|
| Multi-stage build | Separate builder and production stages | 80%+ image size reduction, no dev deps in production |
| Layer caching | Copy lockfile first, install, then copy source | Dependency layer cached across builds |
| Non-root user | RUN adduser + USER in final stage | Never run production containers as root |
| Health check | HEALTHCHECK CMD curl or node/python check | Enables orchestrator restart on failure |
.dockerignore | Exclude node_modules, .git, .env | Smaller build context, faster builds |
| Compose services | compose.yaml with service definitions | Dev environment in one command |
| Compose override | compose.prod.yaml with production settings | Environment-specific config without duplication |
| Named volumes | volumes: in Compose for persistent data | Survives container recreation |
| Build cache mount | RUN --mount=type=cache,target=/root/.npm | Persistent cache across builds |
| Secrets in build | RUN --mount=type=secret,id=token | Never bake secrets into image layers |
| Image pinning | Pin to major.minor or digest | Reproducible builds, avoid surprise breakage |
| Container networking | Custom bridge networks with service discovery | Containers resolve each other by service name |
| Compose watch | develop.watch with sync/rebuild actions | Live reload without volume mounts |
| Init process | --init flag or tini entrypoint | Proper signal handling and zombie reaping |
| Multi-platform | docker buildx build --platform | ARM (Apple Silicon, Graviton) + x86 in one image |
| Monorepo prune | turbo prune app --docker | Minimal build context from workspace dependencies |
| CI layer caching | cache-from/cache-to with GHA or registry | Avoid full rebuilds in CI pipelines |
| Debug containers | docker exec, docker logs, dive | Inspect running containers and image layers |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Installing dev dependencies in production image | Multi-stage build: install in builder, copy artifacts to runtime |
| Copying source before installing dependencies | Copy lockfile first, npm ci, then copy source for cache reuse |
| Running as root in production | Create non-root user, USER directive in final stage |
| Hardcoding secrets in Dockerfile or ENV | Use build secrets (--mount=type=secret) or runtime env |
Using latest tag for base images | Pin to specific version (node:24-alpine) |
No .dockerignore file | Exclude node_modules, .git, .env, build artifacts |
Using npm install instead of npm ci | npm ci for deterministic, lockfile-based installs |
| HEALTHCHECK missing | Add health check for orchestrator integration |
Large base images (node:24) | Use alpine variants (node:24-alpine) for smaller images |
Ignoring .env file precedence in Compose | environment: in Compose overrides .env file values |
| Building entire monorepo for one service | Use turbo prune --docker for minimal build context |
| No layer caching in CI | Use cache-from/cache-to with GHA or registry backend |
| Building only for x86 when deploying to ARM | Use docker buildx with --platform linux/amd64,linux/arm64 |
Delegation
- Dockerfile review: Use
Taskagent to audit Dockerfiles for size, security, and caching - Compose exploration: Use
Exploreagent to discover existing Docker configurations - Architecture decisions: Use
Planagent for container orchestration strategy
If the ci-cd-architecture skill is available, delegate CI/CD pipeline and deployment strategy to it.If the application-security skill is available, delegate container security scanning and hardening review to it.References
- Dockerfile patterns: multi-stage builds, layer caching, and image optimization
- Compose: services, networking, volumes, and environment management
- Security: non-root users, secrets, scanning, and production hardening
- Buildx: multi-platform builds for ARM and x86
- CI: GitHub Actions caching, registry push, and automated builds
- Monorepo: Turborepo prune, pnpm workspaces, and selective builds
- Debugging: logs, exec, inspect, layer analysis, and network troubleshooting
Buildx and Multi-Platform
Why Multi-Platform
ARM is everywhere: Apple Silicon (M1-M4), AWS Graviton, Azure Ampere, Fly.io, Raspberry Pi. Building for both linux/amd64 and linux/arm64 ensures images run natively on any target without emulation overhead.
Setup
# Create a new builder with multi-platform support
docker buildx create --name multiplatform --use --bootstrap
# Verify available platforms
docker buildx inspect --bootstrap
# Platforms: linux/amd64, linux/arm64, linux/arm/v7, ...Basic Multi-Platform Build
# Build and push for both platforms
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t myregistry/myapp:latest \
.Docker creates a manifest list — a single tag pointing to platform-specific images. docker pull automatically selects the right one.
Cross-Compilation (Fastest)
For compiled languages, cross-compile in the build stage instead of emulating. This is significantly faster than QEMU.
Go
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -o /server ./cmd/server
FROM scratch
COPY --from=builder /server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]Rust
FROM --platform=$BUILDPLATFORM rust:1.80-alpine AS builder
ARG TARGETPLATFORM
RUN apk add --no-cache musl-dev
RUN case "$TARGETPLATFORM" in \
"linux/amd64") echo "x86_64-unknown-linux-musl" > /target ;; \
"linux/arm64") echo "aarch64-unknown-linux-musl" > /target ;; \
esac && \
rustup target add $(cat /target)
WORKDIR /app
COPY . .
RUN cargo build --release --target $(cat /target)
FROM scratch
COPY --from=builder /app/target/*/release/myapp /myapp
ENTRYPOINT ["/myapp"]QEMU Emulation (Simpler, Slower)
For interpreted languages (Node.js, Python), QEMU emulates the target architecture. No Dockerfile changes needed.
# Install QEMU user-static binaries (one-time setup)
docker run --privileged --rm tonistiigi/binfmt --install all
# Build with emulation
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myregistry/myapp:latest \
--push .Build Arguments
Docker injects these ARGs automatically in multi-platform builds:
| ARG | Example | Description |
|---|---|---|
BUILDPLATFORM | linux/amd64 | Platform of the build host |
TARGETPLATFORM | linux/arm64 | Target platform being built |
TARGETOS | linux | OS component of target |
TARGETARCH | arm64 | Architecture component of target |
TARGETVARIANT | v7 | ARM variant (v6, v7, v8) |
BUILDOS | linux | OS component of build host |
BUILDARCH | amd64 | Architecture of build host |
Use --platform=$BUILDPLATFORM on the builder stage to run natively:
FROM --platform=$BUILDPLATFORM node:24-alpine AS builder
# Runs natively on host, not emulatedLocal Testing
# Build for a specific platform locally (no push)
docker buildx build \
--platform linux/arm64 \
--load \
-t myapp:arm64-test \
.
# Note: --load only works with a single platform
# For multi-platform, use --push to a registryPlatform-Specific Dependencies
FROM node:24-alpine AS builder
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "arm64" ]; then \
apk add --no-cache python3 make g++; \
fi
COPY package.json package-lock.json ./
RUN npm ciPerformance Comparison
| Method | Speed | Dockerfile Changes | Best For |
|---|---|---|---|
| Cross-compilation | Fast | Yes (ARGs) | Go, Rust, C/C++ |
| QEMU emulation | 3-10x slower | None | Node.js, Python, Ruby |
| Native runners | Fast | None | CI with ARM runners |
CI with Multi-Platform
See CI caching reference for GitHub Actions workflows with multi-platform builds.
CI Caching
GitHub Actions Cache Backend
The fastest CI caching option. Uses GitHub's native cache service.
name: Build and Push
on:
push:
branches: [main]
pull_request:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxCache Modes
| Mode | Behavior | Size |
|---|---|---|
min | Only cache final stage layers | Smaller |
max | Cache all intermediate stages (better hit rate) | Larger |
Use mode=max unless you're hitting the 10 GB GitHub cache limit.
Registry Cache Backend
Store cache in a dedicated registry tag. Works with any CI provider.
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=maxImage Tagging Strategy
Use Docker Metadata Action for automated, semantic tags:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxThis produces tags like:
| Event | Tags Generated |
|---|---|
Push to main | main, sha-abc1234 |
Push tag v1.2.3 | 1.2.3, 1.2, sha-abc1234 |
| Pull request #42 | pr-42 |
Multi-Platform CI Build
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxMulti-Registry Push
Push to both Docker Hub and GHCR in one build:
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
myorg/myapp:latest
ghcr.io/myorg/myapp:latestBuild Matrix for Multiple Services
jobs:
docker:
runs-on: ubuntu-latest
strategy:
matrix:
service: [api, web, worker]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./apps/${{ matrix.service }}
push: true
tags: ghcr.io/myorg/${{ matrix.service }}:${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,scope=${{ matrix.service }},mode=maxUse scope to separate cache entries per service — otherwise they overwrite each other.
Vulnerability Scanning in CI
- name: Build image
uses: docker/build-push-action@v6
with:
load: true
tags: myapp:scan
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:scan
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
- name: Upload scan results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifCache Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Cache never hits | Different builder instance | Ensure setup-buildx-action runs first |
| Cache too large | Using mode=max with many stages | Switch to mode=min or registry backend |
| PR builds have no cache | GHA cache scoped to branch | PRs can read from base branch cache |
| Multi-service cache conflict | Same scope for different Dockerfiles | Use scope=<service-name> per service |
Compose
Service Definitions
# compose.yaml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: production
ports:
- '3000:3000'
environment:
NODE_ENV: production
DATABASE_URL: postgres://user:pass@db:5432/myapp
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U user -d myapp']
interval: 10s
timeout: 3s
retries: 5
ports:
- '5432:5432'
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
command: redis-server --appendonly yes
ports:
- '6379:6379'
volumes:
db-data:
redis-data:Compose Overrides
Use override files for environment-specific config without duplicating the base:
# compose.override.yaml (auto-loaded in dev)
services:
app:
build:
target: builder
volumes:
- .:/app
- /app/node_modules
environment:
NODE_ENV: development
DEBUG: 'app:*'
command: npm run dev# compose.prod.yaml (explicit: docker compose -f compose.yaml -f compose.prod.yaml up)
services:
app:
ports:
- '80:3000'
environment:
NODE_ENV: production
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: '0.5'Networking
Compose creates a default bridge network. Services resolve each other by service name.
services:
app:
networks:
- frontend
- backend
db:
networks:
- backend
nginx:
networks:
- frontend
networks:
frontend:
backend:DNS Resolution
Within the same network, services resolve by name:
// In the app service, connect to db by service name
const pool = new Pool({
host: 'db', // resolved by Docker DNS
port: 5432,
database: 'myapp',
});External Networks
Connect to networks created outside Compose:
networks:
shared:
external: true
name: my-shared-networkVolumes
Named Volumes (Persistent Data)
volumes:
db-data:
driver: local
services:
db:
volumes:
- db-data:/var/lib/postgresql/dataBind Mounts (Development)
services:
app:
volumes:
- .:/app # sync source code
- /app/node_modules # anonymous volume to preserve container's node_modulestmpfs (Ephemeral, In-Memory)
services:
app:
tmpfs:
- /tmp
- /app/.cacheEnvironment Variables
Precedence (Highest to Lowest)
1. docker compose run -e VAR=value 2. environment: in Compose file 3. --env-file flag 4. env_file: in Compose file 5. .env file in project directory 6. Host environment variables
Patterns
services:
app:
# Inline values
environment:
NODE_ENV: production
API_KEY: ${API_KEY} # interpolated from host or .env
# External file
env_file:
- .env
- .env.local# .env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=secret
POSTGRES_DB=myappCompose Watch (Dev Hot Reload)
File watching without bind mounts — syncs files or triggers rebuild:
services:
app:
build:
context: .
develop:
watch:
# Sync source changes (hot reload)
- action: sync
path: ./src
target: /app/src
# Rebuild on dependency changes
- action: rebuild
path: package.json
# Restart on config changes
- action: sync+restart
path: ./config
target: /app/configdocker compose watchdepends_on with Health Checks
Control startup order with health check conditions:
services:
app:
depends_on:
db:
condition: service_healthy # wait for healthy
redis:
condition: service_started # just wait for start
migrations:
condition: service_completed_successfully # wait for exit 0Profiles
Group services for selective startup:
services:
app:
# no profile = always starts
db:
# no profile = always starts
adminer:
image: adminer
profiles:
- debug
ports:
- '8080:8080'
mailhog:
image: mailhog/mailhog
profiles:
- debug
ports:
- '8025:8025'# Start default services only
docker compose up
# Start with debug tools
docker compose --profile debug upResource Limits
services:
app:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'Common Commands
# Start services (detached)
docker compose up -d
# Rebuild and start
docker compose up -d --build
# View logs
docker compose logs -f app
# Execute command in running container
docker compose exec app sh
# Stop and remove containers + networks
docker compose down
# Stop and remove containers + networks + volumes
docker compose down -v
# Pull latest images
docker compose pullDebugging
Container Inspection
Logs
# Follow logs in real-time
docker logs -f <container>
# Show last 100 lines
docker logs --tail 100 <container>
# Logs with timestamps
docker logs -t <container>
# Logs since a specific time
docker logs --since 2024-01-01T00:00:00Z <container>
docker logs --since 30m <container>
# Compose: all services
docker compose logs -f
# Compose: specific service
docker compose logs -f apiExec into Running Container
# Interactive shell
docker exec -it <container> sh
docker exec -it <container> /bin/bash
# Run a specific command
docker exec <container> env
docker exec <container> cat /etc/resolv.conf
# Exec as root (if container runs as non-root)
docker exec -u 0 <container> shInspect Container State
# Full container details (JSON)
docker inspect <container>
# Specific fields
docker inspect --format='{{.State.Status}}' <container>
docker inspect --format='{{.NetworkSettings.IPAddress}}' <container>
docker inspect --format='{{json .Config.Env}}' <container> | jq .
docker inspect --format='{{.State.Health.Status}}' <container>
# View health check logs
docker inspect --format='{{json .State.Health}}' <container> | jq '.Log[-3:]'
# Mounted volumes
docker inspect --format='{{json .Mounts}}' <container> | jq .Resource Usage
# Live resource stats
docker stats
# One-shot stats
docker stats --no-stream
# Specific container
docker stats <container>Image Layer Analysis
docker history
# Show layers and sizes
docker history myapp:latest
# Full commands (not truncated)
docker history --no-trunc myapp:latest
# Human-readable sizes
docker history --format "table {{.Size}}\t{{.CreatedBy}}" myapp:latestdive (Interactive Layer Explorer)
# Install
# brew install dive (macOS)
# apt install dive (Debian)
# Analyze an image
dive myapp:latest
# CI mode (fail if image efficiency below threshold)
dive myapp:latest --ci --lowestEfficiency 0.9dive shows:
- Layer-by-layer file changes (added, modified, removed)
- Image efficiency score
- Wasted space from files added then removed in later layers
- File tree at each layer
Network Troubleshooting
Inspect Networks
# List networks
docker network ls
# Inspect a network (shows connected containers)
docker network inspect bridge
docker network inspect myapp_default
# Find container's IP address
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <container>DNS Resolution
# Test DNS resolution from inside a container
docker exec <container> nslookup db
docker exec <container> getent hosts db
# Check /etc/resolv.conf
docker exec <container> cat /etc/resolv.conf
# Ping another service
docker exec <container> ping -c 3 dbPort and Connectivity
# Check published ports
docker port <container>
# Test TCP connectivity from inside a container
docker exec <container> nc -zv db 5432
docker exec <container> wget -qO- http://api:3000/health
# Check if a port is listening inside the container
docker exec <container> netstat -tlnp
docker exec <container> ss -tlnpCommon Network Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Cannot resolve service name | Containers on different networks | Put both on the same Compose network |
| Connection refused | Service not listening on 0.0.0.0 | Bind to 0.0.0.0, not 127.0.0.1 |
| Port already in use on host | Another process using the port | Change host port mapping or stop conflicting process |
| Container can reach host but not web | DNS not configured | Check --dns flag or /etc/resolv.conf |
| Intermittent timeouts | Network mode or MTU mismatch | Check docker network inspect, try --net=host for debugging |
Build Debugging
Build with Verbose Output
# Show build output (not collapsed)
docker build --progress=plain .
# No cache (force full rebuild)
docker build --no-cache .
# Build up to a specific stage
docker build --target builder .Debug a Failed Build Step
# Build interactively from a specific stage
docker build --target builder -t debug-build .
docker run -it debug-build sh
# Now you can inspect the filesystem at that build stageCompose Debugging
# Validate Compose file
docker compose config
# Show resolved environment variables
docker compose config | grep -A5 environment
# Dry run (show what would happen)
docker compose up --dry-run
# Force recreate containers
docker compose up -d --force-recreate
# Rebuild images
docker compose up -d --buildCleanup
# Remove stopped containers
docker container prune -f
# Remove unused images
docker image prune -f
# Remove unused images (including tagged ones not used by containers)
docker image prune -a -f
# Remove unused volumes (careful: deletes data)
docker volume prune -f
# Nuclear option: remove everything unused
docker system prune -a --volumes -f
# Show disk usage
docker system df
docker system df -vQuick Debugging Workflow
# 1. Check if container is running and healthy
docker ps -a | grep myapp
docker inspect --format='{{.State.Health.Status}}' myapp
# 2. Check recent logs
docker logs --tail 50 myapp
# 3. Check environment
docker exec myapp env
# 4. Check network connectivity
docker exec myapp nslookup db
docker exec myapp nc -zv db 5432
# 5. Check filesystem
docker exec myapp ls -la /app
docker exec myapp df -h
# 6. Interactive debugging
docker exec -it myapp shDockerfile Patterns
Multi-Stage Builds
Separate build-time dependencies from the runtime image. Only copy artifacts needed for production.
Node.js Application
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
ENV NODE_ENV=production
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "dist/server.js"]Static Site with Nginx
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine AS production
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
USER nginx
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
CMD ["nginx", "-g", "daemon off;"]Python Application
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user --no-cache-dir -r requirements.txt
COPY . .
FROM python:3.12-slim AS production
WORKDIR /app
RUN useradd --create-home --shell /bin/bash appuser
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
COPY --from=builder --chown=appuser:appuser /app .
ENV PATH="/home/appuser/.local/bin:$PATH"
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]Layer Caching Strategy
Docker caches each layer. When a layer changes, all subsequent layers rebuild. Order instructions from least to most frequently changed.
# 1. Base image (rarely changes)
FROM node:24-alpine
WORKDIR /app
# 2. Dependencies (changes when lockfile changes)
COPY package.json package-lock.json ./
RUN npm ci
# 3. Source code (changes frequently)
COPY . .
RUN npm run buildBuild Cache Mounts
Persist package manager caches across builds without baking them into layers:
# npm
RUN --mount=type=cache,target=/root/.npm npm ci
# pnpm
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go modules
RUN --mount=type=cache,target=/go/pkg/mod \
go build -o /app ./cmd/serverBase Image Selection
| Base Image | Size | Use Case |
|---|---|---|
alpine | ~5 MB | Minimal containers, CLI tools |
node:24-alpine | ~130 MB | Node.js apps, smallest Node base |
node:24-slim | ~200 MB | Node.js when alpine has musl issues |
python:3.12-slim | ~150 MB | Python apps, smaller than full image |
golang:1.23 | ~800 MB | Go builds (use scratch for runtime) |
scratch | 0 MB | Static binaries (Go, Rust) |
distroless | ~20 MB | Minimal runtime, no shell |
nginx-unprivileged | ~40 MB | Static file serving, non-root default |
Pin Base Image Versions
# Pin to major.minor for stability
FROM node:24-alpine
# Pin to digest for maximum reproducibility
FROM node:24-alpine@sha256:abc123...
# Use ARG for flexible version control
ARG NODE_VERSION=20-alpine
FROM node:${NODE_VERSION}.dockerignore
Exclude files from the build context to speed up builds and avoid leaking secrets:
node_modules
.git
.gitignore
.env
.env.*
*.md
dist
coverage
.nyc_output
.cache
.DS_Store
Dockerfile
docker-compose*.yml
.dockerignoreHealth Checks
# HTTP endpoint check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Node.js without curl dependency
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# TCP port check
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD nc -z localhost 5432 || exit 1| Parameter | Default | Guidance |
|---|---|---|
--interval | 30s | Time between checks |
--timeout | 30s | Max time for a single check (use 3-5s) |
--start-period | 0s | Grace period for container startup (use 5-30s) |
--retries | 3 | Consecutive failures before unhealthy |
Init Process
Containers need an init process for proper signal handling and zombie process reaping:
# Option 1: Docker --init flag (adds tini automatically)
# docker run --init myimage
# Option 2: Install tini explicitly
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]ENTRYPOINT vs CMD
# ENTRYPOINT: the executable (hard to override)
# CMD: default arguments (easy to override)
# Fixed executable, overridable args
ENTRYPOINT ["node"]
CMD ["dist/server.js"]
# docker run myimage dist/worker.js -> node dist/worker.js
# Most common: CMD only (easy to override everything)
CMD ["node", "dist/server.js"]
# docker run myimage sh -> sh
# Always use exec form (JSON array), not shell form
CMD ["node", "server.js"] # exec form (PID 1, receives signals)
# CMD node server.js # shell form (wrapped in /bin/sh, misses signals)Go: Scratch Runtime
Go produces static binaries that need no OS:
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
FROM scratch
COPY --from=builder /server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]Monorepo Builds
The Problem
In a monorepo, the lockfile is shared across all workspaces. Any dependency change — even in an unrelated package — invalidates Docker's layer cache for every service, causing full rebuilds.
turbo prune
Turborepo's prune command extracts a minimal subset of the monorepo containing only the target workspace and its dependencies.
# Generate pruned output for the "api" workspace
turbo prune api --dockerOutput Structure
out/
├── json/ # package.json files only (for dependency install)
│ ├── package.json
│ └── packages/
│ └── shared/
│ └── package.json
├── full/ # Full source code
│ ├── apps/
│ │ └── api/
│ └── packages/
│ └── shared/
└── pnpm-lock.yaml # Pruned lockfile (only relevant dependencies)The --docker flag splits output into json/ and full/ directories, enabling Docker to cache dependency installation separately from source code changes.
Turborepo + pnpm Dockerfile
Three-stage build: prune, install + build, runtime.
FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Stage 1: Prune the monorepo
FROM base AS pruner
RUN pnpm add -g turbo
COPY . .
RUN turbo prune api --docker
# Stage 2: Install dependencies and build
FROM base AS builder
# Copy pruned package.json files and lockfile (cached layer)
COPY --from=pruner /app/out/json/ .
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Copy full source and build
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=api
# Stage 3: Production runtime
FROM node:24-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/apps/api/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/apps/api/package.json ./
ENV NODE_ENV=production
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]Why Three Stages
1. Pruner — Runs turbo prune on the full repo, outputs minimal subset 2. Builder — Installs only pruned dependencies (cached when lockfile unchanged), then builds 3. Runner — Copies only production artifacts, no build tools
Turborepo + npm Dockerfile
FROM node:24-alpine AS base
WORKDIR /app
FROM base AS pruner
RUN npm install -g turbo
COPY . .
RUN turbo prune web --docker
FROM base AS builder
COPY --from=pruner /app/out/json/ .
RUN --mount=type=cache,target=/root/.npm npm ci
COPY --from=pruner /app/out/full/ .
RUN npx turbo build --filter=web
FROM base AS runner
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/apps/web/.next/standalone ./
COPY --from=builder --chown=appuser:appgroup /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=appuser:appgroup /app/apps/web/public ./apps/web/public
USER appuser
EXPOSE 3000
CMD ["node", "apps/web/server.js"]Remote Caching in Docker
Pass Turborepo remote cache credentials as build arguments:
FROM base AS builder
ARG TURBO_TOKEN
ARG TURBO_TEAM
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN TURBO_TOKEN=$TURBO_TOKEN TURBO_TEAM=$TURBO_TEAM \
pnpm turbo build --filter=apidocker build \
--build-arg TURBO_TOKEN="$TURBO_TOKEN" \
--build-arg TURBO_TEAM="$TURBO_TEAM" \
-f apps/api/Dockerfile \
.Pass credentials as ARG (not ENV) so they don't persist in the final image.
pnpm Workspaces Without Turbo
For pnpm workspaces without Turborepo, use pnpm deploy to extract a single workspace:
FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
FROM base AS builder
COPY . .
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm --filter api build
RUN pnpm deploy --filter api --prod /app/deployed
FROM node:24-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/deployed ./
ENV NODE_ENV=production
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]pnpm deploy copies the workspace with only production dependencies, flattened into a standalone directory.
.dockerignore for Monorepos
**/node_modules
**/.turbo
**/.next
**/dist
**/coverage
.git
.env
.env.*
*.mdCI: Build Matrix for Monorepo Services
jobs:
docker:
runs-on: ubuntu-latest
strategy:
matrix:
service: [api, web, worker]
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
file: apps/${{ matrix.service }}/Dockerfile
context: .
push: true
tags: ghcr.io/myorg/${{ matrix.service }}:${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,scope=${{ matrix.service }},mode=maxContext is the monorepo root (.) so turbo prune has access to all workspaces.
Common Monorepo Mistakes
| Mistake | Correct Pattern |
|---|---|
| Using workspace root as Docker context | Use turbo prune --docker for minimal context |
| Copying full repo into every service | Prune to only the target workspace and its dependencies |
ENV TURBO_TOKEN in Dockerfile | Use ARG so credentials don't persist in image layers |
| Separate lockfiles per workspace | Use the pruned monorepo lockfile from turbo prune |
Not using --frozen-lockfile | Always use it for deterministic CI builds |
| Cache scope collision in CI matrix | Use scope=${{ matrix.service }} per service |
Security
Non-Root Users
Never run production containers as root. Create a dedicated user in the final stage.
Alpine (Node.js)
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
USER appuserDebian/Ubuntu (Python)
RUN useradd --create-home --shell /bin/bash --uid 1001 appuser
COPY --from=builder --chown=appuser:appuser /app .
USER appuserPre-Built Non-Root Images
Some images come with non-root users:
# Nginx unprivileged (runs as nginx user on port 8080)
FROM nginxinc/nginx-unprivileged:alpine
USER nginx
# Distroless (runs as nonroot by default)
FROM gcr.io/distroless/nodejs24-debian12
USER nonrootBuild Secrets
Never bake secrets into image layers. Use build-time secret mounts.
# Dockerfile
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --registry=https://npm.pkg.github.com
# Build command
# docker build --secret id=npm_token,src=.npm_token .Compose Secrets
services:
app:
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
environment: API_KEYAccess secrets in the container at /run/secrets/<name>:
import { readFileSync } from 'node:fs';
const dbPassword = readFileSync('/run/secrets/db_password', 'utf8').trim();Image Scanning
Trivy
# Scan an image for vulnerabilities
trivy image myapp:latest
# Fail on HIGH or CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
# Scan Dockerfile for misconfigurations
trivy config Dockerfile
# JSON output for CI
trivy image --format json --output results.json myapp:latestDocker Scout
# Quick vulnerability overview
docker scout quickview myapp:latest
# Detailed CVE list
docker scout cves myapp:latest
# Compare two images
docker scout compare myapp:latest myapp:previousCI Integration
# GitHub Actions
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifRead-Only Root Filesystem
Prevent runtime file modification:
# compose.yaml
services:
app:
read_only: true
tmpfs:
- /tmp
- /app/.cache# docker run
docker run --read-only --tmpfs /tmp myapp:latestSecurity Headers and Network Hardening
Drop Capabilities
services:
app:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # only if binding to ports < 1024
security_opt:
- no-new-privileges:trueDocker Run Equivalent
docker run \
--cap-drop ALL \
--security-opt no-new-privileges \
--read-only \
--tmpfs /tmp \
--user 1001:1001 \
myapp:latestDistroless Images
No shell, no package manager, minimal attack surface:
FROM golang:1.23 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs24-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER nonroot
CMD ["dist/server.js"]Production Hardening Checklist
| Area | Check |
|---|---|
| Base image | Pinned to specific version, not latest |
| Non-root | USER directive in final stage |
| Secrets | No secrets in ENV, layers, or build args |
| Read-only | --read-only with tmpfs for writable dirs |
| Capabilities | cap_drop: ALL, only add what's needed |
| Privileges | no-new-privileges: true |
| Health check | HEALTHCHECK in Dockerfile or orchestrator config |
| Scanning | Trivy or Scout in CI, fail on HIGH/CRITICAL |
| .dockerignore | Excludes .env, .git, node_modules |
| Image size | Multi-stage build, alpine or distroless base |
| Init process | --init or tini for signal handling |
| Logging | Log to stdout/stderr, collect with orchestrator |
| Resource limits | Memory and CPU limits set in deployment |
Common Docker Security Mistakes
| Mistake | Fix |
|---|---|
ENV API_KEY=secret in Dockerfile | Use runtime secrets or --mount=type=secret |
| Running as root | Add USER directive with non-root user |
Using latest tag | Pin base image version |
| No vulnerability scanning | Add Trivy/Scout to CI pipeline |
| Exposing Docker socket to containers | Use Docker-in-Docker or rootless Docker if needed |
--privileged flag | Use specific --cap-add instead |
| Storing secrets in environment variables | Use Docker secrets (/run/secrets/) or vault |
| No resource limits | Set memory and CPU limits in orchestrator |