Back to Containers & Runtime Environments Mastery Series

Part 7: Dockerfile Fundamentals

May 14, 2026 Wasil Zafar 28 min read

A Dockerfile is the recipe that transforms a base operating system into a purpose-built container image. This article dissects every instruction — FROM, RUN, COPY, ADD, CMD, ENTRYPOINT, ENV, ARG, EXPOSE, WORKDIR, USER, and HEALTHCHECK — revealing how each one creates layers, affects caching, and impacts the final image's security and performance.

Table of Contents

  1. What Is a Dockerfile?
  2. The Build Process
  3. FROM
  4. RUN
  5. COPY vs ADD
  6. WORKDIR
  7. ENV & ARG
  8. CMD vs ENTRYPOINT
  9. EXPOSE
  10. USER
  11. HEALTHCHECK
  12. LABEL & STOPSIGNAL
  13. Build Context & .dockerignore
  14. Complete Example
  15. Common Mistakes
  16. Exercises
  17. Conclusion & Next Steps

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/
Performance Impact: Without .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.

Docker Image Build Process
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:

  1. Cache is top-down: Once a cache miss occurs, ALL subsequent layers are rebuilt — even if they haven't changed
  2. RUN instructions: Cached based on the command string. Same string = cache hit (even if external data changed!)
  3. COPY/ADD instructions: Cached based on file checksums. If any copied file changes, the layer is rebuilt
  4. Order matters: Put rarely-changing instructions (FROM, apt-get install) BEFORE frequently-changing ones (COPY source code)
Cache Trap: 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 MBaptFull tooling, familiar environment
debian:bookworm-slim~80 MBaptStable, well-supported, minimal
alpine:3.20~7 MBapkMinimal size, security-focused
node:20-alpine~130 MBapk + npmNode.js applications
python:3.12-slim~130 MBapt + pipPython applications
gcr.io/distroless/static~2 MBNoneCompiled binaries (Go, Rust)
scratch0 MBNoneAbsolute 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"]
Security Principle: Smaller base images = smaller attack surface. Alpine has ~50 CVEs in a typical scan vs ~200+ for Ubuntu. Distroless images have near-zero CVEs because they contain only your application and its runtime dependencies — no shell, no package manager, no utilities for attackers to exploit.

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 filesYesYes
Copy directoriesYesYes
Auto-extract tar archivesNoYes (.tar, .tar.gz, .tgz, etc.)
Download from URLsNoYes (but prefer curl in RUN)
Supports --from (multi-stage)YesNo
Predictable behaviorAlways simple copyMagic 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/
Best Practice: Always use 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 duringBuild onlyBuild + Runtime
Persists in imageNoYes
Override at build--build-argNot directly
Override at runN/A-e or --env
Visible in historyYes (don't use for secrets!)Yes (don't use for secrets!)
ScopeFrom declaration to end of stageFrom 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
Never put secrets in ARG or ENV! Both are visible in 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.jsnode 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"]
The PID 1 Problem

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 stop waits 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
PID 1 Signal Handling Graceful Shutdown

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()"]
Non-Root Pattern: Install dependencies and configure the system as root, then switch to a non-root user with 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
--interval30sTime between health checks
--timeout30sMax time for check to complete
--start-period0sGrace period for container startup (failures don't count)
--retries3Consecutive 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
Cache Optimization

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.jsonnpm install → COPY, go.modgo mod download → COPY, Gemfilebundle install → COPY.

Layer Caching Build Speed CI/CD

Exercises

  1. 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 images and layer counts with docker history.
  2. Cache Invalidation — Create a Node.js Dockerfile that copies everything first (COPY . . before npm install). Build it twice after changing only a comment in your JS file. Then restructure to copy package.json first. Verify the cache behavior with build output.
  3. 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.
  4. Non-Root Security — Write a Dockerfile that creates a non-root user. Verify by running docker exec mycontainer whoami and docker exec mycontainer id. Attempt to write to /etc/ and confirm it fails with "Permission denied".
  5. 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.