Skip to main content

๐Ÿ“ 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 build turns 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 .dockerignore file and pin base-image versions for reproducible builds
  • Run a container as a non-root USER and distinguish CMD from ENTRYPOINT
  • 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.

graph LR A["Dockerfile (recipe)"] -->|docker build| B["Image (frozen snapshot)"] B -->|docker run| C["Container (running process)"]

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:

InstructionWhat it does
FROMChooses the base image to build on โ€” the foundation of your house
WORKDIRSets (and creates) the working directory for later instructions
COPYCopies files from your project into the image
RUNExecutes a command at build time, baking the result into a layer
EXPOSEDocuments which port the container listens on (does not publish it)
CMDThe 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.

A Docker image drawn as stacked layers, from the base image at the bottom to the start command at the top FROM node:20-alpine ยท base image WORKDIR /app COPY package*.json ./ RUN npm ci ยท dependencies COPY . . ยท source code CMD ["node","server.js"]
Each instruction adds a layer. Layers lower in the stack change rarely (the base OS); layers near the top change every time you edit code.

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)
graph TD A["Container starts"] --> B{"ENTRYPOINT set?"} B -->|Yes| C["Run ENTRYPOINT"] B -->|No| D["Run CMD as the command"] C --> E{"CMD set?"} E -->|Yes| F["Append CMD as arguments"] E -->|No| G["Run ENTRYPOINT alone"]

๐Ÿ“– 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 from package-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), never latest
  • Copy package*.json and install deps before copying source
  • Use npm ci for reproducible, lockfile-driven installs; add --omit=dev for production
  • Add a .dockerignore excluding node_modules, .git, and .env
  • Combine related RUN commands and clean caches in the same step
  • Run as a non-root USER and prefer exec-form CMD/ENTRYPOINT

โŒ Don't

  • Put secrets in ENV or ARG โ€” they persist in docker history
  • Run one RUN apt-get per line โ€” every line is a permanent layer
  • Use ADD when COPY will do
  • Copy your host's node_modules into 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 build turns 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
  • ENTRYPOINT is the executable, CMD is the default args โ€” prefer exec form
  • Pin versions, add a .dockerignore, drop to a non-root USER, and keep secrets out of layers

๐Ÿ“š Additional Resources

๐Ÿš€ 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.