Dockerfile Deep Dive¶
Navigation: Home → Deployment → Dockerfile
Overview¶
This document provides an in-depth explanation of the multi-stage Dockerfile used to build optimized Docker images for the Simon Stijnen Portfolio website. The Dockerfile implements industry best practices for Next.js applications, resulting in small, secure, and performant production images.
Table of Contents¶
- Architecture
- Stage 1: Base Image
- Stage 2: Dependencies
- Stage 3: Builder
- Stage 4: Production Runner
- Optimization Techniques
- Security Features
- Customization
Architecture¶
Multi-Stage Build Overview¶
graph LR
A[base<br/>Node 24 Alpine] --> B[deps<br/>Install Dependencies]
B --> C[builder<br/>Build Application]
C --> D[runner<br/>Production Image]
E[node_modules] -.->|Copy| C
F[Source Code] -.->|Copy| C
C -.->|Standalone Output| D
C -.->|Static Assets| D
style A fill:#2496ed,color:#fff
style B fill:#f39c12,color:#fff
style C fill:#e74c3c,color:#fff
style D fill:#27ae60,color:#fff
Build Flow¶
flowchart TD
Start([Start Build]) --> Base[Pull node:24-alpine]
Base --> Deps[Stage: deps<br/>Install node_modules]
Deps --> Builder[Stage: builder<br/>Build Next.js app]
Builder --> Optimize[Next.js Standalone Output<br/>Tree-shake dependencies]
Optimize --> Runner[Stage: runner<br/>Setup production container]
Runner --> Security[Add non-root user<br/>Set permissions]
Security --> Health[Configure health check]
Health --> End([Final Image ~180MB])
style Start fill:#3498db,color:#fff
style End fill:#27ae60,color:#fff
style Deps fill:#f39c12,color:#fff
style Builder fill:#e74c3c,color:#fff
style Runner fill:#9b59b6,color:#fff
Size Comparison¶
| Stage | Size | Contents | Purpose |
|---|---|---|---|
| base | ~150MB | Node.js + Alpine Linux | Foundation for all stages |
| deps | ~400MB | base + node_modules | Isolated dependency installation |
| builder | ~1.2GB | deps + source + build artifacts | Build Next.js application |
| runner | ~180MB | base + standalone output only | Final production image |
Stage 1: Base Image¶
Dockerfile Section¶
Explanation¶
Syntax Directive:
- Enables BuildKit features and latest Dockerfile syntax
- Provides better caching, parallelization, and security
- Optional but recommended for modern Docker builds
Base Image Choice:
Why node:24-alpine?
- Node.js 24: Latest LTS version with modern JavaScript features
- Alpine Linux: Minimal distribution (~5MB vs ~130MB for Debian)
- Security: Fewer packages = smaller attack surface
- Performance: Faster image pulls and container startup
Alternative Base Images:
# Standard Debian-based (larger but more compatible)
FROM node:24-slim AS base
# Specific version pinning (reproducible builds)
FROM node:24.0.0-alpine3.19 AS base
# Non-LTS version (not recommended for production)
FROM node:latest AS base
Stage 2: Dependencies¶
Dockerfile Section¶
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
Detailed Breakdown¶
1. Alpine Compatibility¶
Purpose: Some Node.js native modules expect glibc (GNU C Library), but Alpine uses musl libc.
libc6-compat: Provides compatibility layer for glibc-dependent packages--no-cache: Doesn't store apk index, saves ~1-2MB- Required for packages like: canvas, sharp, better-sqlite3
When NOT Needed: If your application only uses pure JavaScript packages, you can remove this line.
2. Working Directory¶
- Sets
/appas the working directory for subsequent commands - Creates directory if it doesn't exist
- All relative paths now resolve from
/app
3. Copy Lock Files¶
Wildcards (*): Copy if file exists, ignore if missing
package.json- Always requiredyarn.lock*- Yarn v1/v2/v3 lock filepackage-lock.json*- npm lock filepnpm-lock.yaml*- pnpm lock file.npmrc*- Optional npm configuration
Why Copy Lock Files Separately?
- Maximizes Docker layer caching
- If only source code changes, dependency layer is cached
- Significantly speeds up rebuild times
4. Package Manager Detection¶
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
Logic Flow:
flowchart TD
Start[Check Lock Files] --> Yarn{yarn.lock<br/>exists?}
Yarn -->|Yes| YarnCmd[yarn --frozen-lockfile]
Yarn -->|No| Npm{package-lock.json<br/>exists?}
Npm -->|Yes| NpmCmd[npm ci]
Npm -->|No| Pnpm{pnpm-lock.yaml<br/>exists?}
Pnpm -->|Yes| PnpmCmd[corepack enable pnpm &&<br/>pnpm i --frozen-lockfile]
Pnpm -->|No| Error[ERROR: No lockfile]
YarnCmd --> Success[Dependencies Installed]
NpmCmd --> Success
PnpmCmd --> Success
Error --> Fail[Build Fails]
style Success fill:#27ae60,color:#fff
style Fail fill:#e74c3c,color:#fff
Command Explanations:
| Package Manager | Command | Purpose |
|---|---|---|
| Yarn | yarn --frozen-lockfile |
Install exact versions, fail if lock file is outdated |
| npm | npm ci |
Clean install from lock file, faster than npm install |
| pnpm | pnpm i --frozen-lockfile |
Install exact versions with efficient disk usage |
Why npm ci instead of npm install?
- Deletes
node_modulesbefore installing (clean state) - Only installs from
package-lock.json(reproducible) - Faster in CI/CD environments
- Fails if lock file is out of sync with
package.json
Stage 3: Builder¶
Dockerfile Section¶
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
Detailed Breakdown¶
1. Copy Dependencies¶
Multi-stage copy:
- Copies entire
node_modulesfrom thedepsstage - No need to reinstall dependencies
- Saves time and ensures consistency
2. Copy Source Code¶
Copies everything except patterns in .dockerignore:
# Recommended .dockerignore
node_modules
.next
.git
.env*.local
npm-debug.log
Dockerfile
.dockerignore
README.md
3. Production Environment¶
Effects:
- Disables development warnings and debugging features
- Enables production optimizations in Next.js
- Reduces bundle size by tree-shaking dev dependencies
- Enables performance optimizations
4. Build Application¶
What happens during build:
# Next.js build process
npm run build
# ↓
# 1. Compile TypeScript → JavaScript
# 2. Bundle client-side code
# 3. Pre-render static pages
# 4. Generate standalone output (if configured)
# 5. Optimize images and assets
Build Output:
.next/
├── cache/ # Build cache (not needed in production)
├── server/ # Server components
├── static/ # Static assets with content hashes
└── standalone/ # Minimal production files (used in runner)
├── server.js # Entry point
├── package.json # Minimal dependencies
└── node_modules/ # Only production dependencies
Stage 4: Production Runner¶
Dockerfile Section¶
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN apk add --no-cache wget
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check to verify the application is running correctly
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://localhost:3000/ || exit 1
# Run the Next.js application
CMD ["node", "server.js"]
Detailed Breakdown¶
1. User Creation (Security)¶
Why non-root user?
- Security: Limits damage if container is compromised
- Best Practice: Follows principle of least privilege
- Compliance: Required by many security policies
User Details:
--system: System user (no login shell, no home directory)--gid 1001: Group ID 1001 (nodejs group)--uid 1001: User ID 1001 (nextjs user)- No password, no interactive login
2. Install Health Check Tool¶
- Minimal HTTP client for health checks
- Alternative:
curl(slightly larger) - Only ~400KB added to image
3. Copy Public Assets¶
- Static files from
public/directory - Served by Next.js at root path
- Includes: images, fonts, favicon, robots.txt
4. Copy Standalone Output¶
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
Standalone Output: Next.js generates a minimal production build with only required files:
standalone/
├── server.js # Entry point (~10KB)
├── package.json # Only prod dependencies
└── node_modules/ # Tree-shaken modules (~50MB)
--chown=nextjs:nodejs:
- Sets file ownership to
nextjsuser - Prevents permission issues at runtime
- Applied during copy (efficient)
Why two copy commands?
standalone/contains server code and minimal node_modulesstatic/contains client-side JavaScript and assets with content hashes- Static files need to be in
.next/staticfor Next.js to serve them
5. Switch to Non-Root User¶
- All subsequent commands run as
nextjsuser - Includes the final
CMDwhen container starts - Cannot modify system files or install packages
6. Port Declaration¶
- Documentary: Indicates the container listens on port 3000
- Does NOT publish the port (use
-pflag for that) - Used by Docker networking and tools
7. Environment Variables¶
PORT=3000:
- Next.js server listens on this port
- Can be overridden at runtime:
-e PORT=8080
HOSTNAME="0.0.0.0":
- Binds to all network interfaces
- Required for Docker networking
- Alternative:
localhost(only accessible from inside container)
8. Health Check¶
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://localhost:3000/ || exit 1
Parameters:
--interval=30s: Check every 30 seconds--timeout=5s: Kill check if it takes >5 seconds--start-period=10s: Grace period for app startup--retries=3: Mark unhealthy after 3 consecutive failures
Command Breakdown:
wget --quiet --spider http://localhost:3000/ || exit 1
# ↓ ↓
# No output Return 1 (unhealthy) if wget fails
# ↓
# Don't download, just check if URL is accessible
Health States:
starting(0-10s): Grace periodhealthy: Check succeededunhealthy: 3+ consecutive failures
See: Health Checks in Docker Guide
9. Container Command¶
Exec form (recommended):
- Array syntax:
["executable", "param1", "param2"] - Direct process execution (PID 1)
- Proper signal handling (SIGTERM, SIGINT)
- Graceful shutdown support
Shell form (not recommended):
- Wrapped in shell:
/bin/sh -c "node server.js" - Signals sent to shell, not Node.js
- Harder to stop gracefully
Optimization Techniques¶
Layer Caching Strategy¶
Docker caches each layer. To maximize cache hits:
# ✅ GOOD: Copy dependencies first (changes rarely)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source code last (changes frequently)
COPY . .
RUN npm run build
# ❌ BAD: Copy everything at once
COPY . .
RUN npm ci && npm run build
# Any file change invalidates entire layer
BuildKit Features¶
Enable BuildKit for parallel builds:
# One-time
export DOCKER_BUILDKIT=1
docker build -t personal-website .
# Permanent (add to ~/.bashrc or ~/.zshrc)
export DOCKER_BUILDKIT=1
Benefits:
- Parallel stage execution
- Better caching
- Secret mounting
- SSH forwarding
Standalone Output Configuration¶
Required in next.config.ts:
Before vs After:
| Metric | Standard Build | Standalone Build |
|---|---|---|
| Image Size | ~1.2GB | ~180MB |
| node_modules | Full (400MB) | Tree-shaken (50MB) |
| Startup Time | ~3s | ~1s |
| Files Copied | ~10,000 | ~100 |
Security Features¶
Multi-Stage Build Security¶
graph TD
A[Source Code + Secrets] --> B[builder Stage]
B --> C[Build Artifacts]
C --> D[runner Stage]
E[Secrets/Tokens] -.-> B
E -.x|NOT copied| D
F[dev Dependencies] --> B
F -.x|NOT copied| D
style D fill:#27ae60,color:#fff
style E fill:#e74c3c,color:#fff
style F fill:#e74c3c,color:#fff
What's excluded from final image:
- Build-time secrets (API keys, tokens)
- Development dependencies
- Source TypeScript files
- Test files
- Git history
- Build cache
Non-Root User¶
Running as non-root prevents:
- Privilege escalation attacks
- System file modifications
- Package installation by attackers
- Container breakout scenarios
Test user permissions:
# Verify running as nextjs user
docker exec personal-website whoami
# Output: nextjs
# Verify cannot install packages
docker exec personal-website apk add curl
# Output: permission denied
Minimal Attack Surface¶
Alpine Linux benefits:
- Fewer pre-installed packages
- Smaller attack surface
- Faster security patches
- Package manager (apk) audit support
Package count comparison:
- Alpine: ~30 packages
- Debian Slim: ~120 packages
- Ubuntu: ~400 packages
Customization¶
Using Different Node Versions¶
# LTS versions
FROM node:20-alpine AS base # v20 LTS
FROM node:22-alpine AS base # v22 LTS
FROM node:24-alpine AS base # v24 LTS (current)
# Pin exact version
FROM node:24.0.0-alpine3.19 AS base
# Use Iron Codename
FROM node:iron-alpine AS base # Node.js 20
Adding System Dependencies¶
FROM base AS deps
RUN apk add --no-cache \
libc6-compat \
python3 \
make \
g++
# Required for native modules like node-gyp
Custom Build Arguments¶
FROM node:${NODE_VERSION}-alpine AS base
ARG NODE_VERSION=24
# Use with: docker build --build-arg NODE_VERSION=22 .
Development Dockerfile¶
Create Dockerfile.dev for local development:
FROM node:24-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Expose dev server and debugging port
EXPOSE 3000 9229
# Run in development mode
CMD ["npm", "run", "dev"]
Multi-Architecture Support¶
# Automatically use correct architecture
FROM --platform=$BUILDPLATFORM node:24-alpine AS base
# Build with: docker buildx build --platform linux/amd64,linux/arm64 .
See Also¶
- Docker Guide - Comprehensive Docker usage guide
- Docker Compose - Multi-service orchestration
- CI/CD Pipeline - Automated Docker builds
- Production Config - Production best practices
Next Steps¶
- Build Your Image: Follow the Docker Guide
- Local Development: Set up Docker Compose
- Automate Builds: Configure CI/CD Pipeline
- Production Deployment: Review Deployment Strategies
Last Updated: February 2026
Dockerfile Version: Multi-stage v3
Next.js Compatibility: 13.x, 14.x, 15.x
Node.js Version: 24 LTS