Dockerfile
Language Overview¶
Dockerfile is a text file containing instructions to build Docker container images. This guide covers Dockerfile best practices for creating secure, efficient, and maintainable container images.
Key Characteristics¶
- File Name:
Dockerfile(no extension) - Primary Use: Building Docker container images
- Key Principles: Multi-stage builds, layer caching, security, minimal image size
Quick Reference¶
| Category | Convention | Example | Notes |
|---|---|---|---|
| Instructions | |||
| Base Image | FROM |
FROM node:20-alpine |
Use specific tags, prefer slim/alpine |
| Working Dir | WORKDIR |
WORKDIR /app |
Sets working directory |
| Copy Files | COPY |
COPY package.json ./ |
Copy from build context |
| Run Command | RUN |
RUN npm install |
Execute at build time |
| Environment | ENV |
ENV NODE_ENV=production |
Set environment variables |
| Expose Port | EXPOSE |
EXPOSE 3000 |
Document exposed ports |
| User | USER |
USER node |
Run as non-root user |
| Entrypoint | ENTRYPOINT |
ENTRYPOINT ["node", "app.js"] |
Main executable |
| Command | CMD |
CMD ["serve"] |
Default arguments |
| Best Practices | |||
| Multi-stage | Use stages | FROM ... AS builder |
Separate build/runtime |
| Layer Order | Least to most changing | Dependencies before source | Optimize caching |
.dockerignore |
Always use | .git, node_modules |
Exclude unnecessary files |
| Combine RUN | Chain commands | RUN apt-get update && \ |
Reduce layers |
| Security | Non-root user | USER node |
Never run as root |
| File Naming | |||
| Standard | Dockerfile |
Dockerfile |
No extension |
| Multi-stage | Dockerfile.{env} |
Dockerfile.prod |
Environment-specific |
| Common Patterns | |||
| Node.js | Copy package.json first | COPY package*.json ./ |
Cache dependencies |
| Python | Copy requirements first | COPY requirements.txt ./ |
Cache dependencies |
| Go | Multi-stage build | FROM golang AS builder |
Small final image |
Basic Structure¶
Simple Dockerfile¶
## Syntax version (optional but recommended)
## syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
FROM Instruction¶
Always Pin Base Image Versions¶
## Good - Pinned to specific version
FROM node:22-alpine
## Avoid - Using latest or unpinned versions
FROM node:latest
FROM node:22-alpine
Multi-Stage Builds¶
## Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
## Production stage
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
WORKDIR Instruction¶
Always use WORKDIR instead of RUN cd:
## Good - Using WORKDIR
WORKDIR /app
COPY . .
## Bad - Using cd
RUN cd /app
COPY . .
COPY vs ADD¶
Use COPY for Local Files¶
## Good - COPY for local files
COPY package.json ./
COPY src/ ./src/
## Avoid - ADD has implicit extraction behavior
ADD package.json ./
Use ADD Only for URLs or Tar Extraction¶
## ADD for remote URLs (but prefer RUN wget/curl for better control)
ADD https://example.com/file.tar.gz /tmp/
## ADD for automatic tar extraction
ADD archive.tar.gz /opt/
RUN Instruction¶
Combine Commands to Reduce Layers¶
## Good - Single layer
RUN apt-get update && \
apt-get install -y \
curl \
git \
vim && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
## Avoid - Multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y vim
Clean Up in Same Layer¶
## Good - Clean up in same RUN instruction
RUN apt-get update && \
apt-get install -y build-essential && \
make && \
apt-get remove -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
## Bad - Clean up in separate layer (doesn't reduce image size)
RUN apt-get update
RUN apt-get install -y build-essential
RUN make
RUN apt-get remove -y build-essential
ENV Instruction¶
## Environment variables
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info
## Path variables
ENV PATH="/app/node_modules/.bin:${PATH}"
EXPOSE Instruction¶
## Document exposed ports
EXPOSE 3000
EXPOSE 8080/tcp
EXPOSE 8081/udp
USER Instruction¶
Always Run as Non-Root User¶
## Good - Create and use non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
## Bad - Running as root (default)
## (no USER instruction)
Multi-Stage Build Examples¶
Node.js Application¶
## syntax=docker/dockerfile:1
## Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && \
npm prune --production
## Production stage
FROM node:22-alpine AS production
WORKDIR /app
## Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
## Copy files with correct ownership
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs package.json ./
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
CMD ["node", "dist/index.js"]
Python Application¶
## syntax=docker/dockerfile:1
## Build stage
FROM python:3.11-slim AS builder
WORKDIR /app
## Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev && \
rm -rf /var/lib/apt/lists/*
## Install Python dependencies
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
## Production stage
FROM python:3.11-slim AS production
WORKDIR /app
## Install runtime dependencies
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 appuser
## Copy Python packages from builder
COPY --from=builder /root/.local /home/appuser/.local
## Copy application code
COPY --chown=appuser:appuser . .
USER appuser
ENV PATH="/home/appuser/.local/bin:${PATH}" \
PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python healthcheck.py
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Go Application¶
## syntax=docker/dockerfile:1
## Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
## Install build dependencies
RUN apk add --no-cache git
## Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
## Copy source code
COPY . .
## Build binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
## Production stage
FROM alpine:3.19 AS production
WORKDIR /app
## Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates
## Create non-root user
RUN addgroup -g 1001 -S appuser && \
adduser -S appuser -u 1001 -G appuser
## Copy binary from builder
COPY --from=builder --chown=appuser:appuser /app/main .
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./main"]
.dockerignore¶
Always create a .dockerignore file:
## Git
.git
.gitignore
## Node.js
node_modules
npm-debug.log
## Python
__pycache__
*.py[cod]
*$py.class
.Python
venv/
.env
## Build artifacts
dist
build
*.o
*.so
## IDE
.vscode
.idea
*.swp
*.swo
## Documentation
*.md
docs/
## Tests
tests/
*.test.js
## CI/CD
.github
.gitlab-ci.yml
Jenkinsfile
## Docker
Dockerfile*
docker-compose*.yml
Testing¶
Testing with Container Structure Test¶
Use Container Structure Test to validate Docker images:
## Install Container Structure Test
curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64
chmod +x container-structure-test-linux-amd64
sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test
Test Configuration¶
Create container-structure-test.yaml:
schemaVersion: 2.0.0
## Command tests - verify installed packages
commandTests:
- name: "node version"
command: "node"
args: ["--version"]
expectedOutput: ["v18.*"]
- name: "npm is installed"
command: "which"
args: ["npm"]
exitCode: 0
- name: "application exists"
command: "test"
args: ["-f", "/app/dist/index.js"]
exitCode: 0
## File existence tests
fileExistenceTests:
- name: "application directory"
path: "/app"
shouldExist: true
permissions: "drwxr-xr-x"
- name: "package.json exists"
path: "/app/package.json"
shouldExist: true
- name: "no secrets in image"
path: "/app/.env"
shouldExist: false
## File content tests
fileContentTests:
- name: "package.json has correct version"
path: "/app/package.json"
expectedContents: ['"version": "1.0.0"']
## Metadata tests
metadataTest:
env:
- key: "NODE_ENV"
value: "production"
- key: "PORT"
value: "3000"
exposedPorts: ["3000"]
workdir: "/app"
## Verify non-root user
user: "nodejs"
labels:
- key: "org.opencontainers.image.title"
value: "My Application"
Running Structure Tests¶
## Test a locally built image
container-structure-test test \
--image myapp:latest \
--config container-structure-test.yaml
## Test with verbose output
container-structure-test test \
--image myapp:latest \
--config container-structure-test.yaml \
--verbosity debug
## Test multiple config files
container-structure-test test \
--image myapp:latest \
--config test-base.yaml \
--config test-security.yaml
Testing with Trivy¶
Test for vulnerabilities and misconfigurations:
## Scan for vulnerabilities
trivy image myapp:latest
## Scan with specific severity
trivy image --severity HIGH,CRITICAL myapp:latest
## Scan Dockerfile for misconfigurations
trivy config Dockerfile
## Generate JSON report
trivy image --format json --output results.json myapp:latest
## Fail build on high/critical vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
Testing with hadolint¶
Lint Dockerfiles for best practices:
## Basic linting
hadolint Dockerfile
## Lint with specific format
hadolint --format json Dockerfile
## Lint in CI/CD
hadolint Dockerfile || exit 1
Integration Testing with Docker Compose¶
Test multi-container applications:
## docker-compose.test.yml
version: '3.8'
services:
app:
build:
context: .
target: production
environment:
- NODE_ENV=test
- DATABASE_URL=postgresql://test:test@db:5432/test
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 3s
retries: 5
test:
build:
context: .
target: builder
command: npm test
environment:
- DATABASE_URL=postgresql://test:test@db:5432/test
depends_on:
db:
condition: service_healthy
Run integration tests:
## Run tests with docker-compose
docker-compose -f docker-compose.test.yml up --abort-on-container-exit
## Clean up after tests
docker-compose -f docker-compose.test.yml down -v
Runtime Testing with BATS¶
Test container behavior at runtime:
## tests/docker-runtime.bats
#!/usr/bin/env bats
setup() {
# Start container for testing
docker run -d --name test-app -p 3000:3000 myapp:latest
sleep 5 # Wait for startup
}
teardown() {
# Clean up
docker stop test-app
docker rm test-app
}
@test "container starts successfully" {
run docker ps --filter "name=test-app" --format "{{.Status}}"
[[ "$output" =~ "Up" ]]
}
@test "application responds to HTTP requests" {
run curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health
[ "$output" = "200" ]
}
@test "container runs as non-root user" {
run docker exec test-app whoami
[ "$output" = "nodejs" ]
}
@test "container has minimal attack surface" {
# Verify no shell in distroless images
run docker exec test-app sh -c "exit 0"
[ "$status" -ne 0 ]
}
@test "application logs are accessible" {
run docker logs test-app
[[ "$output" =~ "Server started on port 3000" ]]
}
CI/CD Integration¶
## .github/workflows/docker-test.yml
name: Docker Build and Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
- name: Build image
run: docker build -t myapp:test .
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@v0.34.1
with:
image-ref: myapp:test
format: sarif
output: trivy-results.sarif
- name: Install Container Structure Test
run: |
curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64
chmod +x container-structure-test-linux-amd64
sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test
- name: Run structure tests
run: |
container-structure-test test \
--image myapp:test \
--config container-structure-test.yaml
- name: Test image size
run: |
size=$(docker image inspect myapp:test --format='{{.Size}}')
max_size=$((500 * 1024 * 1024)) # 500MB
if [ "$size" -gt "$max_size" ]; then
echo "Image too large: $(($size / 1024 / 1024))MB"
exit 1
fi
- name: Test container startup
run: |
docker run -d --name test-container -p 3000:3000 myapp:test
sleep 5
curl -f http://localhost:3000/health || exit 1
docker stop test-container
Security Testing¶
Test for security best practices:
## tests/security-tests.yaml
schemaVersion: 2.0.0
commandTests:
- name: "runs as non-root"
command: "whoami"
expectedOutput: ["nodejs|appuser|node"]
excludedOutput: ["root"]
- name: "no write permissions on system directories"
command: "test"
args: ["-w", "/usr"]
exitCode: 1
- name: "no unnecessary tools installed"
command: "which"
args: ["wget"]
exitCode: 1
fileExistenceTests:
- name: "no .git directory"
path: "/app/.git"
shouldExist: false
- name: "no environment files"
path: "/app/.env"
shouldExist: false
- name: "no node_modules in final image"
path: "/app/node_modules"
shouldExist: false # For compiled apps
metadataTest:
## Ensure running as non-root
user: "nodejs"
## No hardcoded secrets in env
envVars:
- key: "API_KEY"
isSet: false
- key: "DB_PASSWORD"
isSet: false
Image Layer Analysis¶
Use Dive to analyze image layers:
## Install dive
wget https://github.com/wagoodman/dive/releases/download/v0.11.0/dive_0.11.0_linux_amd64.deb
sudo apt install ./dive_0.11.0_linux_amd64.deb
## Analyze image layers
dive myapp:latest
## CI mode with efficiency threshold
dive myapp:latest --ci --lowestEfficiency=0.95
Performance Testing¶
Test build and runtime performance:
## tests/performance.sh
#!/bin/bash
## Build time test
start_time=$(date +%s)
docker build -t myapp:test .
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "Build time: ${build_time}s"
if [ "$build_time" -gt 300 ]; then
echo "Build taking too long (>5 minutes)"
exit 1
fi
## Image size test
size=$(docker image inspect myapp:test --format='{{.Size}}' | numfmt --to=iec)
echo "Image size: $size"
## Startup time test
start_time=$(date +%s)
docker run -d --name perf-test myapp:test
while ! docker exec perf-test curl -s http://localhost:3000/health > /dev/null 2>&1; do
sleep 1
done
end_time=$(date +%s)
startup_time=$((end_time - start_time))
echo "Startup time: ${startup_time}s"
docker stop perf-test
docker rm perf-test
if [ "$startup_time" -gt 30 ]; then
echo "Startup too slow (>30 seconds)"
exit 1
fi
Security Best Practices¶
Scan for Vulnerabilities¶
## Scan image with Trivy
trivy image myapp:latest
## Scan image with Grype
grype myapp:latest
## Scan with Docker Scout
docker scout cves myapp:latest
Use Minimal Base Images¶
## Good - Minimal alpine image
FROM node:22-alpine
## Good - Distroless image (even smaller, no shell)
FROM gcr.io/distroless/nodejs20-debian12
## Avoid - Full Debian/Ubuntu images
FROM node:22-alpine
FROM ubuntu:22.04
Don't Store Secrets in Images¶
## Bad - Secret in ENV
ENV DB_PASSWORD=supersecret
## Bad - Secret in file
COPY .env .
## Good - Use runtime secrets
## Pass via environment variables at runtime
## docker run -e DB_PASSWORD=$DB_PASSWORD myapp
HEALTHCHECK¶
## HTTP health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
## TCP health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD nc -z localhost 3000 || exit 1
## Custom script
HEALTHCHECK --interval=30s --timeout=3s \
CMD node healthcheck.js
Labels¶
LABEL org.opencontainers.image.title="My Application" \
org.opencontainers.image.description="A sample application" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.authors="Tyler Dukes <tyler@example.com>" \
org.opencontainers.image.url="https://example.com" \
org.opencontainers.image.source="https://github.com/myorg/myapp" \
org.opencontainers.image.licenses="MIT"
Common Pitfalls¶
Layer Caching Invalidation¶
Issue: Placing frequently changing files (source code) before rarely changing files (dependencies) invalidates cache on every build, dramatically increasing build times.
Example:
## Bad - Source code copied before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . . # Invalidates cache every time code changes!
RUN npm install # Re-downloads all dependencies every build
Solution: Copy dependency files first, install, then copy source code.
## Good - Dependencies cached separately from source
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # Copy dependency manifest first
RUN npm ci --only=production # Install dependencies (cached)
COPY . . # Copy source code last
Key Points:
- Order instructions from least to most frequently changing
- Dependencies (package.json) change less than source code
- Each changed layer invalidates all subsequent layers
- Use
.dockerignoreto exclude unnecessary files
Multi-Stage Build ARG Scope¶
Issue: ARG variables don't persist across build stages unless redeclared, causing build failures.
Example:
## Bad - ARG not available in second stage
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
RUN node --version
FROM node:${NODE_VERSION}-alpine # Error: NODE_VERSION undefined!
Solution: Redeclare ARG in each stage that needs it.
## Good - ARG redeclared per stage
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
RUN node --version
FROM node:${NODE_VERSION}-alpine # Works! ARG redeclared above
ARG NODE_VERSION # Can reuse without value (uses global)
RUN node --version
Key Points:
- ARG scope is per-stage in multi-stage builds
- Redeclare ARG in each stage that needs it
- Global ARGs (before first FROM) can be referenced without value
- ENV persists across stages, ARG does not
COPY vs ADD Confusion¶
Issue: Using ADD when COPY is sufficient adds unnecessary magic behavior and security risks.
Example:
## Bad - ADD auto-extracts, can fetch URLs
ADD https://example.com/file.tar.gz /app/ # Security risk: executes remote code!
ADD local-archive.tar.gz /app/ # Auto-extracts (implicit behavior)
Solution: Use COPY for local files, explicit RUN for extraction/downloads.
## Good - Explicit and secure
COPY local-archive.tar.gz /tmp/
RUN tar -xzf /tmp/local-archive.tar.gz -C /app/ && rm /tmp/local-archive.tar.gz
## Good - Explicit download with verification
RUN curl -fsSL https://example.com/file.tar.gz -o /tmp/file.tar.gz \
&& echo "expected-sha256 /tmp/file.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/file.tar.gz -C /app/ \
&& rm /tmp/file.tar.gz
Key Points:
- Use COPY for all local files
- ADD auto-extracts tar/gz files (implicit behavior)
- ADD can fetch remote URLs (security risk)
- Only use ADD when auto-extraction is explicitly desired
Health Check Missing¶
Issue: Containers report as "running" even when application is crashed or hung, causing failed requests.
Example:
## Bad - No health check
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]
# Container shows "Up" even if server crashes!
Solution: Add HEALTHCHECK to verify application is responding.
## Good - Health check validates application
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node healthcheck.js
CMD ["node", "server.js"]
// healthcheck.js
const http = require('http');
const options = { host: 'localhost', port: 3000, path: '/health', timeout: 2000 };
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on('error', () => process.exit(1));
req.end();
Key Points:
- HEALTHCHECK verifies application is responding
- Container status reflects health, not just process existence
- Configure appropriate interval, timeout, retries
- Kubernetes uses liveness/readiness probes instead
- Health endpoint should check dependencies (database, cache)
Build-Time Secrets Leakage¶
Issue: ARG and ENV values are baked into image layers, exposing secrets in image history.
Example:
## Bad - Secret stored in image layer!
FROM node:20-alpine
ARG NPM_TOKEN # Secret visible in docker history!
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm install \
&& rm .npmrc # Too late! Token already in image layer
Solution: Use build secrets with BuildKit (--secret flag).
## Good - Secret not stored in image
## syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install --only=production
# Secret mounted at build time, not stored in layer
## Build with secret
docker buildx build --secret id=npmrc,src=.npmrc -t myapp .
Key Points:
- ARG and ENV values are stored in image layers
- Removing secrets in same/later RUN doesn't remove from history
- Use BuildKit
--mount=type=secretfor build-time secrets - Multi-stage builds: copy artifacts, not secrets
- Never commit secrets to Dockerfile or repository
Anti-Patterns¶
❌ Avoid: Running as Root¶
## Bad - Running as root
FROM node:22-alpine
COPY . /app
CMD ["node", "index.js"]
## Good - Non-root user
FROM node:22-alpine
RUN adduser -D appuser
USER appuser
COPY . /app
CMD ["node", "index.js"]
❌ Avoid: Using latest Tag¶
## Bad - Unpredictable builds
FROM node:latest
## Good - Pinned version
FROM node:22-alpine
❌ Avoid: Installing Unnecessary Packages¶
## Bad - Installing unnecessary packages
RUN apt-get update && apt-get install -y \
build-essential \
python3 \
curl \
vim \
nano
## Good - Only install what's needed
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential && \
rm -rf /var/lib/apt/lists/*
❌ Avoid: Multiple RUN Commands for Package Install¶
## Bad - Creates multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get clean
## Good - Single layer with cleanup
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
❌ Avoid: Copying Entire Context¶
## Bad - Copies everything including .git, node_modules, etc.
FROM node:22-alpine
COPY . /app
## Good - Use .dockerignore and copy selectively
## .dockerignore:
## node_modules
## .git
## .env
## *.md
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
COPY public/ ./public/
❌ Avoid: Not Using Multi-Stage Builds¶
## Bad - Build tools remain in final image
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install # Includes dev dependencies
COPY . .
RUN npm run build
CMD ["npm", "start"]
## Good - Multi-stage build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
USER node
CMD ["node", "dist/index.js"]
❌ Avoid: Exposing Secrets in Build¶
## Bad - Secrets in image layers
FROM node:22-alpine
WORKDIR /app
COPY .env .env # ❌ Secret file in image!
RUN echo "API_KEY=secret123" > config.txt # ❌ In layer history!
## Good - Use build secrets (Docker BuildKit)
## syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install private-package
## Or use build args (for non-sensitive config)
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
Building and Tagging¶
## Build with tag
docker build -t myapp:1.0.0 .
## Build with multiple tags
docker build -t myapp:1.0.0 -t myapp:latest .
## Build with build args
docker build --build-arg NODE_ENV=production -t myapp:1.0.0 .
## Build with target stage
docker build --target production -t myapp:1.0.0 .
## Build with platform
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0.0 .
Tool Configurations¶
hadolint Configuration¶
.hadolint.yaml:
ignored:
- DL3008 # Pin versions in apt-get install
- DL3013 # Pin versions in pip
trustedRegistries:
- docker.io
- gcr.io
Running hadolint¶
## Lint Dockerfile
hadolint Dockerfile
## Lint with custom config
hadolint --config .hadolint.yaml Dockerfile
## Output as JSON
hadolint --format json Dockerfile
Best Practices¶
Use Multi-Stage Builds¶
Separate build and runtime dependencies to minimize final image size:
# Build stage with all development tools
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm run test
# Production stage with only runtime dependencies
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
RUN npm ci --only=production
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]
Optimize Layer Caching¶
Order instructions from least to most frequently changing:
# Good - Dependencies cached separately
FROM python:3.11-slim
WORKDIR /app
# 1. Copy dependency files first (change infrequently)
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 2. Copy source code last (changes frequently)
COPY . .
# Bad - Invalidates cache on every code change
# COPY . .
# RUN pip install -r requirements.txt
Always Use .dockerignore¶
Exclude unnecessary files from build context:
# Version control
.git
.gitignore
.gitattributes
# Dependencies
node_modules
__pycache__
*.pyc
venv/
# Build artifacts
dist
build
*.o
*.so
# IDE and editor files
.vscode
.idea
*.swp
*.swo
.DS_Store
# Documentation
*.md
docs/
LICENSE
# Tests
tests/
*.test.js
coverage/
# CI/CD
.github
.gitlab-ci.yml
Jenkinsfile
# Environment files
.env
.env.*
*.local
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
Choose Minimal Base Images¶
Use slim or distroless images to reduce attack surface:
# Good - Alpine (minimal)
FROM node:20-alpine
# Good - Distroless (no shell, smallest attack surface)
FROM gcr.io/distroless/nodejs20-debian12
# Good - Slim (smaller than default)
FROM python:3.11-slim
# Avoid - Full images (large, unnecessary packages)
FROM node:20
FROM python:3.11
FROM ubuntu:22.04
Pin Specific Versions¶
Always pin base image versions for reproducible builds:
# Good - Fully pinned
FROM node:20.11.1-alpine3.19
FROM python:3.11.8-slim-bookworm
# Acceptable - Major.minor pinned
FROM node:20-alpine
FROM python:3.11-slim
# Bad - Unpredictable
FROM node:latest
FROM python:3
Combine RUN Commands¶
Reduce layers and image size by chaining commands:
# Good - Single layer with cleanup
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev && \
pip install --no-cache-dir -r requirements.txt && \
apt-get purge -y --auto-remove build-essential && \
rm -rf /var/lib/apt/lists/*
# Bad - Multiple layers, no cleanup
RUN apt-get update
RUN apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
RUN apt-get remove -y build-essential
Run Containers as Non-Root User¶
Create and use a non-root user for security:
# Node.js - use built-in node user
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "index.js"]
# Python - create custom user
FROM python:3.11-slim
WORKDIR /app
RUN useradd -m -u 1001 appuser && \
chown -R appuser:appuser /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
# Alpine - create user with adduser
FROM alpine:3.19
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
USER appuser
Use BuildKit Secrets¶
Never store secrets in image layers:
# syntax=docker/dockerfile:1
# Good - Secrets mounted at build time, not stored
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --only=production
# Build with: docker buildx build --secret id=npmrc,src=.npmrc -t myapp .
# Bad - Secret stored in image layer
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm install && \
rm .npmrc # Too late! Token already in layer
Add Health Checks¶
Include health checks to monitor container status:
# HTTP health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# TCP health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD nc -z localhost 3000 || exit 1
# Custom health check script
COPY healthcheck.sh /usr/local/bin/
HEALTHCHECK --interval=30s --timeout=3s \
CMD /usr/local/bin/healthcheck.sh
Use Metadata Labels¶
Add OCI-compliant labels for documentation:
LABEL org.opencontainers.image.title="My Application" \
org.opencontainers.image.description="Production-ready web service" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.authors="team@example.com" \
org.opencontainers.image.url="https://example.com" \
org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${GIT_COMMIT}"
Leverage Build Cache Mounts¶
Use cache mounts for package managers:
# syntax=docker/dockerfile:1
# Python - cache pip packages
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Node.js - cache npm packages
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# Go - cache modules
FROM golang:1.24-alpine
WORKDIR /app
COPY go.* ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
Minimize Image Size¶
Use techniques to keep images small:
# 1. Use minimal base images
FROM python:3.11-slim # Not python:3.11
# 2. Clean up package manager cache
RUN apt-get update && \
apt-get install -y --no-install-recommends pkg && \
rm -rf /var/lib/apt/lists/*
# 3. Remove build dependencies after use
RUN apk add --no-cache --virtual .build-deps gcc musl-dev && \
pip install package && \
apk del .build-deps
# 4. Use --no-cache for package installations
RUN pip install --no-cache-dir package
RUN npm ci --only=production
# 5. Copy only necessary files
COPY --from=builder /app/dist ./dist # Not COPY --from=builder /app ./
Use COPY Instead of ADD¶
Prefer COPY for transparency:
# Good - COPY for local files
COPY package.json ./
COPY src/ ./src/
# Bad - ADD has implicit behavior
ADD package.json ./ # Could auto-extract if it were a tar
ADD https://example.com/file.tar.gz /tmp/ # Fetches URL
# Only use ADD for explicit tar extraction
ADD archive.tar.gz /opt/ # Explicitly want auto-extraction
Set Working Directory¶
Always use WORKDIR instead of cd:
# Good - WORKDIR is persistent
WORKDIR /app
COPY . .
RUN npm install
# Bad - cd only affects single RUN
RUN cd /app # Doesn't persist to next instruction
COPY . . # Copies to wrong location!
Avoid Installing Unnecessary Packages¶
Install only required dependencies:
# Good - Minimal installation
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libpq5 && \
rm -rf /var/lib/apt/lists/*
# Bad - Installing unnecessary packages
RUN apt-get update && \
apt-get install -y \
ca-certificates \
libpq5 \
vim \
curl \
wget \
git # Not needed in production!
Use Explicit Ports¶
Document exposed ports with EXPOSE:
# Good - Document all exposed ports
EXPOSE 3000/tcp
EXPOSE 9090/tcp # Metrics
EXPOSE 8080/udp # Custom protocol
# Note: EXPOSE is documentation only
# Actual port publishing happens at runtime:
# docker run -p 3000:3000 myapp
Regularly Scan Images for Vulnerabilities¶
Regularly scan images for security issues:
# Scan with Trivy
trivy image myapp:latest
# Scan with Grype
grype myapp:latest
# Fail CI on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
Use Specific ENTRYPOINT and CMD¶
Use exec form for proper signal handling:
# Good - Exec form (JSON array)
ENTRYPOINT ["python", "-m", "uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "8000"]
# Bad - Shell form (creates unnecessary shell process)
ENTRYPOINT python -m uvicorn
CMD main:app --host 0.0.0.0 --port 8000
# Combined usage allows runtime argument override
# docker run myapp main:app --reload # Overrides CMD, keeps ENTRYPOINT
References¶
Official Documentation¶
Security¶
Tools¶
Status: Active