What Is a Dockerfile?
A Dockerfile is a plain text file containing a sequence of instructions that Docker reads to assemble an image. Each instruction creates a new layer in the image — a read-only filesystem snapshot. The result is a portable, reproducible image that can run identically on any machine with a Docker-compatible runtime.
Here's the simplest possible Dockerfile:
# The simplest Dockerfile — a static web server
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
This two-line file does three things: (1) starts from the official nginx Alpine image, (2) copies your index.html into the image, and (3) inherits nginx's default CMD which starts the web server. Let's build and run it:
# Create the HTML file
echo '<h1>Hello from Docker!</h1>' > index.html
# Build the image
docker build -t my-website:latest .
# [+] Building 2.1s (7/7) FINISHED
# => [internal] load build definition from Dockerfile
# => [internal] load .dockerignore
# => [internal] load metadata for docker.io/library/nginx:alpine
# => [1/2] FROM docker.io/library/nginx:alpine@sha256:abc123...
# => [2/2] COPY index.html /usr/share/nginx/html/index.html
# => exporting to image
# => => naming to docker.io/library/my-website:latest
# Run it
docker run -d -p 8080:80 my-website:latest
# Visit http://localhost:8080 → "Hello from Docker!"
.dockerignore — Controlling Build Context
When you run docker build, Docker sends the entire directory (the "build context") to the daemon. A .dockerignore file excludes unnecessary files — speeding up builds and preventing sensitive data from entering images:
# .dockerignore — exclude from build context
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
!README.md
docker-compose*.yml
.dockerignore
Dockerfile*
coverage/
.nyc_output/
dist/
build/
*.log
.DS_Store
__pycache__/
*.pyc
.venv/
.dockerignore, a Node.js project sends node_modules/ (often 500MB+) to the Docker daemon on every build — even if your Dockerfile never uses it. With a proper .dockerignore, the build context drops to a few kilobytes, and builds start instantly.
The Build Process
Understanding how Docker builds images is essential for writing efficient Dockerfiles. Each instruction creates a layer, and Docker uses a sophisticated caching mechanism to skip unchanged layers.
flowchart TD
A[Dockerfile] --> B[Send Build Context to Daemon]
B --> C[Parse Instructions]
C --> D{FROM instruction}
D --> E[Pull/Load Base Image]
E --> F{Next Instruction?}
F -->|RUN| G[Create temp container → Execute → Commit as layer]
F -->|COPY/ADD| H[Copy files into layer → Commit]
F -->|ENV/WORKDIR/CMD| I[Update image metadata → Commit]
G --> J{Cache Hit?}
H --> J
I --> J
J -->|Yes| K[Reuse cached layer]
J -->|No| L[Execute instruction → Create new layer]
K --> F
L --> F
F -->|Done| M[Tag final image]
style A fill:#132440,color:#ffffff
style E fill:#16476A,color:#ffffff
style G fill:#3B9797,color:#ffffff
style H fill:#3B9797,color:#ffffff
style K fill:#3B9797,color:#ffffff
style L fill:#BF092F,color:#ffffff
style M fill:#132440,color:#ffffff
Layer Caching Rules
Docker's build cache follows strict rules:
- Cache is top-down: Once a cache miss occurs, ALL subsequent layers are rebuilt — even if they haven't changed
- RUN instructions: Cached based on the command string. Same string = cache hit (even if external data changed!)
- COPY/ADD instructions: Cached based on file checksums. If any copied file changes, the layer is rebuilt
- Order matters: Put rarely-changing instructions (FROM, apt-get install) BEFORE frequently-changing ones (COPY source code)
RUN apt-get update will be cached forever because the command string never changes. Always combine with install: RUN apt-get update && apt-get install -y package. Otherwise you'll get stale package lists on rebuilds.
FROM — Choosing Your Base
Every Dockerfile must begin with FROM. It sets the base image — the foundation upon which all subsequent instructions build. Choosing the right base impacts image size, security surface, and available tooling.
| Base Image | Size | Package Manager | Best For |
|---|---|---|---|
ubuntu:24.04 | ~78 MB | apt | Full tooling, familiar environment |
debian:bookworm-slim | ~80 MB | apt | Stable, well-supported, minimal |
alpine:3.20 | ~7 MB | apk | Minimal size, security-focused |
node:20-alpine | ~130 MB | apk + npm | Node.js applications |
python:3.12-slim | ~130 MB | apt + pip | Python applications |
gcr.io/distroless/static | ~2 MB | None | Compiled binaries (Go, Rust) |
scratch | 0 MB | None | Absolute minimal (static binaries) |
# Standard base with version pinning
FROM node:20.14-alpine3.20
# Multi-platform base (builds for amd64, arm64, etc.)
FROM --platform=$BUILDPLATFORM golang:1.22-alpine
# Using a digest for absolute reproducibility
FROM python:3.12-slim@sha256:a1b2c3d4e5f6...
# The empty base — for statically compiled binaries
FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]
RUN — Executing Build Commands
RUN executes commands during the build and commits the result as a new layer. It has two forms:
- Shell form:
RUN command arg1 arg2— Runs via/bin/sh -c, supports shell features (pipes, variables) - Exec form:
RUN ["executable", "arg1", "arg2"]— Direct execution, no shell processing
# BAD: Multiple RUN instructions = multiple layers = larger image
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
# Result: 5 layers, intermediate apt cache layers persist
# GOOD: Combined RUN with cleanup = single layer = smaller image
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Result: 1 layer, apt cache cleaned in the same layer
The key insight: each layer is immutable. If you install 200MB of packages in one RUN and delete them in the next RUN, the image still contains 200MB (the deletion only marks files as removed in the upper layer). Cleanup must happen in the same RUN instruction.
# Alpine equivalent — using apk
FROM alpine:3.20
RUN apk add --no-cache \
curl \
git \
openssh-client \
python3 \
py3-pip
# Building from source with cleanup in the same layer
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libssl-dev \
&& cd /tmp && \
curl -sSL https://example.com/source.tar.gz | tar xz && \
cd source && ./configure && make && make install && \
cd / && rm -rf /tmp/source && \
apt-get purge -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Build tools installed, used, and removed in one layer
COPY vs ADD
Both instructions copy files from the build context into the image, but they have different capabilities:
| Feature | COPY | ADD |
|---|---|---|
| Copy local files | Yes | Yes |
| Copy directories | Yes | Yes |
| Auto-extract tar archives | No | Yes (.tar, .tar.gz, .tgz, etc.) |
| Download from URLs | No | Yes (but prefer curl in RUN) |
| Supports --from (multi-stage) | Yes | No |
| Predictable behavior | Always simple copy | Magic behavior (sometimes unexpected) |
# COPY — preferred for most cases (explicit, predictable)
COPY package.json package-lock.json ./
COPY src/ ./src/
COPY --chown=node:node . .
# COPY with specific file permissions (BuildKit required)
COPY --chmod=755 scripts/entrypoint.sh /entrypoint.sh
# ADD — use ONLY for tar extraction
ADD rootfs.tar.gz /
# Automatically extracts the archive into /
# ADD from URL (NOT recommended — use curl in RUN instead)
# ADD https://example.com/file.tar.gz /tmp/
# Problem: doesn't cache well, can't clean up in same layer
# BETTER: Use RUN + curl for URL downloads
RUN curl -sSL https://example.com/file.tar.gz | tar xz -C /opt/
COPY unless you specifically need tar auto-extraction (the only valid use case for ADD). COPY is transparent — it always does exactly one thing. ADD has implicit behaviors that can surprise you.
WORKDIR — Setting the Working Directory
WORKDIR sets the working directory for all subsequent instructions (RUN, CMD, ENTRYPOINT, COPY, ADD). If the directory doesn't exist, it's created automatically.
# GOOD: Use WORKDIR to set directory context
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"]
# All paths relative to /app
# BAD: Using cd in RUN (doesn't persist to next instruction!)
FROM node:20-alpine
RUN cd /app # This has NO effect on subsequent instructions!
COPY package.json . # Copies to / (root), not /app!
RUN cd /app && npm install # Only works within this single RUN
# WORKDIR can be used multiple times
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
WORKDIR /app/src
COPY src/ .
WORKDIR /app
CMD ["python", "src/main.py"]
ENV & ARG — Build-Time vs Runtime Variables
These two instructions both set variables, but at different scopes:
| Aspect | ARG | ENV |
|---|---|---|
| Available during | Build only | Build + Runtime |
| Persists in image | No | Yes |
| Override at build | --build-arg | Not directly |
| Override at run | N/A | -e or --env |
| Visible in history | Yes (don't use for secrets!) | Yes (don't use for secrets!) |
| Scope | From declaration to end of stage | From declaration to all subsequent layers + runtime |
# ARG: Build-time parameterization
ARG NODE_VERSION=20
ARG ALPINE_VERSION=3.20
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}
ARG APP_VERSION=1.0.0
RUN echo "Building version ${APP_VERSION}"
# Build with: docker build --build-arg APP_VERSION=2.1.0 .
# ENV: Runtime configuration
FROM node:20-alpine
ENV NODE_ENV=production
ENV PORT=3000
ENV LOG_LEVEL=info
WORKDIR /app
COPY . .
RUN npm ci --production
EXPOSE $PORT
CMD ["node", "server.js"]
# Override at runtime: docker run -e PORT=8080 myapp
# Combined pattern: ARG for build flexibility, ENV for runtime
ARG DEFAULT_PORT=3000
ENV PORT=${DEFAULT_PORT}
# Build-time: docker build --build-arg DEFAULT_PORT=8080 .
# Runtime: docker run -e PORT=9090 myapp
docker history and docker inspect. For build-time secrets (API keys, SSH keys), use BuildKit's --secret mount: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci. For runtime secrets, use Docker secrets or environment injection at runtime.
CMD vs ENTRYPOINT — Defining Container Behavior
These instructions define what runs when a container starts. Their interaction is one of the most confusing aspects of Dockerfiles:
| Scenario | ENTRYPOINT | CMD | Container Runs |
|---|---|---|---|
| CMD only | (none) | ["node", "app.js"] | node app.js |
| ENTRYPOINT only | ["node"] | (none) | node |
| Both (recommended) | ["node"] | ["app.js"] | node app.js |
| User override | ["node"] | ["app.js"] | docker run img test.js → node test.js |
# Pattern 1: CMD only — simple, overridable
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
# docker run myapp → node server.js
# docker run myapp node test.js → node test.js (CMD replaced)
# docker run myapp sh → sh (CMD replaced entirely)
# Pattern 2: ENTRYPOINT + CMD — executable with default arguments
FROM python:3.12-slim
WORKDIR /app
COPY . .
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver", "0.0.0.0:8000"]
# docker run myapp → python manage.py runserver 0.0.0.0:8000
# docker run myapp migrate → python manage.py migrate
# docker run myapp shell → python manage.py shell
# docker run --entrypoint sh myapp → sh (ENTRYPOINT overridden)
# Pattern 3: Wrapper script as ENTRYPOINT
FROM postgres:16
COPY docker-entrypoint-custom.sh /docker-entrypoint-custom.sh
RUN chmod +x /docker-entrypoint-custom.sh
ENTRYPOINT ["/docker-entrypoint-custom.sh"]
CMD ["postgres"]
Shell Form vs Exec Form — Signal Handling
The form you choose for CMD/ENTRYPOINT determines whether your app receives signals correctly:
- Exec form
CMD ["node", "app.js"]— node runs as PID 1, receives SIGTERM directly → graceful shutdown works - Shell form
CMD node app.js— launches/bin/sh -c "node app.js", sh is PID 1, node is a child → SIGTERM goes to sh, node never sees it →docker stopwaits 10s then SIGKILL
# BAD: Shell form — signals don't reach the app
CMD npm start
# PID 1: /bin/sh -c "npm start"
# PID 2: npm
# PID 3: node server.js ← never receives SIGTERM!
# GOOD: Exec form — app is PID 1
CMD ["node", "server.js"]
# PID 1: node server.js ← receives SIGTERM directly
# GOOD: If you need shell features, use exec
CMD ["sh", "-c", "envsubst < /template.conf > /app.conf && exec node server.js"]
# 'exec' replaces the shell process with node → node becomes PID 1
EXPOSE — Documenting Ports
EXPOSE is documentation — it declares which ports the container listens on at runtime. It does NOT actually publish ports or create network rules. You still need -p at runtime.
# EXPOSE as documentation
FROM node:20-alpine
WORKDIR /app
COPY . .
EXPOSE 3000
EXPOSE 9229
# Tells users/tools: this app uses ports 3000 (HTTP) and 9229 (debug)
CMD ["node", "--inspect=0.0.0.0:9229", "server.js"]
# Multiple protocols
EXPOSE 80/tcp
EXPOSE 443/tcp
EXPOSE 53/udp
# At runtime, you STILL need -p to publish:
# docker run -p 3000:3000 -p 9229:9229 myapp
# Or use -P to publish ALL exposed ports to random host ports:
# docker run -P myapp → maps 3000→32768, 9229→32769 (random)
USER — Running as Non-Root
By default, containers run as root (UID 0). This is a significant security risk — if an attacker escapes the container, they have root access on the host. The USER instruction switches to a non-privileged user.
# Create a non-root user and switch to it
FROM node:20-alpine
# Create app user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Install dependencies as root (needs write access to /app)
COPY package*.json ./
RUN npm ci --production
# Copy application files
COPY --chown=appuser:appgroup . .
# Switch to non-root user for runtime
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
# Debian/Ubuntu pattern
FROM python:3.12-slim
RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /sbin/nologin appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appgroup . .
USER appuser:appgroup
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:create_app()"]
USER before the final CMD. The non-root user only needs read access to application files and write access to runtime directories (logs, tmp). Use --chown in COPY to set ownership without extra RUN layers.
HEALTHCHECK — Container Health Monitoring
HEALTHCHECK tells Docker how to verify a container is still working correctly. Docker periodically runs the health check command and marks the container as healthy, unhealthy, or starting.
# HTTP health check for a web application
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --production
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
# Database health check
FROM postgres:16-alpine
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
CMD pg_isready -U postgres || exit 1
# Redis health check
FROM redis:7-alpine
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD redis-cli ping | grep -q PONG || exit 1
# curl-based health check (for containers with curl)
FROM python:3.12-slim
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
# Disable inherited health check
HEALTHCHECK NONE
| Option | Default | Description |
|---|---|---|
--interval | 30s | Time between health checks |
--timeout | 30s | Max time for check to complete |
--start-period | 0s | Grace period for container startup (failures don't count) |
--retries | 3 | Consecutive failures before marking "unhealthy" |
LABEL & STOPSIGNAL
LABEL adds metadata to your image — maintainer info, version, description, licensing. STOPSIGNAL changes the signal sent to the container on docker stop.
# Standardized labels (OCI Image Spec)
FROM node:20-alpine
LABEL org.opencontainers.image.title="My Application"
LABEL org.opencontainers.image.description="Production API server"
LABEL org.opencontainers.image.version="2.1.0"
LABEL org.opencontainers.image.authors="team@example.com"
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.created="2026-05-14T10:00:00Z"
# Multiple labels in one instruction (reduces layers)
LABEL maintainer="team@example.com" \
version="2.1.0" \
environment="production"
# STOPSIGNAL: Change the shutdown signal
# Default is SIGTERM — override for apps that need different signals
FROM nginx:alpine
STOPSIGNAL SIGQUIT
# nginx uses SIGQUIT for graceful shutdown (finish current requests)
# Verify with: docker inspect -f '{{.Config.StopSignal}}' mycontainer
Build Context Deep Dive
The build context is everything in the directory you pass to docker build. Understanding it prevents common issues:
# The "." in docker build is the build context path
docker build -t myapp .
# Sends everything in current directory to daemon
# Sending build context to Docker daemon 152.4MB ← TOO BIG!
# Means you're sending node_modules, .git, etc.
# Use -f to specify Dockerfile location separately from context
docker build -f docker/Dockerfile.production -t myapp .
# Context = current dir, Dockerfile = docker/Dockerfile.production
# Build from a URL (Git repo)
docker build -t myapp https://github.com/user/repo.git#main
# Build from stdin (no context)
echo 'FROM alpine:3.20' | docker build -t minimal -
# Useful for trivial images, but COPY/ADD won't work (no context)
# Build with specific target directory as context
docker build -t myapp ./services/api/
# Only files in ./services/api/ are available to COPY/ADD
# Comprehensive .dockerignore for a Node.js project
# Version control
.git
.gitignore
.gitattributes
# Dependencies (will be installed fresh in container)
node_modules
bower_components
# Build artifacts (will be built in container)
dist
build
coverage
.nyc_output
# Environment and secrets
.env
.env.*
*.pem
*.key
# IDE and OS files
.vscode
.idea
*.swp
.DS_Store
Thumbs.db
# Docker files (prevent recursive builds)
Dockerfile*
docker-compose*
.dockerignore
# Documentation (not needed in image)
*.md
docs/
LICENSE
# Tests (usually not needed in production image)
test/
tests/
__tests__
*.test.js
*.spec.js
jest.config.js
Complete Production Dockerfile
Here's a production-ready Dockerfile for a Node.js application, incorporating all best practices covered in this article:
# ============================================================
# Production Dockerfile for Node.js API
# Follows all best practices: non-root user, health check,
# layer caching, minimal base, security hardening
# ============================================================
# Build argument for version pinning
ARG NODE_VERSION=20.14
ARG ALPINE_VERSION=3.20
# --- Stage 1: Dependencies (cached layer) ---
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS deps
WORKDIR /app
# Copy only dependency files first (cache optimization)
COPY package.json package-lock.json ./
# Install production dependencies only
RUN npm ci --production --ignore-scripts && \
npm cache clean --force
# --- Stage 2: Production Runtime ---
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS runtime
# Metadata
LABEL org.opencontainers.image.title="My API Server" \
org.opencontainers.image.version="2.1.0" \
org.opencontainers.image.authors="team@example.com"
# Install runtime dependencies (tini for PID 1 signal handling)
RUN apk add --no-cache tini curl
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Set working directory
WORKDIR /app
# Copy installed dependencies from deps stage
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
# Copy application source
COPY --chown=appuser:appgroup . .
# Environment configuration
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info
# Expose application port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Switch to non-root user
USER appuser
# Use tini as init system (handles signals correctly)
ENTRYPOINT ["tini", "--"]
# Start the application
CMD ["node", "server.js"]
# Build the production image
docker build -t myapi:latest -t myapi:2.1.0 .
# Verify the image
docker images myapi
# REPOSITORY TAG IMAGE ID SIZE
# myapi latest a1b2c3d4e5f6 182MB
# myapi 2.1.0 a1b2c3d4e5f6 182MB
# Run it
docker run -d --name api \
-p 3000:3000 \
-e DATABASE_URL="postgres://..." \
--restart unless-stopped \
myapi:latest
# Check health
docker inspect -f '{{.State.Health.Status}}' api
# healthy
Common Mistakes & Anti-Patterns
| Mistake | Problem | Fix |
|---|---|---|
Using latest tag for FROM |
Non-reproducible builds — base changes under you | Pin version: FROM node:20.14-alpine3.20 |
| COPY . . before npm install | Any source change invalidates npm install cache | Copy package*.json first, install, then COPY source |
| Running as root | Container escape = host root access | Create user with adduser, switch with USER |
| Using shell form CMD | App doesn't receive SIGTERM, 10s delay on stop | Use exec form: CMD ["node", "app.js"] |
| Secrets in ENV/ARG | Visible in docker history and inspect | Use BuildKit --secret or runtime injection |
| No .dockerignore | Sends .git, node_modules to daemon (slow, large) | Create .dockerignore matching .gitignore |
| apt-get update alone | Cached forever, gives stale packages | Always: apt-get update && apt-get install together |
| Multiple RUN for related commands | Extra layers, larger image | Combine with && and \ continuation |
| Using ADD instead of COPY | Unexpected auto-extraction behavior | Use COPY unless you need tar extraction |
| No HEALTHCHECK | Docker/orchestrator can't detect app failures | Add HEALTHCHECK for production images |
The Dependency-First Pattern
The single most impactful Dockerfile optimization is separating dependency installation from source code copying. Since dependencies change rarely but source code changes constantly, this pattern gives you cache hits on the expensive npm install / pip install step:
# OPTIMIZED: Dependencies cached separately from source
FROM python:3.12-slim
WORKDIR /app
# Step 1: Copy ONLY dependency specification (rarely changes)
COPY requirements.txt .
# Step 2: Install dependencies (cached unless requirements.txt changes)
RUN pip install --no-cache-dir -r requirements.txt
# Step 3: Copy source code (changes frequently)
COPY . .
# Result: Source changes don't trigger pip install rebuild!
This pattern works for every language: package.json → npm install → COPY, go.mod → go mod download → COPY, Gemfile → bundle install → COPY.
Exercises
- Layer Analysis — Write a Dockerfile that installs 3 packages with separate RUN instructions. Build it. Then rewrite with a single combined RUN. Compare image sizes with
docker imagesand layer counts withdocker history. - Cache Invalidation — Create a Node.js Dockerfile that copies everything first (
COPY . .beforenpm install). Build it twice after changing only a comment in your JS file. Then restructure to copypackage.jsonfirst. Verify the cache behavior with build output. - Signal Handling Test — Create a Node.js app that logs "Received SIGTERM, shutting down..." when it gets SIGTERM. Build two images: one with shell form CMD, one with exec form. Run each and use
docker stop— verify which one logs the message and which waits the full 10 seconds. - Non-Root Security — Write a Dockerfile that creates a non-root user. Verify by running
docker exec mycontainer whoamianddocker exec mycontainer id. Attempt to write to/etc/and confirm it fails with "Permission denied". - Complete Production Image — Containerize a real application (Python Flask, Express.js, or Go) with: non-root user, health check, .dockerignore, pinned base version, combined RUN layers, dependency-first pattern, exec form CMD, and LABEL metadata. Run a security scan with
docker scout cves myimage.
Conclusion & Next Steps
The Dockerfile is deceptively simple — a sequence of instructions that produce an image. But the difference between a beginner Dockerfile and a production-grade one is enormous: image size (100MB vs 1GB), build speed (30 seconds vs 10 minutes), security posture (non-root with health checks vs root with no monitoring), and signal handling (graceful shutdown vs 10-second SIGKILL).
Key takeaways:
- Every instruction creates a layer — combine related commands with
&&to minimize layers - Cache flows top-down — put rarely-changing instructions (dependencies) before frequently-changing ones (source code)
- Use COPY, not ADD — unless you specifically need tar auto-extraction
- Always use exec form for CMD and ENTRYPOINT — shell form breaks signal handling
- Run as non-root — create a dedicated user and switch with USER before CMD
- Never embed secrets — use BuildKit secrets for build-time, environment injection for runtime
- Add HEALTHCHECK — enable Docker and orchestrators to detect application failures
- Pin base image versions — use specific tags or digests for reproducible builds
- Use .dockerignore — exclude node_modules, .git, and secrets from the build context
Part 8 builds on these foundations with multi-stage builds — the technique that produces minimal production images by separating build-time dependencies from runtime. You'll learn to build Go binaries on Alpine and ship them in scratch (0 MB base), compile TypeScript and ship only the JavaScript, and use BuildKit's advanced caching for sub-second rebuilds.
Next in the Series
In Part 8: Multi-Stage Builds & Image Optimization, we will use multi-stage builds to reduce image sizes by 90%, explore Alpine and distroless bases, and master BuildKit's advanced caching strategies for lightning-fast CI/CD pipelines.