In the previous lesson you learned where images come from: a registry, a repository, a tag pointing to a digest. And you reserved the address where yours will live, auroralibros/aurora-api. Now it is time to manufacture it. This lesson is about the build process itself: what docker build does from the moment you press Enter, what that trailing dot everybody copies without knowing what it means actually is, why a folder with node_modules inside it can turn a two-second build into a two-minute one, and how BuildKit's layer cache rewards or punishes the order in which you write your instructions. You are not going to study the Dockerfile instruction by instruction yet — that is the next lesson — but rather the machine that interprets it. By the end you will have auroralibros/aurora-api:0.1.0 built, running in a container and responding to a curl.
Contents
- What
docker buildreally does - Anatomy of the command and the trailing dot
- The build context
- The
.dockerignorefile - Dockerfiles with another name or path: the
-foption - BuildKit's layer cache
- Dependencies before code: inefficient versus efficient
--no-cacheand--pull: when to distrust the cache- Reading BuildKit's output
- From nothing to an image:
auroralibros/aurora-api:0.1.0
- What
docker build really does
docker build really doesRemember the client-server architecture from lesson 01-03: the docker client does not do the work, it asks the daemon to do it. Building images follows that same scheme, with one more actor. Since Docker Engine 23, the default builder is BuildKit, a standalone build engine that is far more capable than the classic one.
sequenceDiagram
participant U as You (terminal)
participant C as docker client
participant D as dockerd
participant B as BuildKit
participant R as Registry
U->>C: docker build -t aurora-api:0.1.0 .
C->>C: Reads the Dockerfile and the .dockerignore
C->>D: Sends the context (non-ignored files)
D->>B: Requests the build
B->>B: Parses the Dockerfile and computes the step graph
B->>R: Do I have node:22-alpine? If not, pull it
R-->>B: Base image layers
loop For each instruction
B->>B: Is there a valid cache entry for this step?
alt There is cache
B-->>B: CACHED (reuses the layer)
else No cache
B->>B: Runs the step and creates a new layer
end
end
B->>D: Exports the layers and the manifest
D->>D: Stores the image and applies the tag
D-->>C: Build completed
C-->>U: naming to docker.io/auroralibros/aurora-api:0.1.0
Three practical consequences of this diagram worth internalizing before moving on:
- The build does not happen in your working directory. It happens in the builder, which may be on another machine. That is why the files have to be sent to it: that is the context from section 3.
- BuildKit builds a graph, not a list. It does not execute the instructions blindly from top to bottom: it works out which steps depend on which and skips the ones it has already resolved. Hence the power of the cache.
- The resulting image stays in your local store.
docker builddoes not publish anything to any registry; that isdocker push, and it arrives in lesson 02-06.
docker build and docker buildx build
You will come across both forms, so let's clear them up right away:
| Command | What it is |
|---|---|
docker build |
The classic command. Since Engine 23 it delegates to BuildKit transparently |
docker buildx build |
BuildKit's full interface, with extra options (multi-platform, exporters, remote builders) |
For everything in this module they are equivalent: docker build is shorter and it is what you will use. The capabilities exclusive to buildx — building for several architectures at once, cache mounts, build secrets — are covered in lesson 05-05. You can check which builder you are using:
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker
\_ default \_ default running v0.19.0 linux/amd64, linux/386The asterisk marks the active builder. BUILDKIT v0.19.0 confirms that BuildKit is running; if that column were empty, you would be on the old builder and you would see none of what this lesson describes.
- Anatomy of the command and the trailing dot
The canonical form is:
Broken down:
| Part | What it is | Detail |
|---|---|---|
docker build |
The command | Builds an image |
-t auroralibros/aurora-api:0.1.0 |
--tag: name and tag of the resulting image |
It can be repeated to give the same build several names |
. |
The build context | The directory whose content is sent to the builder |
The trailing dot is the part that generates the most confusion. It does not mean "build here" nor "use the Dockerfile in this directory", even though by default it produces both effects. It means: the build context is the current directory. In other words, "send the builder everything that is in . (minus what is ignored), because the COPY instructions are going to look for their files in there".
It is easier to see with variations:
# Context = current directory; Dockerfile = ./Dockerfile
docker build -t aurora-api:0.1.0 .
# Context = ./api ; Dockerfile = ./api/Dockerfile
docker build -t aurora-api:0.1.0 ./api
# Context = parent directory; Dockerfile = ../Dockerfile
docker build -t aurora-api:0.1.0 ..
# Context = a remote Git repository, cloned by the builder
docker build -t aurora-api:0.1.0 https://github.com/auroralibros/aurora-libros.git#main:apiThe last form is revealing: the context does not even have to be on your machine. BuildKit can clone a repository and build from it. That proves the context is an abstract concept ("the set of files available to the build"), not "the folder you happen to be in".
Other docker build options you will use in this module:
| Option | What for |
|---|---|
-t, --tag |
Name and tag; repeatable |
-f, --file |
Path to a Dockerfile with another name or location (section 5) |
--no-cache |
Ignores the cache entirely (section 8) |
--pull |
Forces a pull of the most recent version of the base image (section 8) |
--progress=plain |
Full output, without the dynamic interface (section 9) |
--build-arg |
Passes a build argument (lesson 02-04) |
--target |
Builds up to a specific stage (multi-stage, lesson 05-04) |
- The build context
Before running any instruction, the client packages the context and sends it to the builder. This matters because everything in that directory travels, whether you use it in some COPY or not.
Check the size of your context before building. It is a habit worth its weight in gold:
Twenty-three megabytes for a 100-line server.js and a package.json. Where does that come from?
There it is: node_modules, generated when you ran npm install back in lesson 01-07. And those 23 MB contribute nothing to the image, because the dependencies will be installed inside it with npm ci.
Consequences of a large context:
- The build is slower, and the delay happens before the first instruction: the client has to read, compress and transmit all those files. In a project with a 500 MB
.git, that is several seconds on every single build. - Risk of leaks. A careless
COPY . .puts everything in the context into the image, including.envfiles, SSH keys or database dumps. That is a real security incident, not a theoretical one. - It breaks the cache. As you will see in section 6, the cache of a
COPY . .is invalidated if any file in the context changes. Withnode_modulesinside, a localnpm installinvalidates the entire build.
The classic mistake: COPY from outside the context
You are going to make this mistake, so it is better to understand it now. Suppose that from ~/aurora-libros/api you try to copy the init.sql that lives in ~/aurora-libros/db/:
ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of
ref ...: "/db/init.sql": not foundThe message is misleading because it says "not found", yet the file exists perfectly well on your disk. What happens is that it does not exist in the context: since the context is . (that is, api/), the builder has only received what is inside api/, and ../db/ is outside it. The source paths of COPY are always relative to the root of the context and can never leave it. It is a deliberate security restriction: if it were not so, any Dockerfile could copy /etc/shadow or ~/.ssh/id_rsa from the machine doing the building.
The two legitimate solutions:
# a) Widen the context to the project root and point at the Dockerfile with -f
cd ~/aurora-libros
docker build -t test -f api/Dockerfile .
# b) Keep the context in api/ and copy the file there (if it really belongs to the API)For Aurora Libros we will choose the narrow-context option: the API image does not need init.sql, which is a matter for the PostgreSQL container (module 3). Each image carries only what belongs to it.
- The
.dockerignore file
.dockerignore fileIt was announced back in lesson 01-07: the API image will have a .dockerignore that excludes node_modules/, .git/ and .env. Now is the moment to write it.
.dockerignore is a text file at the root of the context that tells the client what not to send to the builder. Its syntax is reminiscent of .gitignore, but it has rules of its own:
| Pattern | What it excludes |
|---|---|
node_modules |
Any file or folder with that name at the root of the context |
**/node_modules |
That name at any depth |
*.log |
Every file with the .log extension at the root |
**/*.log |
Every .log in any subdirectory |
.git |
The entire Git directory |
temp? |
temp1, tempA… (? = any single character) |
!important.log |
Exception: re-includes that file even if an earlier pattern excluded it |
# comment |
Ignored line |
Two differences from .gitignore that cause surprises:
node_modulesis not recursive on its own, unlike in Git. If you have subprojects with their own dependencies, you need**/node_modules.- Order matters when you use
!: the last matching rule wins.
Create ~/aurora-libros/api/.dockerignore:
# Dependencies: installed inside the image with npm ci
node_modules
**/node_modules
# Version control
.git
.gitignore
# Secrets and local configuration: NEVER inside an image
.env
.env.*
# Logs and temporary files
*.log
npm-debug.log*
.npm
.cache
tmp/
# Operating system and editor metadata
.DS_Store
Thumbs.db
.vscode
.idea
# The Dockerfile itself and its companions: the builder already has them
Dockerfile
Dockerfile.*
.dockerignore
# Tests and documentation: they do not run in production
test/
*.test.js
README.md
ONBOARDING-NOTES.mdThe rationale for the less obvious entries:
node_modules: the main reason. Beyond the size, copying your machine'snode_modulescan bring in binaries compiled for your operating system and architecture that will not work inside an Alpine image (this is exactly the warning from exercise 3 of 01-07)..env: the project's non-negotiable rule. Credentials do not go into the image. A.dockerignorethat excludes it is the first line of defense against a carelessCOPY . ..Dockerfile: it is not needed inside the image. Excluding it has a very useful side effect: editing the Dockerfile no longer invalidates the cache ofCOPY . ..README.md,test/: they do not run in production, and every byte that does not go in is a byte that does not get downloaded on every deployment.
Measuring before and after
The clean way to check the effect is with --progress=plain, which shows the context transfer line. First, without the .dockerignore:
cd ~/aurora-libros/api
mv .dockerignore .dockerignore.off
docker build --no-cache --progress=plain -t measurement:without -f Dockerfile . 2>&1 | grep -i "transferring context"Now with it:
mv .dockerignore.off .dockerignore
docker build --no-cache --progress=plain -t measurement:with -f Dockerfile . 2>&1 | grep -i "transferring context"From 23.41 MB to 47.83 kB: a reduction of over 99%, and the transfer time drops from 1.8 seconds to practically zero. In a real project with a bulky .git, build artifacts and screenshots, the difference is measured in hundreds of megabytes and tens of seconds on every build.
- Dockerfiles with another name or path: the
-f option
-f optionBy default, docker build looks for a file called exactly Dockerfile at the root of the context. -f breaks that coupling:
# A Dockerfile with another name, in the same directory
docker build -t aurora-api:dev -f Dockerfile.dev .
# Context at the project root, Dockerfile inside api/
cd ~/aurora-libros
docker build -t auroralibros/aurora-api:0.1.0 -f api/Dockerfile ./api
# Dockerfiles centralized in a separate folder
docker build -t aurora-web:0.1.0 -f docker/web.Dockerfile ./webKey points:
-fand the context are independent. You can have the Dockerfile anywhere and the context somewhere else. What never changes is thatCOPYpaths are resolved against the context, not against the Dockerfile's location. It is the number one confusion with-f.- The
-fpath is relative to your current directory, not to the context. - The most common use case is having variants:
Dockerfilefor production andDockerfile.devwith development tools. Aurora Libros will use a single Dockerfile; the differences between environments will be handled with Compose (lesson 04-06), which is a cleaner approach.
- BuildKit's layer cache
Here lies the difference between waiting ninety seconds on every code change or waiting two. Remember from lesson 01-05 that an image is a stack of layers. During the build, every Dockerfile instruction that modifies the filesystem produces a layer, and BuildKit tries to reuse the ones it already has.
How BuildKit decides whether to reuse a layer
For each instruction, BuildKit computes a cache key from:
- The key of the previous layer (that is why order is everything).
- The literal text of the instruction. Changing a space or a comment inside a
RUNinvalidates it. - For
COPYandADD, additionally, the checksum of the content of the copied files (not the modification date: if you touch a file without changing its content, the cache holds).
If that key exists in the local cache, it marks the step as CACHED and runs nothing. If it does not exist, it runs the step and all subsequent ones, without exception.
Cascading invalidation
This is the property that governs the design of every Dockerfile:
flowchart TB
A["FROM node:22-alpine<br/>base layer"] --> B["WORKDIR /app"]
B --> C["COPY package*.json ./"]
C --> D["RUN npm ci<br/>⏱ 45 s"]
D --> E["COPY . .<br/>your code"]
E --> F["CMD [node, server.js]"]
style C fill:#f4f0fa
style D fill:#f4f0fa
style E fill:#ffe0e0
style F fill:#ffe0e0
If you change a line of server.js, the COPY . . (step E) is invalidated and, in cascade, everything that comes after it. But steps A–D stay cached, including the 45-second npm ci. The result: the build takes a couple of seconds.
Now flip the order — copy the code before installing dependencies — and the same change in server.js invalidates the COPY . ., which is now before the npm ci. The consequence: the npm ci runs in full again. Forty-five seconds, every time you change a comma.
Hence the golden rule: order the instructions from least to most volatile. What almost never changes (the base image, installing system packages, the dependencies) goes at the top; what you change fifty times a day (your code) goes at the bottom.
- Dependencies before code: inefficient versus efficient
Let's measure it, because a number is more convincing than an explanation.
INEFFICIENT version
It looks reasonable: copy the project and then install. And it works. The problem is that COPY . . includes server.js, so any change in the code invalidates that layer and, with it, the npm ci.
EFFICIENT version
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]The difference is one COPY split into two. First only the dependency manifests are copied and installed, and then the rest of the code is copied. Since package.json and package-lock.json rarely change, the npm ci stays cached almost always.
The measurement
Save each version in its own file and time the realistic cycle: a first cold build, and a second build after touching the code.
cd ~/aurora-libros/api
# First build of each variant (both cold)
time docker build --no-cache -q -t inefficient -f Dockerfile.inefficient . > /dev/null
time docker build --no-cache -q -t efficient -f Dockerfile.efficient . > /dev/null
# Simulate a code change, which is what happens fifty times a day
echo "// minor tweak" >> server.js
# Rebuild both, now taking advantage of the cache
time docker build -q -t inefficient -f Dockerfile.inefficient . > /dev/null
time docker build -q -t efficient -f Dockerfile.efficient . > /dev/nullTypical results on a development machine:
| Scenario | Inefficient | Efficient |
|---|---|---|
| First build (cold) | 48.3 s | 49.1 s |
After changing server.js |
46.7 s | 1.4 s |
After changing package.json |
47.9 s | 48.5 s |
Read the table carefully, because it contains the lesson's three messages:
- Cold, they are the same (the efficient one is even a hair slower, because it has one extra
COPY). The cache does not speed up the first time. - After a code change, the difference is 33×. Multiply that by fifty builds a day and by every person on the Aurora Libros team: that is hours.
- After changing the dependencies, they even out again, and that is correct: if
package.jsonchanges, you have to reinstall. The cache is not lying, it is doing its job.
A note about npm ci that justifies using it over npm install, and which lesson 02-03 picks up again: npm ci wipes node_modules and installs exactly the versions pinned in package-lock.json, failing if the lock and the package.json do not agree. npm install may resolve different versions depending on when it runs, which breaks reproducibility. In an image, always npm ci.
--no-cache and --pull: when to distrust the cache
--no-cache and --pull: when to distrust the cacheThe cache is an optimization based on an assumption: that the same instruction with the same inputs produces the same result. Sometimes that assumption is false.
# Ignore all cache: run every instruction from scratch
docker build --no-cache -t auroralibros/aurora-api:0.1.0 .
# Check whether there is a more recent version of the base image and pull it
docker build --pull -t auroralibros/aurora-api:0.1.0 .
# Both: the most reproducible build possible
docker build --no-cache --pull -t auroralibros/aurora-api:0.1.0 .When to use each one:
| Situation | Option | Why |
|---|---|---|
| Release or CI build | --pull (and often --no-cache) |
Guarantees an up-to-date base and no inherited state |
apt-get install / apk add bringing in old packages |
--no-cache |
The instruction is identical, but the remote repositories have changed |
RUN git clone or curl of a remote resource |
--no-cache |
Same idea: the instruction does not change, the remote content does |
| You suspect a corrupt cache or unexplainable behavior | --no-cache |
Rules out the cache as a variable in the problem |
| Day-to-day development | Neither | You would be throwing away the advantage from section 7 |
The --pull case deserves a separate explanation, because it is subtle. If your Dockerfile says FROM node:22-alpine and you already have that tag downloaded, BuildKit uses it without checking whether it has changed in the registry. But 22-alpine is a moving tag (lesson 02-01): Node publishes patches and that tag starts pointing to a different digest. Without --pull, you could be building for months on a base with vulnerabilities that were already fixed upstream. That is why --pull is practically mandatory in build pipelines (lesson 06-02).
And a warning: --no-cache does not delete the existing cache, it just ignores it for that build. To actually free up the space it takes there is a specific command, docker builder prune, which you will see in lesson 02-05.
- Reading BuildKit's output
BuildKit prints a dynamic interface that rewrites itself in place. Learning to read it is learning to diagnose builds.
[+] Building 12.4s (11/11) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 421B 0.0s
=> [internal] load metadata for docker.io/library/node:22-alpine 1.1s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 583B 0.0s
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c... 2.8s
=> => resolve docker.io/library/node:22-alpine@sha256:9f2c... 0.0s
=> => sha256:9f2c... 1.72kB / 1.72kB 0.0s
=> => extracting sha256:4a1b... 0.4s
=> [internal] load build context 0.0s
=> => transferring context: 47.83kB 0.0s
=> CACHED [2/5] WORKDIR /app 0.0s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 7.9s
=> [5/5] COPY . . 0.0s
=> exporting to image 0.5s
=> => exporting layers 0.4s
=> => writing image sha256:6b4d... 0.0s
=> => naming to docker.io/auroralibros/aurora-api:0.1.0 0.0sHow to read it, line by line:
| Element | Meaning |
|---|---|
[+] Building 12.4s (11/11) FINISHED |
Total time and completed / total steps |
[internal] … |
Internal steps: reading the Dockerfile, the .dockerignore, resolving the base image metadata |
transferring context: 47.83kB |
The size of your context. It is the line you watch after writing the .dockerignore |
[1/5], [2/5]… |
Numbered Dockerfile steps. Only instructions that generate a layer are counted |
CACHED |
Step reused from the cache. Zero seconds. What you want to see |
exporting layers |
Writing the new layers into the local store |
writing image sha256:… |
The ID of the resulting image |
naming to … |
The tag you gave it with -t |
You diagnose by looking at where CACHED stops appearing. That is the invalidation point, and everything below it has been re-executed. If the first non-cached step is earlier than you expected, you have an instruction-ordering problem or a volatile file sneaking into a COPY.
--progress=plain
The dynamic interface is convenient but it hides the commands' output: you do not see what npm ci prints. When something fails, you need to see everything:
#8 [4/5] RUN npm ci --omit=dev && npm cache clean --force
#8 3.412 npm warn config production Use `--omit=dev` instead.
#8 7.108 added 112 packages, and audited 113 packages in 7s
#8 7.115 found 0 vulnerabilities
#8 7.883 npm warn using --force Recommended protections disabled.
#8 DONE 7.9sThe format is #<step> <seconds since the start of the step> <output line>. Those relative times are pure gold for working out which part of a long RUN is the slow one. Three common uses of --progress=plain:
- Seeing why a
RUNfails (the command's full error message). - Measuring the size of the context, as you did in section 4.
- Keeping a complete log in CI, where the dynamic interface produces unreadable garbage.
- From nothing to an image:
auroralibros/aurora-api:0.1.0
auroralibros/aurora-api:0.1.0It is time to put it all together. You are going to build the first Aurora Libros image with a minimal Dockerfile, presented in full. Here we only skim over what each instruction does; the complete detail, the alternatives and the fine-grained decisions are lesson 02-03.
Create ~/aurora-libros/api/Dockerfile:
# syntax=docker/dockerfile:1
# Base image: Node.js 22 on Alpine Linux, as decided in 01-07
FROM node:22-alpine
# Working directory inside the image; all other paths are relative to it
WORKDIR /app
# First ONLY the dependency manifests: that keeps npm ci cached
COPY package.json package-lock.json ./
# Reproducible install, without dev dependencies, cleaning npm's cache
RUN npm ci --omit=dev && npm cache clean --force
# Now yes, the application code
COPY . .
# Default configuration. Credentials do NOT go here (project rule)
ENV NODE_ENV=production
ENV PORT=3000
# Documents that the service listens on 3000 (it does not publish anything by itself)
EXPOSE 3000
# The process that runs when the container starts
CMD ["node", "server.js"]A quick look at each instruction, without going deep:
# syntax=docker/dockerfile:1: picks the most recent Dockerfile frontend in the 1.x series.FROM: which image you start from.WORKDIR: sets the working directory inside the image and creates it if it does not exist.COPY: brings files from the context into the image. It is split in two because of section 7.RUN: runs a command during the build and freezes the result into a layer.ENV: defines environment variables that persist at runtime.EXPOSE: documentation. It does not publish the port; that is still a job for-p(lesson 01-06).CMD: which process the container starts.
If you are wondering why npm ci and not npm install, why that bracketed form of CMD, or why EXPOSE does not do what its name suggests: all of that is exactly the content of lesson 02-03.
Building
[+] Building 13.7s (11/11) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/node:22-alpine 1.2s
=> [internal] load .dockerignore 0.0s
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c... 3.1s
=> [internal] load build context 0.0s
=> => transferring context: 47.83kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 8.4s
=> [5/5] COPY . . 0.0s
=> exporting to image 0.6s
=> => naming to docker.io/auroralibros/aurora-api:0.1.0 0.0sCheck the result:
167 MB, of which about 142 are the node:22-alpine base you already knew about. Your application and its dependencies contribute around 25 MB.
Rebuilding and seeing the cache in action
[+] Building 0.4s (11/11) FINISHED
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> CACHED [5/5] COPY . . 0.0sEverything CACHED, 0.4 seconds. From 13.7 s to 0.4 s without changing anything.
Running the image
docker run -d --name aurora-api-test -p 3000:3000 auroralibros/aurora-api:0.1.0
docker ps --filter name=aurora-api-testCONTAINER ID IMAGE STATUS PORTS NAMES
d3f8a1c9b7e2 auroralibros/aurora-api:0.1.0 Up 4 seconds 0.0.0.0:3000->3000/tcp aurora-api-testCommands you already know from 01-06: -d in the background, --name so you can refer to it, -p 3000:3000 to publish the port. Look at the logs:
[aurora-api] listening on port 3000
[aurora-api] database: localhost:5432/aurora_books
[aurora-api] cache: localhost:6379The API has started inside a container, without you having installed Node on your machine. Try it out:
{"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"}The endpoint responds (HTTP 503), which is what we wanted to check: the process is alive, Express is listening and the route works. But db and cache are ko with the same ECONNREFUSED from lesson 01-07, and now for a new and very instructive reason: localhost inside the container is the container itself, not your machine. In there, there is no PostgreSQL and no Redis listening, and that is why the connection is refused. Even if you had the services running on your laptop, the container would not see them with that configuration.
And /books, which does need the database:
Exactly as expected. This failure is not a mistake of yours: it is the course's next problem. Connecting containers to each other through their own networks, so that DB_HOST=aurora-db means something, is the content of lesson 03-05; and giving PostgreSQL a volume to store the catalog in is that of 03-06.
Clean up before moving on:
The image stays in your local store for the following lessons. A stocktake of what has been achieved: of the fifteen manual steps from lesson 01-07, steps 2, 3, 4 and 5 (find out the Node version, install nvm, install Node 22, run npm install) are gone. Anyone with Docker can run your API without installing anything Node-related.
Common Mistakes and Tips
- Believing that the trailing dot means "the Dockerfile is here". It means "this is the context". They are separate things, and
-fproves it. As soon as you internalize that, half of yourCOPYerrors disappear. COPY ../somethingfrom outside the context. It will never work, by design. Widen the context and use-f, or reorganize the files. If you find yourself fighting with this, it is almost always a sign that the image is trying to carry something inside it that does not belong there.- Forgetting the
.dockerignore. Slow context, bloated images, a cache that invalidates itself and a real risk of leaking a.env. Write it before your firstdocker build, not after. COPY . .before installing dependencies. It is the most expensive and most frequent performance mistake. Dependencies at the top, code at the bottom.- Trusting the cache to "detect" remote changes. A cached
RUN apk add curlwill keep installing the version from three months ago even if there is a new one today. For release builds, use--pulland, where appropriate,--no-cache. - Rebuilding with
--no-cache"just in case" during development. You are throwing away the advantage from section 7. Use it when you have a concrete reason. - Thinking that
docker builduploads the image. It uploads nothing. The image stays on your machine until you rundocker push(lesson 02-06). - Tip: keep an eye on the
transferring contextline. If it grows over time, something new is sneaking into the context. It is the cheapest alarm you have. - Tip: tag from the very first moment. A build without
-tproduces a<none>:<none>image that you can only reference by ID and that turns into accumulated junk (dangling images, lesson 02-05).
Exercises
Exercise 1: measure the effect of the .dockerignore
Starting from ~/aurora-libros/api with node_modules installed:
- Temporarily rename the
.dockerignoreand build with--no-cache --progress=plain, noting down thetransferring contextline. - Restore the
.dockerignoreand repeat the measurement. - Compute the percentage reduction.
- Add to the project a
.envfile with a fictional password and ascreenshots/folder with 5 MB of fake data. Without touching the.dockerignore, check whether they make it into the context and reason about what would have happened with aCOPY . .if the.dockerignoredid not exist.
A hint for generating fake data: dd if=/dev/urandom of=screenshots/dump.bin bs=1M count=5.
Exercise 2: demonstrate cascading invalidation
With the Dockerfile from section 10 already built:
- Rebuild without changing anything and check that every step comes out
CACHED. - Modify a line of
server.jsand rebuild. Which steps stay cached and which is the first to be re-executed? How long does it take? - Add a dependency to
package.json(for example"dotenv": "^16.4.7"), regenerate the lock withnpm install --package-lock-onlyand rebuild. How many steps are re-executed now? How long does it take? - Change a comment inside the Dockerfile, on the line immediately before the
RUN npm ci. Rebuild. Does theRUN's cache hold? Explain the result.
Exercise 3: fix a broken Dockerfile
This Dockerfile has four problems related to what you saw in the lesson. Find them, explain the symptom of each one and write the corrected version.
FROM node:22-alpine
COPY . .
COPY ../db/init.sql /app/init.sql
RUN npm install
WORKDIR /app
CMD ["node", "server.js"]Solutions
Solution to exercise 1
cd ~/aurora-libros/api
# 1. Without .dockerignore
mv .dockerignore .dockerignore.off
docker build --no-cache --progress=plain -t measurement . 2>&1 | grep "transferring context"# 2. With .dockerignore
mv .dockerignore.off .dockerignore
docker build --no-cache --progress=plain -t measurement . 2>&1 | grep "transferring context"3. Reduction: (23,410 − 48) / 23,410 ≈ 99.8%. The transfer time goes from ~1.9 s to something too small to measure. Over a day with fifty builds, that is nearly two minutes of pure waiting recovered, and in a CI pipeline the saving is multiplied by every run.
4. With the new files:
echo "DB_PASSWORD=superSecret2026" > .env
mkdir -p screenshots && dd if=/dev/urandom of=screenshots/dump.bin bs=1M count=5 2>/dev/null
docker build --no-cache --progress=plain -t measurement . 2>&1 | grep "transferring context"The context grows by 5 MB: screenshots/ gets in because it is not in the .dockerignore. .env, on the other hand, does not get in, because it is excluded. Check it by looking inside the image:
total 60
drwxr-xr-x 1 root root 4096 Aug 4 10:22 .
drwxr-xr-x 5 root root 4096 Aug 4 10:22 screenshots
-rw-r--r-- 1 root root 44231 Aug 4 10:22 package-lock.json
-rw-r--r-- 1 root root 412 Aug 4 10:22 package.json
-rw-r--r-- 1 root root 6104 Aug 4 10:22 server.jsThere is no .env and there is a screenshots/. The two lessons:
- Without a
.dockerignore, that.envwithsuperSecret2026would have ended up inside the image and, when you published it to the public repository from section 10 of the previous lesson, on the internet. And deleting it in a later layer would not be enough: as you learned in 01-05, the lower layer keeps the file anddocker image historygives the operation away. - The
.dockerignorehas to be maintained. It has protected what it was asked to protect, butscreenshots/is new and nobody added it. Addscreenshots/to the file and measure again.
Solution to exercise 2
1. With no changes: [+] Building 0.4s, every step CACHED. The build is practically instantaneous because BuildKit only verifies cache keys.
2. After touching server.js:
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package.json package-lock.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.4s
[+] Building 1.1s (11/11) FINISHEDOnly the COPY . . is re-executed, because the context's checksum has changed. The RUN npm ci stays cached, which is precisely the goal of the design. Barely more than a second.
3. After changing package.json and the lock:
=> CACHED [2/5] WORKDIR /app 0.0s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 9.2s
=> [5/5] COPY . . 0.1s
[+] Building 10.4s (11/11) FINISHEDInvalidation starts at step 3, and in cascade takes the npm ci and the COPY . . with it. Ten seconds. And that is correct: you changed the dependencies, so they have to be reinstalled. The cache is not failing, it is working exactly as it should.
4. Changing a comment immediately before the RUN:
The cache holds. The cache key is computed over the text of the instruction, and comments are not instructions: the frontend discards them before computing anything. On the other hand, if you modify the text of the RUN itself — even just adding a space or reordering two equivalent options — the cache is invalidated, because the comparison is textual, not semantic. That is why reformatting a long RUN "to make it prettier" triggers a full build.
Solution to exercise 3
The four problems:
COPY . .beforeWORKDIR. With noWORKDIRdefined, the working directory is/, so the files land at the root of the filesystem, mixed in with/bin,/etcand/usr. Then theWORKDIR /appat the end creates an empty/app, and theCMDfails withCannot find module '/app/server.js'.COPY ../db/init.sqlpoints outside the context: an immediate build error ("/db/init.sql": not found). Besides, that file does not belong to the API image: it is PostgreSQL's initialization and it will be dealt with in module 3.COPY . .before installing dependencies. Cascading invalidation: every change inserver.jsforces a complete reinstall of the dependencies.npm installinstead ofnpm ci --omit=dev.npm installmay resolve versions other than the ones pinned inpackage-lock.json, so two builds of the same code can produce different images, and it also installs the development dependencies, which fatten the image without contributing anything at runtime.
Corrected version:
# syntax=docker/dockerfile:1
FROM node:22-alpine
# 1. WORKDIR BEFORE any COPY: it sets where the files land
WORKDIR /app
# 2. init.sql removed: it does not belong to this image
# 3. Dependencies first, to preserve the cache
COPY package.json package-lock.json ./
# 4. npm ci: reproducible and without dev dependencies
RUN npm ci --omit=dev && npm cache clean --force
# The code, last because it is the most volatile
COPY . .
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]Verify that the files are now where they should be:
Conclusion
You now know how to manufacture images. docker build does not run anything in your folder: it packages the build context and sends it to BuildKit, which interprets the Dockerfile as a graph of steps and produces layers. That trailing dot in the command is the context, not the Dockerfile, and from that follow both the "COPY from outside the context" error and the need for the .dockerignore, which in Aurora Libros has cut the transfer from 23.41 MB to 47.83 kB and, along the way, has prevented a .env with credentials from ending up inside the image. The -f option decouples the Dockerfile's location from the context, but COPY paths are still always resolved against the latter.
You have also seen the piece that will save you the most time in your daily life: the layer cache. BuildKit computes a key per instruction from the previous layer, the instruction's text and the content of the copied files; if that key exists, the step comes out CACHED in zero seconds, and if it does not, that step and all the ones after it are re-executed. From that cascading invalidation comes the golden rule of ordering from least to most volatile, which you have measured: 46.7 s versus 1.4 s for a simple code change, thirty-three times faster just for splitting one COPY into two. And you know when to distrust the cache with --no-cache and --pull, and how to read BuildKit's output to pinpoint exactly where CACHED stopped appearing.
Most important of all: auroralibros/aurora-api:0.1.0 exists. It is 167 MB that start Express in a container and respond to curl http://localhost:3000/health on a machine where Node is not installed. Four of the fifteen onboarding steps have fallen in one go. That /books returns ECONNREFUSED is not a failure: it is the syllabus's next problem, and it will be solved when you connect containers over a network in module 3.
That said, you wrote that Dockerfile almost blind: you know roughly what each line does, but not why it is written that way. Why npm ci and not npm install, why the CMD has brackets and quotes, why EXPOSE does not expose anything, when to use ADD instead of COPY and why chaining commands with && produces smaller images. All of that is the next lesson, Dockerfile Basics, where you will walk through the language instruction by instruction and finish with the definitive aurora-api Dockerfile, commented line by line and justified decision by decision.
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
