A Dockerfile is the recipe that tells Docker how to build an image. For beginners it can feel like magic: one file produces a reproducible container image. This article walks through a simple, practical Dockerfile line by line, explains common pitfalls, and provides actionable tips so you can write efficient, maintainable Dockerfiles.
Minimal example (context)
Below is a compact example you can adapt for a Node.js app. Place it at the project root alongside package.json and your app source.
# syntax=docker/dockerfile:1
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
ENV NODE_ENV=production
USER node
CMD ["node", "index.js"]
Line-by-line explanation
# syntax=…
- Optional directive to select Dockerfile frontend/buildkit features. Useful when you need BuildKit-specific syntax (like secrets, mount=type=cache). Place it as the first line.
FROM node:18-alpine AS base
- FROM selects the base image. Here we use the official Node.js 18 variant on Alpine (small).
- The AS base part creates a named stage; helpful for multi-stage builds. Single-stage builds can omit AS.
- Best practice: pin major (or full) versions to avoid surprise upgrades: e.g., node:18.17.1-alpine.
WORKDIR /app
- Sets the working directory for subsequent instructions and runtime defaults. If the directory doesn’t exist, Docker creates it.
- Avoid using absolute paths in COPY later; rely on WORKDIR for clarity.
COPY package*.json ./
- Copies package.json and package-lock.json (if present) into the image. Copying dependency manifests separately enables Docker layer caching for npm install.
- Prefer precise globs to avoid copying unintended files.
RUN npm ci –only=production
- RUN executes a command in the image and creates a new layer.
- npm ci is deterministic and faster in CI contexts; –only=production avoids devDependencies in production images.
- Combine related RUN steps when appropriate to reduce layers and keep the image small. Example: RUN apk add –no-cache git && npm ci.
COPY . .
- Adds your application source into the image.
- Use .dockerignore to exclude node_modules, logs, local config, and build artifacts. This keeps build context small and prevents leaking secrets.
EXPOSE 3000
- Metadata declaring the port your app listens on. EXPOSE doesn’t publish the port to the host by itself — you still need docker run -p or container orchestration port mappings.
ENV NODE_ENV=production
- Sets environment variables baked into the image. ENV is persisted in the image and used at runtime unless overridden.
- For secrets use runtime mechanisms (secrets manager, environment variables at deploy time) — don’t bake secrets into images.
USER node
- Switches user for subsequent instructions and runtime. Running as non-root is a security best practice.
- Ensure files/dirs you need are owned or accessible by that user. You may need chown during build for files created as root.
CMD [“node”, “index.js”]
- Default command executed when the container starts. Use exec form (JSON array) to avoid shell interpretation.
- Distinguish CMD vs ENTRYPOINT:
- ENTRYPOINT sets the executable; CMD provides default arguments. If you want the container to behave like a specific binary (but allow args injection), combine ENTRYPOINT + CMD.
Important instructions not in the minimal example
ADD vs COPY
- COPY is simple and explicit: copies files from context. Prefer it.
- ADD can unpack local tar archives and fetch remote URLs. Avoid ADD unless you need those specific behaviors.
ARG vs ENV
- ARG values exist only at build-time and are not persisted in final image metadata.
- ENV variables persist into the image and are available at runtime.
- Use ARG for build-time configuration (e.g., build-time tokens — still avoid secrets), and ENV for runtime config defaults.
Multi-stage builds
- Useful to separate build environment and runtime image. Build heavy toolchains in one stage, copy only final artifacts to a slim runtime stage.
- Example pattern: builder stage runs npm run build, final stage copies dist/ and installs only production deps.
Caching and layer strategy
- Docker caches layers by instruction. Place frequently changing steps (like COPY . .) after low-change steps (like copying package.json + installing deps) to reuse cached layers.
- Combine commands with && and clear caches when appropriate (e.g., apk add and rm caches in same RUN) to reduce image size.
HEALTHCHECK
- Add HEALTHCHECK to let orchestrators know when your app is healthy. Eg: HEALTHCHECK –interval=30s CMD curl -f http://localhost:3000/health || exit 1
LABEL
- Use LABEL to store metadata (maintainer, version, source). Eg: LABEL maintainer=”you@example.com” version=”1.0″
Practical best practices checklist
- Use .dockerignore to reduce build context.
- Pin base image versions.
- Prefer smaller base images (alpine, distroless) but measure compatibility and image size.
- Run as non-root when possible.
- Keep image layers minimal and leverage caching by ordering instructions.
- Don’t bake secrets into images; use environment variables, secret managers, or BuildKit secrets.
- Test image locally with docker run -p 3000:3000 IMAGE and inspect with docker history to see layers.
Troubleshooting tips
- If rebuilds seem slow, ensure .dockerignore excludes large folders and node_modules.
- Use docker build –no-cache . only when you need a fresh build; otherwise rely on cache.
- Inspect the image: docker run –rm -it –entrypoint sh IMAGE to poke around.
- If permissions cause problems after switching USER, add appropriate chown steps during the build.
Final notes
Start small and iterate: begin with a straightforward Dockerfile, then profile and optimize for size and build speed as needed. The most critical wins come from using .dockerignore correctly, ordering COPY/RUN for cache-friendliness, and separating build/runtime with multi-stage builds.
