๐ Dockerfile Syntax & Best Practices
A running container has to come from somewhere. That somewhere is an image, and an image is baked from a plain text file called a Dockerfile. Learn to write one well and you turn "works on my machine" into "works on every machine" โ reproducibly, and in seconds.
Week 11 · Day 2 (Tuesday: Creating Docker Images) · Lecture 2
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a Dockerfile is and how
docker buildturns it into an image - Use the core instructions โ
FROM,WORKDIR,COPY,RUN,ENV,EXPOSE,CMD,ENTRYPOINTโ correctly - Describe how images are built from stacked, cached layers and order instructions to exploit the cache
- Write a
.dockerignorefile and pin base-image versions for reproducible builds - Run a container as a non-root
USERand distinguishCMDfromENTRYPOINT - Author a production-quality Dockerfile for a real Node.js app
Estimated Time: 70 minutes
Practice: Rewrite a naive Node Dockerfile into a cached, slim, non-root image.
In This Lesson
What Is a Dockerfile?
In the previous lessons you pulled ready-made images and ran them. Now you'll build your own. A Dockerfile is a text file โ literally named Dockerfile, no extension โ containing an ordered list of instructions that Docker follows to assemble an image.
Think of it as a recipe. A cake recipe names the starting ingredients, the things you add, the steps you run, and how to serve the result. A Dockerfile names a base image, the files to add, the commands to run during the build, and the command to run when the container starts. Because the recipe is text, you commit it to git โ so every teammate and every CI server bakes the exact same image.
Why bother learning the syntax carefully?
- Reproducibility โ the same Dockerfile produces the same image on any host, ending "but it works locally."
- Speed โ Docker caches unchanged steps, so rebuilds after a code edit take seconds, not minutes.
- Size & security โ good choices shrink a 1 GB image to ~120 MB and cut the attack surface.
- Documentation โ the file is the runtime spec, version-controlled alongside your code.
Anatomy of a Dockerfile
Here is a complete, working Dockerfile for a Node.js app. Read it top to bottom โ each line is a step in the recipe.
# Start from an official Node image, pinned to a specific version
FROM node:20.11-alpine
# All following commands run inside /app in the image
WORKDIR /app
# Copy dependency manifests FIRST (they change less often than source)
COPY package*.json ./
# Install exactly what package-lock.json specifies
RUN npm ci
# Now copy the rest of the source code
COPY . .
# Document the port the app listens on (metadata only)
EXPOSE 3000
# The command that runs when a container starts
CMD ["node", "server.js"]
Line by line:
| Instruction | What it does |
|---|---|
FROM | Chooses the base image to build on โ the foundation of your house |
WORKDIR | Sets (and creates) the working directory for later instructions |
COPY | Copies files from your project into the image |
RUN | Executes a command at build time, baking the result into a layer |
EXPOSE | Documents which port the container listens on (does not publish it) |
CMD | The default command to run when the container starts |
Building the image
Once the file exists, turn it into an image with docker build:
# Build an image tagged myapp:1.0 from the Dockerfile in the current directory
docker build -t myapp:1.0 .
# Build from a differently named Dockerfile
docker build -f Dockerfile.prod -t myapp:prod .
# Pass a build-time argument
docker build --build-arg NODE_ENV=production -t myapp:1.0 .
The -t flag tags the image (name:version). The trailing . is the build context โ the directory Docker sends to the engine and copies files from. Keep it small; we'll trim it with .dockerignore shortly.
Images Are Stacks of Layers
Every instruction that changes the filesystem (FROM, COPY, RUN) adds a new read-only layer on top of the previous ones. The final image is those layers stacked together. This is the single most important mental model for writing good Dockerfiles.
Two consequences flow from this design, and they drive every optimization in this lesson:
- Layers are cached. If nothing an instruction depends on has changed, Docker reuses the cached layer instead of rebuilding it.
- A cache miss cascades. The moment one layer must be rebuilt, every layer above it is rebuilt too. So put stable things low and volatile things high.
The Core Instructions
FROM โ the foundation
Every Dockerfile starts with FROM, naming the base image you build on.
FROM node:latest # โ moving target โ "latest" changes under you
FROM node:20 # โ ๏ธ better, but still drifts across minor versions
FROM node:20.11-alpine # โ
pinned + small: reproducible and lightweight
Best practice: pin a specific version and prefer a slim variant (-alpine or -slim). latest silently changes, breaking reproducibility and inviting surprise bugs.
WORKDIR โ set your workspace
WORKDIR /app
# Every later RUN / COPY / CMD now runs relative to /app.
# WORKDIR creates the directory if it does not exist.
Always use an absolute path, and use a dedicated app directory rather than a system folder like / or /root. Prefer WORKDIR over chaining cd inside RUN โ cd does not persist across instructions.
COPY (and ADD)
COPY package*.json ./ # copy both package.json and package-lock.json
COPY src/ ./src/ # copy a directory
COPY --chown=node:node . . # copy and set ownership to the node user
COPY transfers files from the build context into the image. ADD can also unpack local tar archives and fetch URLs โ but those extras cause surprises, so prefer COPY and reach for ADD only when you specifically need archive extraction.
RUN โ execute during the build
# One layer, cache cleaned in the same step โ good
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Each RUN adds a layer. Combine related shell commands with && so a temporary file created and deleted in the same instruction never bloats a layer. On Alpine the equivalent is apk add --no-cache.
ENV โ runtime environment variables
ENV NODE_ENV=production
ENV PORT=3000 LOG_LEVEL=info # group related variables
Values set with ENV persist into running containers. Use them for configuration that varies between environments. Never put secrets in ENV โ they are visible in docker history and image metadata.
EXPOSE โ document the port
EXPOSE 3000
โ ๏ธ EXPOSE is documentation, not publishing
EXPOSE 3000 records intent; it does not open the port to your host. You still need docker run -p 8080:3000 to map a host port to the container port.
USER โ drop root
# The official node image ships a ready-made non-root "node" user
USER node
# Everything after this runs as node, not root
By default containers run as root, which is risky if the process is ever compromised. Switch to a non-root user as early as the app allows. The official Node image already includes a node user for exactly this.
CMD vs ENTRYPOINT
Both declare what runs when the container starts, and beginners constantly confuse them. Here is the clean rule: ENTRYPOINT is the executable; CMD is the default arguments.
# CMD alone: the whole default command, easily overridden at run time
CMD ["node", "server.js"]
# ENTRYPOINT + CMD: fixed program, swappable default args
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run myapp -> node server.js
# docker run myapp worker.js -> node worker.js (CMD replaced)
๐ Always prefer exec form
Write CMD ["node", "server.js"] (JSON array, "exec form") rather than CMD node server.js ("shell form"). Exec form runs your process as PID 1 directly, so it receives SIGTERM and shuts down cleanly. Shell form wraps it in /bin/sh -c, which swallows signals and leaves containers hanging on stop.
Layer Caching & .dockerignore
Now we cash in on the layer model. The classic mistake is copying everything before installing dependencies:
# โ Bad: any source edit invalidates the cache and reinstalls everything
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
Because COPY . . comes first, changing a single line of source busts its layer โ and the expensive npm ci layer above it is rebuilt every time. Flip the order:
# โ
Good: dependencies cached until package files actually change
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # changes rarely
RUN npm ci # cached unless the manifests change
COPY . . # changes often, but that's fine โ it's last
CMD ["node", "server.js"]
Now a routine code edit only rebuilds the final COPY . . layer. Dependency installs โ often the slowest step โ stay cached until you actually add or update a package.
The .dockerignore file
The build context (that trailing .) is uploaded to the Docker engine. A .dockerignore file, sibling to your Dockerfile, keeps junk out of it โ just like .gitignore for git.
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
coverage
dist
build
*.md
Excluding node_modules matters most: you rebuild dependencies inside the image with npm ci, so copying your host's platform-specific node_modules is both wasteful and a source of "works locally, breaks in the container" bugs. Excluding .env keeps secrets out of the image entirely.
A Real Node.js Dockerfile
Putting every best practice together, here is a production-grade Dockerfile for an Express API. It pins its base, orders layers for caching, installs only production dependencies, and runs as a non-root user.
# --- Pinned, slim base image ---
FROM node:20.11-alpine
# Set NODE_ENV so npm skips devDependencies and libraries run in prod mode
ENV NODE_ENV=production
WORKDIR /app
# 1) Copy manifests first for maximum cache reuse
COPY package*.json ./
# 2) Install ONLY production deps, reproducibly, from the lockfile
RUN npm ci --omit=dev && npm cache clean --force
# 3) Copy application source (owned by the built-in non-root node user)
COPY --chown=node:node . .
# 4) Drop root privileges before running the app
USER node
# Document the listening port
EXPOSE 3000
# Exec form so Node is PID 1 and handles SIGTERM cleanly
CMD ["node", "server.js"]
โ Why each choice matters
node:20.11-alpineโ pinned and tiny (~50 MB base vs ~1 GB for the full image).npm ci --omit=devโ deterministic install frompackage-lock.json, no test/build tooling.- Manifests copied before source โ the dependency layer stays cached across code edits.
USER nodeโ the process cannot write outside its files or run as root if breached.
Output
$ docker build -t api:1.0 .
$ docker images api
REPOSITORY TAG SIZE
api 1.0 142MB # vs ~1.1GB with FROM node:20 and COPY . . first
Practice & Quiz
๐๏ธ Exercise 1: Fix the caching
Goal: This Dockerfile reinstalls all dependencies on every source change. Reorder it so npm ci is cached until the package files change.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
EXPOSE 3000
CMD ["node", "server.js"]
๐ก Hint
Copy only package*.json before RUN npm ci, then COPY . . for the source after the install.
โ Solution
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # copy manifests first
RUN npm ci # cached until manifests change
COPY . . # source copied last
EXPOSE 3000
CMD ["node", "server.js"]
๐๏ธ Exercise 2: Harden it
Goal: Take your fixed file from Exercise 1 and make it production-ready: pin the base version, install only production deps, and run as a non-root user.
โ Solution
FROM node:20.11-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Pair it with a .dockerignore that excludes node_modules, .git, and .env.
๐ฏ Quick Quiz
Question 1: Why copy package*.json before the rest of the source?
Question 2: What does EXPOSE 3000 actually do?
Question 3: Which is the recommended base for a small, reproducible production image?
Best Practices & Pitfalls
โ Do
- Pin base image versions (
node:20.11-alpine), neverlatest - Copy
package*.jsonand install deps before copying source - Use
npm cifor reproducible, lockfile-driven installs; add--omit=devfor production - Add a
.dockerignoreexcludingnode_modules,.git, and.env - Combine related
RUNcommands and clean caches in the same step - Run as a non-root
USERand prefer exec-formCMD/ENTRYPOINT
โ Don't
- Put secrets in
ENVorARGโ they persist indocker history - Run one
RUN apt-getper line โ every line is a permanent layer - Use
ADDwhenCOPYwill do - Copy your host's
node_modulesinto the image
โ ๏ธ Secrets leak through image history
# Anyone with the image can read this:
docker history --no-trunc myapp:1.0
# ENV API_KEY=super-secret โ visible forever
Pass secrets at run time (docker run -e / a secrets manager) or use BuildKit --mount=type=secret, never bake them into a layer.
Summary
๐ Key Takeaways
- A Dockerfile is a version-controlled recipe that
docker buildturns into an image - Images are stacks of cached layers; a cache miss rebuilds every layer above it
- Order matters: copy manifests and install deps before copying source
ENTRYPOINTis the executable,CMDis the default args โ prefer exec form- Pin versions, add a
.dockerignore, drop to a non-rootUSER, and keep secrets out of layers
๐ Additional Resources
- Docker Docs โ Dockerfile reference
- Docker Docs โ Building best practices
- Docker Docs โ .dockerignore files
๐ What's Next?
Your image is clean and cached โ but it still ships the tools used to build it. The next lesson, Multi-stage builds, shows how to compile in one stage and copy only the finished artifacts into a slim runtime stage, cutting image size dramatically.
๐ You can build images now!
From here on, every deployment starts with a Dockerfile you understand line by line.