Skip to content
7 min read·Lesson 5 of 10

Writing Effective Dockerfiles

Learn the Dockerfile syntax — FROM, RUN, COPY, CMD, ENTRYPOINT — and write images that build fast, stay small, and behave well at runtime.

You can build an image by running commands inside a container and committing the result, but nobody does that. The standard approach is a Dockerfile — a text file that describes how to assemble an image, line by line.

A Minimal Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Build and run it:

docker build -t myapp:1.0 .
docker run -p 3000:3000 myapp:1.0

The Core Instructions

InstructionPurpose
FROM image:tagBase image to start from. Required, must be the first non-comment line.
WORKDIR /pathSet the working directory; created if missing.
COPY src dstCopy files from the build context into the image.
ADD src dstLike COPY but also extracts tarballs and supports URLs. Prefer COPY.
RUN cmdExecute a command at build time and bake the result into a layer.
ENV KEY=VALUESet an environment variable in the image (and at runtime).
ARG NAME[=DEFAULT]Build-time variable, available only during build. Pass with --build-arg.
EXPOSE portDocuments the port the container listens on (does not publish it).
USER nameSwitch to a non-root user for subsequent instructions and runtime.
CMD ["cmd", "arg"]Default command if none is given to docker run.
ENTRYPOINT ["cmd"]Sets the executable. CMD becomes its default arguments.
HEALTHCHECKHow Docker probes whether the container is healthy.

The Build Cache

Each instruction produces a layer. When you rebuild, Docker checks each instruction in order: if the inputs haven't changed since last build, it reuses the cached layer. The first changed instruction and everything below it rebuilds.

This is why you order Dockerfiles like the example above: copy the dependency manifest first, install dependencies, then copy your source code. Editing application code does not bust the slow npm ci layer.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./       # changes rarely
RUN npm ci --omit=dev       # slow — cached as long as package.json is unchanged
COPY . .                    # changes on every commit
CMD ["node", "server.js"]

CMD vs ENTRYPOINT

This trips up almost everyone. The two interact:

  • CMD only: The arguments to docker run replace CMD entirely. docker run myapp ls runs ls instead of the default.
  • ENTRYPOINT only: Arguments to docker run are appended to ENTRYPOINT. Use this when you want the image to behave like a single command.
  • Both: ENTRYPOINT is the executable, CMD is the default args, easily overridden.
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]
# docker run myapp                 → python app.py --port 8000
# docker run myapp --port 9000     → python app.py --port 9000

Always use the exec form (JSON array) — not the shell form. Shell form wraps the command in /bin/sh -c, which breaks signal handling (SIGTERM is swallowed by the shell, not delivered to your app).

.dockerignore

The build context (the directory you point docker build at) is sent to the daemon. Without filtering, a 2 GB node_modules or .git folder gets uploaded on every build. Use .dockerignore just like .gitignore:

node_modules
.git
.env
*.log
dist
.next/cache

Best Practices

  1. Pin base images: node:20.11-alpine, not node:latest.
  2. Use minimal bases: Alpine, distroless, or -slim variants. A 50 MB image starts faster, ships faster, and has a smaller attack surface than a 1 GB one.
  3. Combine related RUN commands with && and clean up in the same layer (so the cleanup is also in the same layer):
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
  1. Run as non-root:
    RUN useradd -m app
    USER app
  2. Use HEALTHCHECK so orchestrators know when your container is ready.
  3. Tag deliberately: include a version (1.2.3) and consider adding latest only after release verification.
  4. Scan images: docker scout cves, Trivy, Snyk, or your registry's built-in scanner — a missing patch in a base image becomes your problem.

The next lesson covers multi-stage builds, which let you compile your app in one image and ship a tiny runtime image — often shrinking final images by 90% or more.

Key Takeaways

  • A Dockerfile is a text recipe; each instruction creates one image layer.
  • Order instructions from least to most frequently changing for best build cache hits.
  • Use specific base image tags, run as a non-root user, and minimise installed packages.
  • CMD provides default arguments; ENTRYPOINT defines the executable.
  • docker build -t name:tag . builds an image; .dockerignore excludes files from the build context.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →