In the previous lesson you built auroralibros/aurora-api:0.1.0 with a Dockerfile that was handed to you ready-made and explained only in passing. It works, it starts the API inside a container and it responds to curl. But you wrote it almost blind: you know what each line does, not why it is written exactly that way. This lesson settles that debt. You are going to go through the Dockerfile language instruction by instruction, at the level of detail left pending in 02-02: why WORKDIR is better than RUN cd, how COPY and ADD differ and why COPY almost always wins, what exactly the two forms of RUN and CMD mean, why chaining commands with && produces smaller images, why EXPOSE does not expose anything, and what happens to your container when you stop it depending on how you wrote the CMD. At the end you will build the definitive aurora-api Dockerfile, commented line by line and with every decision justified.
Contents
- The structure of a Dockerfile, comments and the
# syntaxdirective - Summary table of the basic instructions
FROM: where you start fromWORKDIR: where you workCOPY: what you bring in from the contextCOPYversusADDRUN: what you execute during the buildENV: configuration that survives the buildEXPOSE: documentation, not publishingCMD: which process the container starts- The definitive
aurora-apiDockerfile
- The structure of a Dockerfile, comments and the
# syntax directive
# syntax directiveA Dockerfile is a plain text file, with no extension, with one instruction per line:
# syntax=docker/dockerfile:1
# Comment: BuildKit ignores it entirely
FROM node:22-alpine
WORKDIR /app
RUN echo "one instruction" && \
echo "can continue on the next line"
CMD ["node", "server.js"]Syntax rules:
- Instructions are written in UPPERCASE by convention.
from node:22-alpineworks just the same, but nobody writes it that way: in uppercase you can tell the instruction from its arguments at a glance. - One instruction per line. To split a long line you use a backslash
\at the end, with nothing after it (not even a space: a space after the backslash reverses its effect and produces baffling errors). - Lines starting with
#are comments, except for the directives in the next section. As you verified in exercise 2 of lesson 02-02, comments do not affect the cache. - Order matters, and not only because of the cache: each instruction runs on the state left behind by the previous one.
- Blank lines are ignored. Use them to group logical blocks; a well-spaced Dockerfile reads enormously better.
The # syntax directive
The first line of the file deserves a section of its own:
It is not a comment: it is a frontend directive. It tells BuildKit which version of the Dockerfile frontend to use when interpreting the rest of the file. BuildKit pulls that image from the registry and uses it as the parser.
Why it is worth always including it:
- It gives you the most recent features without updating Docker.
docker/dockerfile:1is a moving tag that points to the latest stable release of the 1.x series. Things likeCOPY --link, cache mounts or build secrets (lesson 05-05) depend on it. - It makes the file portable. The same Dockerfile is interpreted identically on your laptop and on a CI agent with another version of Docker.
- It is backward compatible. The
1series guarantees it will not break existing Dockerfiles.
The variants you will see:
| Directive | What you get |
|---|---|
# syntax=docker/dockerfile:1 |
Latest stable of the 1.x series. The recommended one |
# syntax=docker/dockerfile:1.12 |
Pinned to a specific minor version, for maximum reproducibility |
| (no directive) | Whatever frontend ships with your Docker Engine version. It works, but you lose features |
It must be the first line of the file (before any instruction, even before other comments) for it to take effect.
- Summary table of the basic instructions
These are the instructions this lesson covers, with the essentials of each one:
| Instruction | What it does | Creates a layer? | When does it act? |
|---|---|---|---|
FROM |
Sets the base image | Inherits the base's | Build |
WORKDIR |
Sets the working directory | Yes (metadata, size ~0) | Build and runtime |
COPY |
Copies files from the context into the image | Yes | Build |
ADD |
Like COPY, plus URLs and automatic extraction |
Yes | Build |
RUN |
Runs a command and freezes the result | Yes | Build |
ENV |
Defines environment variables | Yes (metadata) | Build and runtime |
EXPOSE |
Documents the port the service uses | Yes (metadata) | Documentation only |
CMD |
Defines the container's default process | Yes (metadata) | Runtime |
The "Creates a layer?" column explains an observation from lesson 01-05: not every instruction fattens the image. COPY, ADD and RUN modify the filesystem and generate layers with real content; the rest only write metadata into the manifest, and their layer weighs zero bytes.
The "When does it act?" column is the one that prevents the most confusion. RUN executes at build time and its result stays frozen; CMD does not execute at build time at all, it only describes what the container will do when it starts. Confusing them is beginners' number one conceptual mistake.
There are more instructions — ARG, ENTRYPOINT, USER, LABEL, HEALTHCHECK, VOLUME, STOPSIGNAL, ONBUILD, SHELL — and they are all covered in the next lesson, 02-04.
FROM: where you start from
FROM: where you start fromFROM is mandatory and must be the first instruction (directives and comments aside). It sets the starting filesystem and metadata: everything that comes after is built on top of those layers.
Full syntax:
Forms you will see, applied to Aurora Libros:
FROM node # Dangerous: latest, unpredictable version
FROM node:22 # Better: pins the major, but the image is ~1.1 GB
FROM node:22-alpine # The chosen one: lightweight and with the major pinned
FROM node:22.14-alpine3.21 # Maximum control: pins the minor and the Alpine version
FROM node:22-alpine@sha256:9f2c1a... # Absolute reproducibility: immutable digestThe trade-off between reproducibility and maintenance is real and has no single answer:
| Form | Reproducibility | Security patches | Recommended for |
|---|---|---|---|
node (= latest) |
None | Automatic, but it can jump major version without warning | Never |
node:22 |
Medium | Automatic within major 22 | Development |
node:22-alpine |
Medium | Automatic within major 22 | Aurora Libros: a good balance |
node:22.14-alpine3.21 |
High | Manual | Regulated environments |
@sha256:… |
Total | Manual | Critical production, audited supply chain |
Aurora Libros uses node:22-alpine, decided in lesson 01-07 for three reasons you can now fully justify: it satisfies the engines: node >=22.0.0 in package.json, it is an official image (the library/ namespace, the first trust criterion from lesson 02-01) and it weighs ~142 MB against the ~1.1 GB of node:22. The trade-off is that Alpine uses musl instead of glibc as its C library; the project's three dependencies (express, pg, redis) are pure JavaScript or ship compatible binaries, so it is not a problem.
The AS <name> clause names a build stage. It is used for multi-stage builds, the technique that lets you compile in one image and carry only the result over to a much smaller one. It is studied in lesson 05-04; make a mental note and move on.
WORKDIR: where you work
WORKDIR: where you workWORKDIR sets the working directory for all subsequent instructions that use one: RUN, COPY, ADD and CMD. If it does not exist, it creates it, including intermediate directories.
Why not RUN cd
It is the inevitable question. Compare:
In the first case, npm ci runs in /, not in /app, and fails with ENOENT: no such file or directory, open '/package.json'. The reason is fundamental: every RUN executes in a fresh temporary container. The cd changes the shell's directory in that ephemeral container, which dies as soon as the instruction ends; the next RUN starts from scratch, in the working directory inherited from the image.
The only way for cd to be useful is to chain it inside the same RUN:
Even so, WORKDIR wins for four reasons:
- It persists at runtime. It is the directory you land in with
docker exec -it aurora-api shand where theCMDruns. Acdinside aRUNleaves no trace. - It creates the directory if it does not exist, with no need for
mkdir -p. - It is visible metadata.
docker image inspecttells you what it is; acdburied in aRUNhas to be hunted down. - It reads better. It declares the intent instead of hiding it inside a chain of commands.
Additional details:
WORKDIR /app # Absolute: always preferable
WORKDIR api # Relative to the previous one: ends up as /app/api
WORKDIR $DIRECTORY # Accepts variables defined with ENV or ARGAlways use absolute paths. Chained relative ones force you to keep mental track of where you are and are a classic source of errors in long Dockerfiles.
For Aurora Libros, /app is the usual convention in Node images. Any path would do (/srv/aurora, /usr/src/app), but /app is short, unambiguous and does not collide with Alpine's filesystem.
COPY: what you bring in from the context
COPY: what you bring in from the contextCOPY brings files and directories from the build context into the image's filesystem.
Rules you need to be clear about:
- The source is always relative to the root of the context, never to your current directory nor to the Dockerfile's location. And it can never leave the context: that is the restriction that caused the
"/db/init.sql": not founderror in lesson 02-02. - The destination is relative to the
WORKDIRif it is not absolute. WithWORKDIR /app, the destination./means/app/. - If the destination ends in
/, it is treated as a directory; if it does not, and there is a single source, it is interpreted as the name of the destination file. Always adding the trailing slash avoids surprises. - With several sources, the destination must be a directory and end in
/. COPYcopies the contents of a directory, not the directory itself.COPY web/ /public/leaves the contents ofweb/directly in/public/, not in/public/web/. It is the number one source of paths that "do not show up" inside the image.
Examples with Aurora Libros:
# Two specific files into the WORKDIR
COPY package.json package-lock.json ./
# Wildcards: anything starting with "package" and ending in ".json"
COPY package*.json ./
# The whole context (already filtered by .dockerignore)
COPY . .
# Renaming at the destination
COPY server.js /app/main.js
# An entire directory into an absolute path
COPY web/ /usr/share/nginx/html/About COPY package*.json ./: the wildcard covers package.json and package-lock.json in one go. It has a practical advantage over naming them separately: it does not fail if package-lock.json does not exist, because the pattern simply matches fewer files. COPY package.json package-lock.json ./, on the other hand, errors out if the lock is missing. Both forms are defensible: the explicit one catches a forgotten lock earlier (which would break the npm ci), the wildcard one is more forgiving. Aurora Libros will use the wildcard, which is the most widespread convention in the Node ecosystem.
--chown and --chmod
By default, everything copied belongs to root:root. --chown changes the owner in the same operation:
The node:22-alpine image already includes an unprivileged node user. Copying directly with its ownership avoids a subsequent RUN chown -R node:node /app, which would double the size of that layer: changing a file's permissions marks it as modified and the copy-on-write from lesson 01-05 forces a full copy to be written into the new layer. A recursive chown over node_modules can add tens of megabytes to the image.
--chmod does the same for permissions:
Using USER to run the container without privileges is covered in lesson 02-04, and the reasoning in depth in 05-03.
COPY versus ADD
COPY versus ADDADD has existed since Docker's very first day and does everything COPY does, plus two extra things. That is precisely why it is worth avoiding.
| Aspect | COPY |
ADD |
|---|---|---|
| Copy local files from the context | Yes | Yes |
| Copy directories | Yes | Yes |
--chown / --chmod |
Yes | Yes |
Automatically extract a local .tar, .tar.gz, .tar.bz2, .tar.xz |
No | Yes |
| Download from a URL | No | Yes (but discouraged) |
Clone a Git repository (--keep-git-dir) |
No | Yes, in recent versions |
| Predictable behavior | Total | Depends on the file type |
| Official recommendation | Use this one | Only in specific cases |
The problem with ADD is that its behavior depends on what you pass it:
ADD data.tar.gz /app/ # Extracts the tar inside /app/
ADD data.zip /app/ # Does NOT extract: zips are not covered by the rule
ADD file.txt /app/ # A normal copyThree different behaviors from the same instruction. Whoever reads the Dockerfile has to know by heart which formats get extracted in order to predict the result. COPY always does exactly the same thing: copy.
And the URL case is worse:
# ❌ WRONG: downloads a remote file into a layer
ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gzFour cumulative problems: the .tar.gz stays forever in the ADD's layer even if you delete it afterwards (copy-on-write, lesson 01-05), you cannot verify the checksum before using it, ADD does not extract what comes from a URL (only local files), and you have no control over headers or authentication. The correct form, all in a single RUN:
# ✅ RIGHT: download, verify, extract and delete in the SAME layer
RUN wget -q https://example.com/tool.tar.gz -O /tmp/t.tar.gz && \
echo "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b /tmp/t.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/t.tar.gz -C /opt && \
rm /tmp/t.tar.gzHere you do verify the hash, and the deletion happens within the same layer, so the temporary file does not fatten the image.
The practical rule: always use COPY. The only case where ADD genuinely contributes something is extracting a local tarball from the context, which saves you having tar in the image:
There is no ADD at all in the aurora-api Dockerfile: there are no tarballs and no downloads.
RUN: what you execute during the build
RUN: what you execute during the buildRUN executes a command at image build time, inside a temporary container based on the previous layers, and freezes the resulting filesystem into a new layer. It is the instruction that installs packages, compiles, generates files… and the one that adds the most weight.
Shell form and exec form
# Shell form: runs through /bin/sh -c
RUN npm ci --omit=dev
# Exec form: runs directly, with no shell
RUN ["npm", "ci", "--omit=dev"]| Aspect | Shell form | Exec form |
|---|---|---|
| Syntax | RUN command args |
RUN ["executable", "arg1", "arg2"] |
| Runs through | /bin/sh -c |
Directly |
Environment variables ($VAR) |
Get expanded | Do not get expanded |
Pipes, &&, >, * |
Work | Do not work |
| Requires a shell in the image | Yes | No |
Common use with RUN |
The normal one | Rare |
For RUN, the shell form is the usual one, because you almost always want to chain commands or expand variables. The exec form is only needed in images with no shell (scratch, distroless) or when an argument contains characters the shell would misinterpret.
Watch out for the quotes: the exec form is JSON, so it requires double quotes. RUN ['npm', 'ci'] with single quotes is not valid JSON and BuildKit will treat it as the shell form, with baffling results.
Why chain with && and \
This is the part that really matters. Every RUN creates a layer. Compare:
# ❌ WRONG: four layers, and the junk stays inside forever
RUN apk update
RUN apk add --no-cache curl
RUN apk add --no-cache tzdata
RUN rm -rf /var/cache/apk/*# ✅ RIGHT: a single layer, and the cleanup actually takes effect
RUN apk update && \
apk add --no-cache curl tzdata && \
rm -rf /var/cache/apk/*Two problems with the bad version, and the second is the serious one:
- Four layers where one was enough. More metadata, more manifest entries, more slowness when mounting the image.
- The
rm -rfat the end frees nothing. Remember from lesson 01-05: layers are diffs and they only stack. Deleting a file in layer 4 does not remove it from layer 2; it only writes a deletion marker that hides it. The file still travels inside the image, taking up its space on every download.
Check it for yourself:
# Version with the cleanup in a separate RUN
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3\nRUN rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.bad
docker build -q -t test:bad -f /tmp/Dockerfile.bad /tmp
# Version with everything in the same layer
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3 && rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.good
docker build -q -t test:good -f /tmp/Dockerfile.good /tmp
docker image ls test --format "table {{.Tag}}\t{{.Size}}"48 MB of difference for moving an rm from one line to another. The deleted file "exists" all the same inside the bad image, invisible but downloadable.
Hence the three golden rules of RUN:
- Group related operations into a single
RUNwith&&and\. - Clean up in the same layer where you make the mess. Package manager caches, temporary files, source code you have already compiled.
- But do not group everything. If you cram dependency installation and application startup into a single
RUN, you lose cache granularity. The balance: oneRUNper purpose.
Cleanup depending on the package manager
| Base | Command | Note |
|---|---|---|
| Alpine | apk add --no-cache package |
--no-cache avoids writing the index: no later rm needed |
| Debian/Ubuntu | apt-get update && apt-get install -y --no-install-recommends package && rm -rf /var/lib/apt/lists/* |
The rm is essential and must go in the same RUN |
| Node | npm ci --omit=dev && npm cache clean --force |
npm's cache can take up hundreds of MB |
A warning about Debian: apt-get update and apt-get install must always go in the same RUN. If you separate them, the cache may reuse an update from weeks ago while running a fresh install, and you will end up installing versions that are no longer in the repositories. It is the classic mistake known as apt cache busting.
RUN in Aurora Libros
Broken down:
npm ciinstead ofnpm install.cistands for clean install: it deletesnode_modulesif it exists and installs exactly the versions pinned inpackage-lock.json, with no range resolution. If the lock and thepackage.jsondo not agree, it fails instead of improvising. That is exactly what you want in an image: two builds of the same commit produce the same bytes.npm install, by contrast, may resolve^4.21.2to4.21.2today and to4.22.0next month, producing different images from the same code.--omit=devexcludes thedevDependencies. Aurora Libros has none today, but as soon as linters or test frameworks come in, this option will stop them travelling to production. It replaces the old--production, now discouraged.npm cache clean --forcedeletes the cache npm leaves in~/.npm, which can be around 50 MB. It goes in the sameRUNfor everything explained above: in a separateRUNit would not save a single byte.
ENV: configuration that survives the build
ENV: configuration that survives the buildENV defines environment variables that exist for the rest of the build and also inside the running container. That double life is its distinguishing feature, and it is what sets it apart from ARG (lesson 02-04).
Syntax:
ENV KEY=value
ENV KEY1=value1 KEY2=value2 # Several in one instruction, a single layer
ENV KEY value # Old form, without '=': discouragedAlways use the form with =. The old one is ambiguous with values containing spaces and is deprecated.
The variables you define can be used in later instructions:
And they persist at runtime. Check it with the image you already have:
The important thing is that they are overridable default values when the container starts:
That -e takes priority over the image's ENV. It is exactly the mechanism that lets the server.js from lesson 01-07 — which reads all its configuration from process.env — work unchanged on your laptop and in production.
What to put and what NOT to put in an ENV
| Variable | In the Dockerfile's ENV? |
Why |
|---|---|---|
NODE_ENV=production |
Yes | It is not a secret and it is the right default value for the image |
PORT=3000 |
Yes | A sensible default, overridable with -e |
DB_HOST |
No | It depends on the environment; it is injected at runtime |
DB_USER |
No | It depends on the environment |
DB_PASSWORD |
NEVER | It is a secret. It gets burned into a layer and is visible with docker image history |
The last row is the rule you have been carrying since lesson 01-07 and that you can now demonstrate. If somebody wrote ENV DB_PASSWORD=superSecret2026, anyone with access to the image would see it:
["PATH=/usr/local/sbin:...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]There it all is, in the clear, without even having to start the container. And by publishing the image to the public auroralibros/aurora-api repository, on the internet.
NODE_ENV=production deserves a comment because in Node it is not decorative: Express disables debug views and caches templates, many libraries reduce their logging and npm install would skip the devDependencies. It is a real behavior change, not a label.
EXPOSE: documentation, not publishing
EXPOSE: documentation, not publishingHere is the trap that catches everybody. Reading "expose", anyone understands "open this port to the outside world". EXPOSE opens nothing, publishes nothing and changes no networking behavior. It is exclusively documentation in the form of metadata.
What it does do:
- It records in the image's metadata which port the service uses, so that whoever runs it knows without reading the code.
- It shows up in
docker image inspectand in the PORTS column ofdocker ps. - It enables the
docker run -Poption (uppercase), which publishes all the ports declared withEXPOSEon random high ports of the host. - Docker Compose and some orchestrators read it as a hint.
What it does not do: publish the port. That is still the exclusive job of -p in docker run, as you learned in lesson 01-06.
Prove it. Your 0.1.0 image has EXPOSE 3000. Start it without -p:
docker run -d --name expose-test auroralibros/aurora-api:0.1.0
docker ps --filter name=expose-test --format "table {{.Names}}\t{{.Ports}}"Look at the PORTS column: it says 3000/tcp, with no -> arrow at all. That means "the container declares this port", not "it is published". Check it:
Now with uppercase -P, which does publish what is declared:
docker rm -f expose-test
docker run -d --name expose-test -P auroralibros/aurora-api:0.1.0
docker ps --filter name=expose-test --format "table {{.Names}}\t{{.Ports}}"Now there is an arrow: the container's port 3000 is published on the host's port 32768, chosen at random. Clean up:
So why bother writing EXPOSE at all? For three solid reasons:
- It documents the image's interface. Whoever receives it knows which port to map without reading
server.js. - It is the contract with the orchestrator. Compose (module 4), Swarm and Kubernetes (module 6) use it as a reference.
- It costs nothing. It is metadata: it does not add a single byte to the image.
Full syntax:
EXPOSE 3000 # TCP by default
EXPOSE 3000/tcp # Explicit
EXPOSE 53/udp # UDP
EXPOSE 3000 9229 # Several ports
CMD: which process the container starts
CMD: which process the container startsCMD defines the default command that runs when a container is started from the image. It does not run during the build: it is only stored as metadata.
And there is a rule that governs everything else, already seen in lesson 01-06: a container lives as long as its main process lives. When the CMD's process ends, the container stops. That is why CMD ["node", "server.js"] keeps the container alive (the server does not end) and CMD ["echo", "hello"] produces a container that dies instantly.
The two forms, and why they really matter
# Exec form (JSON) — THE CORRECT ONE
CMD ["node", "server.js"]
# Shell form — problematic
CMD node server.jsThey look equivalent, and under normal conditions they are. The difference shows up when you stop the container, and it is important enough to look at in detail.
With the exec form, Docker runs node server.js directly. The node process is PID 1 inside the container.
With the shell form, Docker runs /bin/sh -c "node server.js". PID 1 is sh, and node is a child process:
flowchart LR
subgraph EXEC["Exec form: CMD [node, server.js]"]
E1["PID 1: node server.js"]
end
subgraph SHELL["Shell form: CMD node server.js"]
S1["PID 1: /bin/sh -c"] --> S2["PID 7: node server.js"]
end
SIG1["docker stop<br/>SIGTERM"] --> E1
SIG2["docker stop<br/>SIGTERM"] --> S1
E1 -.->|"closes connections<br/>and exits cleanly"| OK["Stopped in ~0.2 s ✅"]
S1 -.->|"sh does not forward the signal"| KO["10 s of waiting<br/>and SIGKILL ❌"]
When you run docker stop, Docker sends SIGTERM to PID 1 and waits 10 seconds before sending SIGKILL. With the exec form, node receives the SIGTERM and can close connections, flush buffers and exit. With the shell form, the SIGTERM goes to sh, which does not forward it to its children: node never finds out, keeps working, and after 10 seconds a SIGKILL kills it with no chance of cleanup. Requests cut off halfway through and half-finished transactions.
Measure it:
# With the exec form (your current image)
docker run -d --name t-exec auroralibros/aurora-api:0.1.0
time docker stop t-exec# With the shell form
printf 'FROM auroralibros/aurora-api:0.1.0\nCMD node server.js\n' > /tmp/Dockerfile.shell
docker build -q -t aurora-shell -f /tmp/Dockerfile.shell /tmp
docker run -d --name t-shell aurora-shell
time docker stop t-shell0.3 seconds versus 10.2. Ten seconds of difference over a pair of brackets, multiplied by every container in every deployment. In a rolling deployment with twenty replicas (module 6), that difference is what separates a clean rollout from one with errors for your users.
Clean up:
Rule: always use the exec form, with brackets and double quotes. It is JSON: single quotes will not do.
Other properties of CMD
- Only the last one counts. If you write several
CMDs, the earlier ones are discarded silently. - It is overridden from the command line. Anything you put after the image name replaces the
CMD:
It did not start the server: you replaced the CMD with node --version. This flexibility is useful for debugging (docker run --rm -it yourimage sh gives you a shell inside the image) and it is the basis of the ENTRYPOINT + CMD pattern from lesson 02-04.
- If the base image has an
ENTRYPOINT, theCMDbecomes its arguments. That is precisely the subject of 02-04. - Do not use
CMDfor several processes.CMD ["sh", "-c", "node server.js & nginx"]is an antipattern: one container, one process. Aurora Libros has four services because it will have four containers.
- The definitive
aurora-api Dockerfile
aurora-api DockerfileYou can now justify every line. This is the module's final Dockerfile, which replaces the minimal one from lesson 02-02. Save it as ~/aurora-libros/api/Dockerfile:
# syntax=docker/dockerfile:1
# -----------------------------------------------------------------------------
# aurora-api image · REST API for the Aurora Libros S.L. catalog
# Build with: docker build -t auroralibros/aurora-api:1.0.0 .
# -----------------------------------------------------------------------------
# 1. Official base image: Node 22 (required by engines) on Alpine (~142 MB
# against ~1.1 GB for node:22). Tag with the major version pinned so it
# receives patches without unexpected version jumps.
FROM node:22-alpine
# 2. Working directory. Created if it does not exist and persists at runtime:
# it is where 'docker exec -it aurora-api sh' lands.
WORKDIR /app
# 3. Only the dependency manifests. By coming BEFORE the code, the npm ci
# layer is reused as long as package.json and the lock do not change.
# The wildcard covers package.json and package-lock.json in one instruction.
COPY package*.json ./
# 4. Reproducible install:
# npm ci -> EXACT versions from the lock; fails if they disagree
# --omit=dev -> development dependencies left out
# npm cache clean -> frees ~50 MB of cache IN THE SAME LAYER, which is the
# only way for the deletion to actually save space
RUN npm ci --omit=dev && npm cache clean --force
# 5. Now the application code, the most volatile part. The .dockerignore has
# already left out node_modules/, .git/ and .env.
COPY . .
# 6. Default configuration, overridable with -e at runtime.
# NODE_ENV=production enables real optimizations in Express.
# NO SECRETS GO HERE: they would be burned into the image's metadata.
ENV NODE_ENV=production \
PORT=3000
# 7. Documents that the service listens on 3000. It does NOT publish the port:
# for that you still need -p 3000:3000 in docker run.
EXPOSE 3000
# 8. Main process, in exec form so that node is PID 1 and receives SIGTERM
# directly: stops in 0.3 s instead of 10 s.
CMD ["node", "server.js"]Build version 1.0.0, which is the one matching the version in package.json:
[+] Building 11.9s (11/11) FINISHED
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c... 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 47.83kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package*.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 8.9s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.6s
=> => naming to docker.io/auroralibros/aurora-api:1.0.0 0.0sThe cache-hit demonstration
Change the code and rebuild, which is what you will do fifty times a day:
[+] Building 1.2s (11/11) FINISHED
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package*.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.5s1.2 seconds, with the npm ci untouched in the cache. The order of the instructions — dependencies at the top, code at the bottom — does exactly what it was designed to do.
Verify the finished image:
docker image ls auroralibros/aurora-api
docker image inspect auroralibros/aurora-api:1.0.0 \
--format 'WorkingDir: {{.Config.WorkingDir}}
Cmd: {{json .Config.Cmd}}
Env: {{json .Config.Env}}
Ports: {{json .Config.ExposedPorts}}'REPOSITORY TAG IMAGE ID CREATED SIZE
auroralibros/aurora-api 1.0.0 8c1e4a7f2b9d 4 seconds ago 167MB
auroralibros/aurora-api 0.1.0 6b4d2f8e1a3c 22 minutes ago 167MB
WorkingDir: /app
Cmd: ["node","server.js"]
Env: ["PATH=...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]
Ports: {"3000/tcp":{}}Every piece of metadata you have been declaring is there, readable. And no credentials among them, as the project's rule demands. One final functional check:
docker run -d --name aurora-api-v1 -p 3000:3000 auroralibros/aurora-api:1.0.0
curl -s http://localhost:3000/health
docker rm -f aurora-api-v1{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",
"errorDb":"connect ECONNREFUSED 127.0.0.1:5432","errorCache":"connect ECONNREFUSED 127.0.0.1:6379"}Just like in 02-02: the service is alive and responding; the dependencies still do not exist, and that is module 3.
Common Mistakes and Tips
RUN cd /appexpecting it to persist. EveryRUNruns in a different temporary container. UseWORKDIR.CMDin shell form. Ten extra seconds on every stop and signals that never reach your process. Always useCMD ["executable", "arg"]with double quotes: it is JSON, not Python.- Believing that
EXPOSEpublishes the port. It publishes nothing.EXPOSEdocuments;-ppublishes. If your service "does not respond" and you cannot see the->arrow in the PORTS column ofdocker ps, that is the problem. - Cleaning up in a different
RUNfrom the one that made the mess. The deletion frees nothing: the lower layer keeps the files. Chain with&&in the same instruction. apt-get updateandapt-get installin separateRUNs. The cache reuses a stale index and theinstalleither fails or installs obsolete versions. Always together.npm installinstead ofnpm ci. It breaks reproducibility: the same commit can produce different images. And do not forget--omit=dev.ADDout of habit. Its behavior depends on the file type and it hides unverified remote downloads. UseCOPYunless you need to extract a local tarball.- Secrets in
ENV. They stay in the metadata, visible withdocker image inspectwithout even starting the container, and they travel with the image wherever it goes. Never. COPY . .without a.dockerignore. You already saw it in 02-02: bloated context, broken cache and the risk of leaking a.env.- Tip: comment the why, not the what.
# Installs dependenciesis redundant: you can see that.# The cache clean goes here so it actually frees spaceis what whoever reads it six months from now will thank you for. - Tip: read other people's Dockerfiles. Those of the official images are on GitHub (lesson 02-01) and they are an excellent school.
Exercises
Exercise 1: prove that the cleanup has to be in the same layer
Build two images based on node:22-alpine that install the git package with apk:
- Version A:
apk add gitin oneRUNandrm -rf /var/cache/apk/*in a laterRUN. - Version B: everything chained with
&&in a singleRUN, usingapk add --no-cache.
Compare the sizes with docker image ls and use docker image history to locate the layer responsible for the difference. Explain the result in terms of layers and copy-on-write.
Exercise 2: measure the impact of the CMD form
- Write two Dockerfiles that are identical except for the
CMD: one in exec form and one in shell form, both startingnode server.jsfrom the Aurora Libros image. - Start a container from each.
- With
docker exec <container> ps -o pid,comm, check which process is PID 1 in each case. - Time
docker stopon both withtime. - Explain why the difference is exactly about 10 seconds and not some arbitrary value, and which
docker stopoption would let you change that wait.
Exercise 3: audit and fix a Dockerfile
This Dockerfile for the Aurora Libros API has seven problems related to what you have seen. Identify them, explain the consequence of each one and write the corrected version.
FROM node:latest
ADD . /app
RUN cd /app
RUN npm install
RUN npm cache clean --force
ENV DB_PASSWORD=aurora2026
ENV NODE_ENV production
EXPOSE 3000
CMD node /app/server.jsSolutions
Solution to exercise 1
mkdir -p /tmp/ex1 && cd /tmp/ex1
cat > Dockerfile.a <<'EOF'
FROM node:22-alpine
RUN apk add git
RUN rm -rf /var/cache/apk/*
EOF
cat > Dockerfile.b <<'EOF'
FROM node:22-alpine
RUN apk add --no-cache git
EOF
docker build -q -t ex1:a -f Dockerfile.a .
docker build -q -t ex1:b -f Dockerfile.b .
docker image ls ex1 --format "table {{.Tag}}\t{{.Size}}"Four megabytes of difference (with larger packages, the difference reaches tens or hundreds). Locate the guilty layer:
SIZE CREATED BY
0B RUN /bin/sh -c rm -rf /var/cache/apk/* # buildkit
19.2MB RUN /bin/sh -c apk add git # buildkitThe reading is conclusive: the rm -rf layer weighs 0 B. It has freed nothing. It has only written deletion markers (whiteouts) that hide the files in the lower layer, but that 19.2 MB layer is still in the image and gets downloaded in full on every docker pull.
In version B, --no-cache means apk never writes the index to disk, so there is nothing to delete. It is a direct application of the copy-on-write from lesson 01-05: a layer can only add content or hide it, never shrink the previous layers. Hence the rule: make the mess and clean it in the same instruction.
Solution to exercise 2
cd /tmp/ex1
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD ["node", "server.js"]\n' > Dockerfile.exec
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD node server.js\n' > Dockerfile.shell
docker build -q -t cmd:exec -f Dockerfile.exec .
docker build -q -t cmd:shell -f Dockerfile.shell .
docker run -d --name c-exec cmd:exec
docker run -d --name c-shell cmd:shell3. The processes:
Confirmed: in the exec form node is PID 1; in the shell form PID 1 is sh and node is its child with PID 7.
4. The timings:
5. The difference is exactly ~10 seconds because that is the value of docker stop's grace timer: it sends SIGTERM to PID 1, waits 10 seconds and, if the container is still alive, sends SIGKILL.
- In the exec form,
nodereceives the SIGTERM. Even though thisserver.jsinstalls no explicit handler, Node's default behavior on SIGTERM is to exit immediately: 0.3 s. - In the shell form,
shreceives the SIGTERM and does not forward it to its children (a minimal POSIX shell does no signal forwarding).nodenever finds out. The 10 seconds run out and the SIGKILL arrives, which the process cannot catch: an abrupt shutdown, severed connections, no chance to flush buffers or close the PostgreSQL pool.
The timeout can be adjusted with docker stop -t <seconds>:
docker rm -f c-shell 2>/dev/null; docker run -d --name c-shell cmd:shell
time docker stop -t 2 c-shell # ~2 s: it only shortens the agony, it does not fix the causeLowering the timeout solves nothing: the process still does not receive the signal. The solution is the exec form, or an ENTRYPOINT with a script that uses exec "$@" (lesson 02-04). Cleanup:
Solution to exercise 3
The seven problems:
| # | Line | Problem | Consequence |
|---|---|---|---|
| 1 | FROM node:latest |
The latest tag and the full variant |
Unpredictable major version (it could jump to Node 24 without warning) and a ~1.1 GB image instead of ~142 MB |
| 2 | ADD . /app |
ADD where COPY is enough |
Behavior that depends on the file type; no justification here |
| 3 | RUN cd /app |
cd in its own RUN |
It has no effect at all: the following npm install runs in / and fails |
| 4 | RUN npm install |
No ci, no --omit=dev, and after the ADD . |
Not reproducible, includes development dependencies and the cache is invalidated on every code change |
| 5 | RUN npm cache clean on its own line |
Cleanup in another layer | It does not free a single byte |
| 6 | ENV DB_PASSWORD=aurora2026 |
A secret in the image | Visible with docker image inspect; catastrophic in a public repository |
| 7 | CMD node /app/server.js |
Shell form | PID 1 = sh, SIGTERM never reaches node, 10 s of waiting and a SIGKILL on every stop |
An eighth, minor detail: ENV NODE_ENV production uses the old syntax without =, discouraged for being ambiguous.
Corrected version:
# syntax=docker/dockerfile:1
# (1) Lightweight official base with the major version pinned
FROM node:22-alpine
# (3) WORKDIR instead of RUN cd: it persists and creates the directory
WORKDIR /app
# (4) Dependencies before code, to preserve the cache
COPY package*.json ./
# (4)(5) Reproducible install and cleanup IN THE SAME LAYER
RUN npm ci --omit=dev && npm cache clean --force
# (2) COPY instead of ADD, and after the dependencies
COPY . .
# (6) No secrets. (8) Syntax with '='
ENV NODE_ENV=production \
PORT=3000
EXPOSE 3000
# (7) Exec form: node is PID 1 and receives SIGTERM
CMD ["node", "server.js"]The password is injected at runtime, never at build time:
docker run -d --name aurora-api \
-p 3000:3000 \
-e DB_HOST=aurora-db \
-e DB_PASSWORD=aurora2026 \
auroralibros/aurora-api:1.0.0And in module 4 it will not even be on the command line, but in a .env file outside version control (lesson 04-05) or in a managed secret (lesson 05-03).
Conclusion
You have now mastered the Dockerfile language. You know that the # syntax=docker/dockerfile:1 directive gives you the most recent frontend without updating Docker, and that of the eight basic instructions only COPY, ADD and RUN fatten the image: the rest write metadata that weighs nothing. FROM sets the starting point and the trade-off between reproducibility and automatic patches. WORKDIR replaces a RUN cd that would never work, because every RUN lives in a different temporary container, and it also persists at runtime. COPY resolves its paths against the context and copies the contents of directories; ADD adds extraction and downloads with a variable behavior that makes it the worse option except for local tarballs.
With RUN you have seen the most profitable rule of all: clean up in the same layer where you make the mess, because a layer can only add or hide, never slim down the previous ones — 48 MB of difference in the demonstration with python3. With ENV you have told a legitimate default value (NODE_ENV, PORT) apart from what must never go into an image (DB_PASSWORD), and you have verified it by reading the metadata with docker image inspect. With EXPOSE you have dismantled the trap in the name: it documents, it does not publish, and the proof is the missing -> arrow in docker ps. And with CMD you have measured first-hand that the exec form and the shell form are not alternative styles: they are 0.3 seconds versus 10.2 when stopping the container, because PID 1 is either node or sh, and sh does not forward SIGTERM.
The result is auroralibros/aurora-api:1.0.0: 167 MB, commented line by line, with every decision justified, rebuilding in 1.2 seconds after a code change and without a single secret inside. It is a Dockerfile that works.
In the next lesson, Advanced Dockerfile Instructions, you will turn it into a professional one. You will learn to parameterize the base version with ARG and how it differs from ENV, to separate the fixed executable from its arguments by combining ENTRYPOINT and CMD, to stop running the API as root with USER, to describe the image with OCI labels through LABEL, and to have Docker watch the /health endpoint on its own with HEALTHCHECK so that docker ps shows healthy or unhealthy. That is the leap between an image that starts and an image you can put into production without blushing.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
