Docker Best Practices
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
.dockerignoreto 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
}
}