← All Guides

Docker Best Practices

dockercontainerssecurity

We ship a lot of containers. These are the patterns we’ve settled on after doing it wrong a few times.

Multi-Stage Builds

Always use multi-stage builds. Your final image shouldn’t contain build tools, source code, or dev dependencies.

Bun (TypeScript / JavaScript)

# Build stage
FROM oven/bun:1-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build

# Production stage
FROM oven/bun:1-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
USER bun
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

Deno (TypeScript / JavaScript)

FROM denoland/deno:alpine AS build
WORKDIR /app
COPY . .
RUN deno compile --allow-net --allow-read --output server src/server.ts

FROM scratch
COPY --from=build /app/server /server
USER 65534
EXPOSE 8000
ENTRYPOINT ["/server"]

Deno’s compile produces a single binary that runs from scratch, like Go and Rust. See the JavaScript Runtimes guide for when to use Bun vs Deno.

Python

# Build stage
FROM python:3.13-slim AS build
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
COPY . .

# Production stage
FROM python:3.13-slim
WORKDIR /app
COPY --from=build /app/.venv ./.venv
COPY --from=build /app/src ./src
ENV PATH="/app/.venv/bin:$PATH"
USER nobody
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0"]

Go

FROM golang:1.23-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /server /server
USER 65534
EXPOSE 8080
ENTRYPOINT ["/server"]

Go binaries can run from scratch (empty image). Final image is typically under 15MB.

Rust

FROM rust:1.82-alpine AS build
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN cargo build --release

FROM scratch
COPY --from=build /app/target/release/server /server
USER 65534
EXPOSE 8080
ENTRYPOINT ["/server"]

The dummy main.rs trick caches dependency compilation.

Image Size

Smaller images pull faster and give attackers less to work with.

Use Alpine or Distroless Base Images

# Best - Bun Alpine (~100MB) or Deno Alpine (~130MB)
FROM oven/bun:1-alpine
FROM denoland/deno:alpine

# OK - Node Alpine if you must (~180MB)
FROM node:22-alpine

# Avoid - Full Debian (~300MB+)
FROM node:22

See the JavaScript Runtimes guide for why we prefer Bun and Deno over Node.

Check Image Size

# See image size
docker images myapp

# Inspect layers with dive
dive myapp:latest

Common Size Wins

  • Delete caches after installing packages: RUN apk add --no-cache ...
  • Combine RUN statements to reduce layers
  • Use .dockerignore to exclude node_modules, .git, docs, tests

.dockerignore

.git
.github
node_modules
dist
*.md
LICENSE
.env*
.vscode
.idea
tests
docs

Security

Run as Non-Root

Never run containers as root in production:

# Create a non-root user
RUN addgroup -S app && adduser -S app -G app
USER app

# Or use the built-in nobody user
USER nobody

# Or use numeric UID (works with scratch/distroless)
USER 65534

Pin Base Image Versions

# Good - pinned to minor version
FROM node:22.12-alpine

# OK - pinned to major version
FROM node:22-alpine

# Bad - floating tag, could change any time
FROM node:latest

Scan for Vulnerabilities

# Scan with Snyk
snyk container test myapp:latest

# Scan with Docker Scout
docker scout cves myapp:latest

# Scan with Trivy
trivy image myapp:latest

Run scans in CI, not just locally.

Don’t Store Secrets in Images

# Bad - secret baked into image
ENV API_KEY=sk-secret-123

# Good - pass at runtime
# docker run -e API_KEY=sk-secret-123 myapp

Use Docker secrets, environment variables at runtime, or 1Password op run.

Docker Compose

For local development, use Compose:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

Useful Commands

dc up -d          # start in background
dc logs -f app    # follow logs
dc down           # stop and remove
dc build --no-cache  # rebuild from scratch
dc exec app sh    # shell into running container

Build Performance

Layer Caching

Order your Dockerfile so things that change least are at the top:

# 1. Base image (rarely changes)
FROM node:22-alpine

# 2. System dependencies (rarely changes)
RUN apk add --no-cache curl

# 3. App dependencies (changes when deps update)
COPY package.json bun.lock ./
RUN npm ci

# 4. App source (changes most often)
COPY . .
RUN npm run build

BuildKit

Enable BuildKit for faster builds:

export DOCKER_BUILDKIT=1

Or in ~/.docker/daemon.json:

{
  "features": {
    "buildkit": true
  }
}