2.1 · Writing your first Dockerfile

Level 2 · Dockerfile & build

2.1Writing your first Dockerfile

Goal: Write a minimal Dockerfile that packages an application and runs it.
Estimated time: 14 min · self-paced reading · If you only have 5 min: read “The essentials in 30s” + the diagram.

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.

Common misinterpretation: "RUN and CMD are the same thing: both launch commands". No. RUN runs during the build and freezes a layer (installing, compiling); CMD runs when the container starts (launching the server). Idir's rule: everything preparing the image → RUN; the command bringing the container to life → CMD, in JSON form.
From code to a running app Base, code, installation at build time, launch at run time FROM + COPY Official baseand code copied inRUN (at build time) Install,freeze a layerCMD (at run time) Start theserver on launch
Base, code, installation at build time, launch at run time
Text version of the diagram (SEO / screen readers / translation): 1) Idir SETS the base: FROM on a lightweight official image and WORKDIR as working directory, then COPY to drop the code there from the context. 2) He BAKES: RUN installs the dependencies during the build (frozen layer), docker build -t mon-app:1.0 freezes the image. 3) He LAUNCHES: CMD in JSON form starts the server on docker run, published port. Lesson: RUN builds, CMD starts — two beats, two roles.

🔎 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

  1. Create a folder with a server.js answering "OK" on port 3000.
  2. Write the Dockerfile (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD) at the project root.
  3. Build as mon-app:1.0 and read each build step.
  4. Run with -p 3000:3000 and open localhost:3000.
You turn any code into a runnable image: build here, run anywhere.

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.

Section exit checklist
  • 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.
Self-assessment 1-5: do I place each instruction at the right time (build vs run)?does my image run with no rework on another machine?

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.

Check your understanding

In a Dockerfile, what does RUN do compared to CMD?

Additional resources (optional, to go further)

Level 2 examFresh, harder questions to test what you've learned