I’ve been containerizing applications for six years now, and I still see teams making the same Docker mistakes I made back in 2019. Bloated 2GB images, 15-minute builds that could be 30 seconds, running as root in production, storing secrets in image layers — the list goes on.
After optimizing Docker builds for everything from Node.js APIs to machine learning pipelines, here’s my collected wisdom. These aren’t theoretical best practices — they’re patterns I’ve battle-tested in production environments handling millions of requests.
Multi-Stage Builds: The Non-Negotiable Foundation
If you’re not using multi-stage builds, you’re probably shipping an image 3-10x larger than necessary. Here’s the pattern I use for every Node.js/TypeScript application:
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --production
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
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 ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Size Comparison
| Approach | Image Size | Build Time |
|---|---|---|
| Single-stage (no optimization) | 1.2 GB | 4 min |
| Multi-stage (basic) | 320 MB | 2.5 min |
| Multi-stage (alpine + pruned) | 145 MB | 2 min |
| Multi-stage (distroless) | 89 MB | 2.5 min |
Pro Tip: For even smaller images, use Google’s distroless base images instead of Alpine. They contain only your app and its runtime dependencies — no shell, no package manager, nothing an attacker could exploit.
# Distroless for minimum attack surface
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
CMD ["dist/index.js"]
Layer Caching: Make Your Builds 10x Faster
Docker caches layers, and the order of your instructions determines how effectively that cache works. The rule is simple: put things that change least often at the top.
# ❌ Bad: Any source change invalidates everything
FROM node:22-alpine
WORKDIR /app
COPY . . # Source changes invalidate this + all below
RUN npm install # Reinstalls ALL deps every time
RUN npm run build
# ✅ Good: Dependencies are cached separately from source
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # Only changes when deps change
RUN npm ci # Cached unless package.json changes
COPY . . # Source changes only rebuild from here
RUN npm run build
Advanced Caching with BuildKit
# syntax=docker/dockerfile:1.5
FROM node:22-alpine AS builder
WORKDIR /app
# Mount cache for npm
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
npm ci
COPY . .
RUN npm run build
Enable BuildKit with:
export DOCKER_BUILDKIT=1
# or in Docker Desktop, it's enabled by default
Pro Tip: For monorepos, use
COPY package.json packages/*/package.json ./patterns with a.dockerignoreto only copy what’s needed. This prevents cache invalidation when unrelated packages change.
.dockerignore: The Forgotten Performance Win
Your .dockerignore file is as important as your Dockerfile. Without it, you’re copying node_modules, .git, test files, and documentation into your build context — wasting time and potentially leaking secrets.
# .dockerignore
node_modules
.git
.gitignore
*.md
!README.md
docker-compose*.yml
.env*
.vscode
coverage
tests
__tests__
*.test.ts
*.spec.ts
.next
dist
logs
tmp
Build Context Size Impact
| With .dockerignore | Without |
|---|---|
| 12 MB sent to daemon | 890 MB sent to daemon |
| Build starts instantly | 15+ second upload delay |
Security Best Practices
1. Never Run as Root
# Create a dedicated user
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001 -G app
# Set ownership of app files
COPY --chown=app:app . .
# Switch to non-root user
USER app
2. Scan for Vulnerabilities
# Scan your image with Docker Scout
docker scout cves myapp:latest
# Or use Trivy (my preference)
trivy image myapp:latest
# In CI/CD (GitHub Actions)
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:latest
severity: 'CRITICAL,HIGH'
exit-code: '1'
3. Pin Your Base Image Versions
# ❌ Bad: "latest" could change unexpectedly
FROM node:latest
# ⚠️ Okay: Major version pinned
FROM node:22-alpine
# ✅ Best: Specific digest for reproducibility
FROM node:22-alpine@sha256:abc123...
# ✅ Also good: Specific minor version
FROM node:22.5.1-alpine3.20
4. Don’t Store Secrets in Images
# ❌ NEVER do this - secrets persist in image layers
ENV DATABASE_URL=postgres://user:password@host/db
COPY .env .
# ✅ Use runtime secrets
# Pass via environment at runtime:
# docker run -e DATABASE_URL=... myapp
# Or use Docker secrets:
# docker service create --secret db_password myapp
5. Use Read-Only Filesystem
# docker-compose.yml
services:
api:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/logs
security_opt:
- no-new-privileges:true
Docker Compose for Development
Here’s my go-to development setup for a full-stack application:
# docker-compose.yml
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
target: development
ports:
- "3000:3000"
- "9229:9229" # Debug port
volumes:
- ./src:/app/src:delegated
- /app/node_modules # Anonymous volume prevents host override
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
Pro Tip: Use
depends_onwithcondition: service_healthyinstead of justdepends_on: [db]. Without health checks, your API container starts before PostgreSQL is ready to accept connections, causing startup crashes.
For teams using GitHub Actions for CI/CD, Docker Compose is perfect for integration testing — spin up the full stack, run tests, tear it down.
Health Checks
Always include health checks in your Dockerfiles:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
For Node.js applications, I prefer a dedicated health endpoint:
app.get('/health', (req, res) => {
const health = {
status: 'ok',
uptime: process.uptime(),
timestamp: Date.now(),
checks: {
database: dbPool.totalCount > 0 ? 'connected' : 'disconnected',
redis: redisClient.isOpen ? 'connected' : 'disconnected',
}
};
const isHealthy = Object.values(health.checks).every(s => s === 'connected');
res.status(isHealthy ? 200 : 503).json(health);
});
Common Mistakes I Still See in 2025
1. Using npm install Instead of npm ci
# ❌ npm install can modify package-lock.json
RUN npm install
# ✅ npm ci gives reproducible builds from lockfile
RUN npm ci
2. Not Leveraging Build Arguments for Flexibility
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine
ARG APP_VERSION=unknown
ENV APP_VERSION=${APP_VERSION}
# Pass at build time:
# docker build --build-arg APP_VERSION=1.2.3 .
3. Copying the Entire Source When Only dist Is Needed
If your app compiles/transpiles, only copy the build output to the final stage. Never ship source code in production images.
4. Not Using .dockerignore with Build Secrets
If you have a .env file for development, it might accidentally get copied into your image without a proper .dockerignore.
5. Running One Process Per Container (Usually)
The “one process per container” rule has exceptions, but generally:
# ✅ Good: Separate containers for separate concerns
services:
api:
image: myapp-api
worker:
image: myapp-worker
scheduler:
image: myapp-scheduler
# ❌ Avoid: Multiple processes in one container
# (unless they're tightly coupled, like nginx + php-fpm)
Production Optimization Checklist
| Optimization | Impact | Effort |
|---|---|---|
| Multi-stage builds | 70-90% size reduction | Low |
| Alpine base images | 50-80% size reduction | Low |
| .dockerignore | Faster builds | Very Low |
| Layer ordering | 80% cache hit rate | Low |
| Non-root user | Security hardening | Low |
| Health checks | Better orchestration | Low |
| BuildKit cache mounts | 50% faster installs | Medium |
| Distroless base | Maximum security | Medium |
| Image scanning | Vulnerability detection | Medium |
| Read-only filesystem | Attack surface reduction | Medium |
Advanced Patterns
Conditional Stages
# Build different targets for different environments
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS development
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS production
COPY . .
RUN npm run build && npm prune --production
CMD ["node", "dist/index.js"]
# Build specific target
docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .
Docker Init (New in Docker 2025)
Docker now includes docker init which generates Dockerfiles based on your project:
$ docker init
? What application platform does your project use? Node
? What version of Node do you want to use? 22
? Which package manager do you use? npm
? What command do you use to start your app? npm start
? What port does your app listen on? 3000
✔ Created Dockerfile
✔ Created .dockerignore
✔ Created docker-compose.yml
This generates a solid starting point that follows most of the best practices in this article.
For performance-critical deployments, container optimization directly impacts cold start times and scaling speed.
FAQ
Should I use Alpine or Debian-based images?
Use Alpine for most applications — it’s 5-10x smaller than Debian Slim. Switch to Debian when you need native packages that aren’t available in Alpine’s musl-based ecosystem (rare for Node.js/Python, more common for C/C++ dependencies). If you encounter mysterious segfaults or DNS issues, Alpine’s musl libc might be the culprit — switch to Debian Slim.
How often should I rebuild and update base images?
Rebuild weekly at minimum to pick up security patches in base images. Set up automated rebuilds in your CI pipeline that trigger on base image updates. I use Renovate Bot to monitor and auto-PR base image updates. For critical security patches, rebuild immediately.
Docker Desktop vs Podman vs OrbStack — which should I use?
For macOS development, OrbStack is dramatically faster than Docker Desktop (VM boot in <1 second, significantly less RAM usage). Docker Desktop remains the safe enterprise choice with good support. Podman is excellent for Linux and rootless containers. I switched to OrbStack six months ago and haven’t looked back.
How do I keep my Docker images secure?
Four layers: (1) Use minimal base images (Alpine or distroless), (2) Scan images with Trivy/Scout in CI, (3) Run as non-root user, (4) Keep base images updated. Additionally, use --no-new-privileges in your runtime config and consider read-only filesystems. Never store secrets in image layers — use runtime environment variables or Docker secrets.
What’s the best way to handle environment-specific configuration?
Build one image, configure at runtime. Use environment variables for anything that changes between environments (database URLs, API keys, feature flags). For complex configuration, mount config files as volumes or use a config service. Never bake environment-specific values into your image — the same image should run in dev, staging, and production.
