How to Write a Dockerfile: A Complete Practical Guide
Complete practical guide to writing production-ready Dockerfiles with multi-stage builds and real Node.js and Python examples.
How to Write a Dockerfile: A Complete Practical Guide
A Dockerfile is a plain-text recipe that tells Docker exactly how to build a portable image of your application. Written well, it produces a tiny, secure, fast-starting image. Written poorly, it produces a 2 GB image that ships your .env file to a container registry and runs as root. This guide walks through every important instruction, then shows complete production-grade examples for Node.js and Python.
Step 1: Choose the right base image with FROM
Every Dockerfile starts with FROM. The base image you pick decides your image size, attack surface, and glibc/musl compatibility.
- Full images (
node:22,python:3.13): roughly 1 GB. Include build tools, git, and a full Debian userland. Convenient but bloated. - Slim images (
node:22-slim,python:3.13-slim): roughly 150 MB. Debian-based, minimal packages. A safe default for most apps. - Alpine images (
node:22-alpine): roughly 50 MB. Uses musl libc instead of glibc, which can break native modules (bcrypt, sharp, grpc) and Python wheels. Use only when you know your dependencies work.
Always pin a specific tag: python:3.13.1-slim-bookworm, never python:latest. Latest floats and will silently break your builds.
Step 2: Set WORKDIR and copy files carefully
WORKDIR /app creates the directory if missing and sets it as the current directory for all following instructions. Never use RUN cd /app — each RUN starts fresh.
Then comes the most important optimization in Docker: copy dependency manifests before source code. Docker caches each layer, and any layer invalidation busts every layer below it. If you copy your entire source tree first, changing a single line of code re-installs every dependency.
COPY package*.json ./\nRUN npm ci\nCOPY . .COPY vs ADD
Use COPY. Always. ADD has two extra behaviors — auto-extracting local tarballs and fetching remote URLs — both of which are footguns. Remote URL fetches skip cache invalidation and can't be verified. If you need a remote file, use RUN curl with a checksum.
Step 3: Chain RUN commands and clean up
Every RUN creates a new image layer. Files deleted in a later layer still occupy space in the earlier one. This means the classic mistake:
RUN apt-get update\nRUN apt-get install -y curl\nRUN rm -rf /var/lib/apt/lists/*...still ships the apt cache inside the image. Chain them with && and clean up in the same layer:
RUN apt-get update && \\\n apt-get install -y --no-install-recommends curl ca-certificates && \\\n rm -rf /var/lib/apt/lists/*The --no-install-recommends flag skips optional packages and can trim 100+ MB. For pip and npm, similar cleanup applies: pip install --no-cache-dir and npm ci && npm cache clean --force.
Step 4: Add ENV, EXPOSE, USER, and HEALTHCHECK
ENV NODE_ENV=production sets environment variables baked into the image. EXPOSE 3000 is documentation only — it doesn't publish the port — but tools like docker-compose read it.
Never run as root. A container escape from a root process is drastically worse than from an unprivileged one. Create a user and switch:
RUN addgroup --system app && adduser --system --ingroup app app\nUSER appThe Node official images already include a `node` unprivileged user (uid 1000) you can drop to. Python official images run as root and do not ship a non-root user, so you must create one yourself (see the Python multi-stage example below). Add a HEALTHCHECK so orchestrators know when your container is actually ready:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \\\n CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" || exit 1Step 5: ENTRYPOINT vs CMD
Both define what runs when the container starts, but they combine differently. ENTRYPOINT is the fixed executable; CMD is the default argument list that users can override with docker run image arg1 arg2.
Use exec form (JSON array), never shell form. Exec form runs PID 1 directly so signals like SIGTERM reach your process for graceful shutdown:
ENTRYPOINT [\"node\"]\nCMD [\"server.js\"]Step 6: Multi-stage builds — the biggest size win
Multi-stage builds let you compile in a fat image and ship from a lean one. A Node.js example that goes from from roughly 1 GB down to under 200 MB:
FROM node:22-slim AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\nFROM node:22-slim AS runtime\nENV NODE_ENV=production\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev && npm cache clean --force\nCOPY --from=builder /app/dist ./dist\nUSER node\nEXPOSE 3000\nHEALTHCHECK --interval=30s CMD node -e \"require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))\"\nCMD [\"node\", \"dist/server.js\"]A Python equivalent using a virtualenv copied between stages:
FROM python:3.13-slim AS builder\nWORKDIR /app\nRUN python -m venv /opt/venv\nENV PATH=\"/opt/venv/bin:$PATH\"\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nFROM python:3.13-slim AS runtime\nRUN addgroup --system app && adduser --system --ingroup app app\nCOPY --from=builder /opt/venv /opt/venv\nENV PATH=\"/opt/venv/bin:$PATH\" \\\n PYTHONUNBUFFERED=1 \\\n PYTHONDONTWRITEBYTECODE=1\nWORKDIR /app\nCOPY --chown=app:app . .\nUSER app\nEXPOSE 8000\nHEALTHCHECK --interval=30s CMD python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\"\nCMD [\"gunicorn\", \"-b\", \"0.0.0.0:8000\", \"app:app\"]The .dockerignore file — do not skip this
Without .dockerignore, COPY . . ships your .git directory, node_modules, .env, and IDE folders into the image. That's slow, huge, and a security incident waiting to happen. A sensible starter:
.git\n.gitignore\nnode_modules\n__pycache__\n*.pyc\n.env\n.env.*\n.venv\ndist\nbuild\n.vscode\n.idea\nnpm-debug.log\nREADME.md\ncoverage\n.pytest_cacheCommon footguns to avoid
- Running as root. Always add a
USERinstruction near the end. - No .dockerignore. Leaks secrets and inflates build context.
- COPY . . before installing deps. Destroys layer caching for every code change.
- Using
latesttags. Non-reproducible builds. - Shell-form CMD. Signals never reach your process, so
docker stopwaits 10 seconds then SIGKILLs. - Bloated caches. npm, pip, and apt caches should never appear in a final image.
- Secrets in ENV. Anyone who pulls the image sees them. Use build secrets or runtime env vars.
Master these six steps and your Dockerfiles will be small, fast, and safe to run in production.
" }Featured Tools
Try these free tools directly in your browser — no sign-up required.
YAML to JSON
Convert YAML to JSON and JSON to YAML instantly. Essential for working with Kubernetes configs, Docker Compose files, CI/CD pipelines, and APIs.
JSON Formatter
Format, beautify, and validate JSON instantly. Paste raw JSON and get a clean, indented, human-readable output with syntax error detection.