2.1Writing your first Dockerfile
The essentials in 30 seconds
- 5 instructions = 1 runnable image: FROM, WORKDIR, COPY, RUN, CMD — each has a distinct role.
- Build bakes, run executes: the build freezes the layers, the run starts the container — read the last step if it fails.
- RUN builds, CMD starts: install at build time, launch at run time — never the server inside RUN.
Running mini-case — Idir and the app that would not travel
Idir, junior DevOps in Algiers, must deliver his team's small Node app on another machine: "it works on my machine" is no longer enough. He writes his first Dockerfile at the project root: official base, working directory, code copied in, installation at build time, start command. He builds it as mon-app:1.0 then runs it with the published port: the app answers on localhost:3000, on any machine. This app "packaged by Idir" returns in 2.2 (faster build and leaner image), 2.3 (tagging and publishing), 2.4 (configurable port) and 2.5 (hardened non-root version).
Thread of level 2: Idir's app returns in the lesson, the diagram, the script and the 48h challenge — and continues in 2.2, 2.3, 2.4 and 2.5.
1. The 5 vital instructions
FROM picks the base (for example node:20-slim, a lightweight official image), WORKDIR sets the internal working directory (created if missing), COPY drops the code there from the context, RUN installs during the build (each RUN freezes a layer), CMD defines the container's start command. Five lines are enough for a first service that runs. Idir writes them in this order: base, folder, code, installation, launch.
2. Build then run: the two beats
docker build -t mon-app:1.0 . bakes the image: the trailing dot designates the context, that is the folder sent to the daemon for the build. docker run executes it: the container starts and publishes its ports. If the build fails, read the last displayed step: that is the one to blame (missing dependency, typo, network cut). Idir names his image name:version from the build on to keep track.
3. CMD vs RUN: no more confusion
RUN runs during the build (installing, compiling) and freezes a layer in the image. CMD runs when the container starts (launching the server), on every run. Putting the launch in RUN freezes a build that never starts: the classic beginner mistake. Write CMD in JSON form (["node", "server.js"]), a single start command. Idir installs with RUN and starts with CMD: build first, launch after, never the other way round.
🔎 Concrete example — 🔎 Concrete example — A packaged Node app
Idir's app, from code to runnable image:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t mon-app:1.0 .
docker run -d --name app -p 3000:3000 mon-app:1.0
Reading: dependencies install at build time (RUN), the server starts at run time (CMD), the port is declared (EXPOSE) and published (-p).
Lesson: copying package*.json first already prepares the cache optimization (2.2).
Section vocabulary
- FROM
- The Dockerfile's base image, the starting point of all layers (preferably a lightweight official image).
Simple example: Idir starts from node:20-slim for his Node app.
What it is not: the command launching the container.
Reflex: lightweight official base first.
- WORKDIR
- The container's internal working directory, created automatically if missing; every following instruction relates to it.
Simple example: Idir sets WORKDIR /app before copying his code.
What it is not: a volume persisting data.
Reflex: set the folder before copying.
- RUN
- Running a command DURING the build (installing, compiling); each RUN freezes a layer in the image.
Simple example: Idir installs his dependencies with RUN npm ci.
What it is not: the container's start command.
Reflex: preparing the image = RUN.
- CMD
- The container's START command, run on every docker run; a single one, preferably in JSON form.
Simple example: Idir starts with CMD ["node", "server.js"].
What it is not: an installation frozen at build time.
Reflex: bringing the container to life = CMD.
In practice — In practice — Packaging a mini-app
- Create a folder with a server.js answering "OK" on port 3000.
- Write the Dockerfile (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD) at the project root.
- Build as mon-app:1.0 and read each build step.
- Run with -p 3000:3000 and open localhost:3000.
Script ready to say — explaining the Dockerfile to a colleague (30 s)
"This Dockerfile does five things: it starts from an official base, sets the working directory, copies the code, installs at build time with RUN, and starts with CMD. We bake with docker build, we launch with docker run: the app runs the same on every machine. RUN builds, CMD starts — that is the only distinction to remember."
Say while showing Idir's Dockerfile then the build step by step.
Copy-paste template — first Node Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Python app
Idir packages a Python script: FROM python:3.12-slim, COPY requirements then RUN pip install, CMD in JSON form.
Static page
A showcase site: Idir starts from a lightweight server base, copies the files, exposes the port, CMD starts the server.
Huge context
Slow build: Idir adds a .dockerignore (node_modules, .git) and the context becomes light again.
Trapped RUN
A colleague launches the server inside RUN: the build freezes and never starts — Idir moves the launch to CMD.
✓ Best practices
- A single start command in CMD (JSON form).
- Name the image name:version from the build on.
- Read the build step by step on failure.
✗ Common pitfalls
- Launching the server in RUN instead of CMD.
- Forgetting the .dockerignore (huge context).
- Copying local node_modules into the image.
Key takeaways
- FROM, WORKDIR, COPY, RUN, CMD: the foundation of every Dockerfile.
- Build bakes the image, run executes it (the "." is the context sent to the daemon).
- RUN = build (freezes a layer), CMD = start (JSON form).
- Name name:version from the build on; read the last step on failure.
48h challenge — apply tomorrow
Package a real mini-app (yours or Idir's): a 7-line Dockerfile (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD), a build tagged 1.0 read step by step, then a run with published port checked in the browser.
- I can write a minimal Dockerfile (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD).
- I can build (context understood) and read a failure at the last step.
- I tell RUN (build) from CMD (start) apart without hesitation.
Frequently asked questions
Where do I write the Dockerfile?
At the project root, named exactly "Dockerfile". The context is the folder sent to the daemon: stay at the root for COPY . . and slim it down with a .dockerignore (node_modules, .git) so you do not send hundreds of MB on every build.
Why is the build so slow?
Often a huge context (node_modules, .git) sent every time. The .dockerignore and the layer order (dependencies first, code after — see 2.2) fix 80% of cases before any other optimization.
In a Dockerfile, what does RUN do compared to CMD?