Writing a Dockerfile

Build a small Node.js image instruction by instruction, with a lockfile and .dockerignore.

Beginner14 min · lesson 8 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (3 files, 1 KB): dockerfile.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/dockerfile.tar.gz && tar -xzf dockerfile.tar.gz, which creates ~/lab/dockerfile/. SHA-256: 16a42f7476055c8f5d9d16758a11c0cb28775d343a981ee95479e9f2e641cfe7

You'll write a Dockerfile for a small Node.js web service, build it and run it. Along the way you'll hit the two mistakes that show up in almost every first Dockerfile. One is copying files you never meant to ship. The other is a CMD that points at the wrong file. You'll install dependencies from a lockfile from the start. A Dockerfile that runs npm install against version ranges can produce two different images from the same commit a week apart, because a new dependency release came out in between.

A Dockerfile is a text file of instructions. docker build executes them in order to produce an image. Use the main lab VM and unpack the lesson files into ~/lab/dockerfile. You don't need Node.js on the VM. The one step that needs it runs in a container.

The application

The service is a few lines of JavaScript on Express, a small web framework for Node.js. It answers every request to / with one line of text. It closes its listener cleanly when it receives SIGTERM, the signal docker stop sends. "CMD, ENTRYPOINT and PID 1" explains why that handler matters. package.json lists the single dependency with a version range.

server.js
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => res.send('Hello from inside a container\n'));
const server = app.listen(port, () => console.log(`listening on port ${port}`));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
package.json
{
"name": "hello-docker",
"version": "1.0.0",
"private": true,
"dependencies": {
"express": "^5.1.0"
}
}

^5.1.0 means "5.1.0 or any later 5.x". A lockfile, package-lock.json, records the exact version npm resolved for every package in the tree. That includes the dependencies of Express itself, each with a checksum of its download. With the lockfile committed, every build installs the same tree. Generate it with the official Node image instead of a local Node install. The flags matter. -v "$PWD":/app makes the current directory visible inside the container at /app (a bind mount, covered in "Volumes and bind mounts"). -w /app starts the command there. -u "$(id -u):$(id -g)" runs it as your own user, so the new file belongs to you and not to root. npm_config_cache points npm's download cache at a writable place inside the container.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ ls -A
Dockerfile package.json server.js
$ docker run --rm -u "$(id -u):$(id -g)" -e npm_config_cache=/tmp/npm -v "$PWD":/app -w /app node:26-alpine npm install --package-lock-only
up to date, audited 66 packages in 5s 29 packages are looking for funding run `npm fund` for details found 0 vulnerabilities ...
$ ls -A
Dockerfile package-lock.json package.json server.js

npm also prints a notice about newer npm releases, shortened here. In this run the range ^5.1.0 resolved to a tree of 65 packages, Express and everything it depends on. Your lockfile pins whatever is current when you generate it, and from then on it changes only when you regenerate it.

The Dockerfile

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

The two COPY lines are deliberate. Dependencies change rarely and the code changes constantly, so installing dependencies before the code arrives lets a later build reuse the installed packages when only server.js changed. "Layers and the build cache" measures the difference.

Build it

docker build -t lab-hello:1 . builds the image and tags it. The tag is name:version. Without the :1 Docker would use latest, which says nothing about what's inside. The final . is the build context: the directory whose files the build may read. COPY can only copy from the context, never from elsewhere on the disk.

docker build hands the work to BuildKit, the build engine inside Docker Engine. Buildx is the CLI plugin that drives it. The output below is BuildKit's plain progress format, the one CI logs show. In an interactive terminal you see the same steps as a live, collapsing panel. --no-cache makes this build run every step the way a first build on a fresh machine does. Normally you'd leave it out.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker build --no-cache -t lab-hello:1 .
#0 building with "default" instance using docker driver #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 190B done #1 DONE 0.0s #2 [internal] load metadata for docker.io/library/node:26-alpine #2 DONE 0.0s #3 [internal] load .dockerignore #3 transferring context: 2B done #3 DONE 0.0s #4 [1/5] FROM docker.io/library/node:26-alpine@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797 #4 resolve docker.io/library/node:26-alpine@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797 0.0s done #4 DONE 0.0s #5 [internal] load build context #5 transferring context: 30.57kB done #5 DONE 0.0s #6 [2/5] WORKDIR /app #6 CACHED #7 [3/5] COPY package.json package-lock.json ./ #7 DONE 0.0s #8 [4/5] RUN npm ci --omit=dev #8 1.842 #8 1.842 added 65 packages, and audited 66 packages in 2s #8 1.842 #8 1.842 29 packages are looking for funding #8 1.842 run `npm fund` for details #8 1.843 #8 1.843 found 0 vulnerabilities ... #8 DONE 1.9s #9 [5/5] COPY . . #9 DONE 0.0s #10 exporting to image #10 exporting layers #10 exporting layers 0.2s done #10 exporting manifest sha256:4fb9fb9756d49404ad6e2d4a26e938031ddf8feba97cd4cddce75fc5930264a1 done #10 exporting config sha256:656c6b18711a9646d1153458772f449c3b4b0fd22466d414e17d67725bc12ae6 done #10 exporting attestation manifest sha256:892de66a945936022166ec7f4ee0d36ec7ee30d22d779a59be5bf547eb6b51de done #10 exporting manifest list sha256:fe8cb4ba9cfd0bf24fb8f4c4d95be9d7ed81f10af79d0e6d9feba3f3a8fe8a61 done #10 naming to docker.io/library/lab-hello:1 done #10 unpacking to docker.io/library/lab-hello:1 0.1s done #10 DONE 0.3s

Read it from the top. The [internal] steps load the Dockerfile, the .dockerignore file (there is none yet) and the build context. [1/5] FROM resolves the tag node:26-alpine to a content digest. That's the sha256: value that identifies exactly which image was used. "Tags, digests and promotion" in Docker in depth explains why production Dockerfiles often pin that digest. Steps 2 to 5 are your instructions. BuildKit still reported WORKDIR as CACHED despite --no-cache in this run, but the steps that do real work all ran. npm ci added 65 packages in about two seconds.

The export at the end is specific to Docker 29 with the containerd image store, the default on new installs. BuildKit writes the image manifest and config, an attestation manifest, and a manifest list that ties them together. The attestation is a small provenance record describing how the image was built, covered in "Pinning, SBOMs, provenance and scanning" in Advanced container security. "unpacking to" prepares the layers so containers can start from them. Digests in your output will differ.

Run it

Start a container from the image, publish its port on the VM's loopback address, and send a request. The log line is the console.log from server.js, and id confirms the process runs as node, not root.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker run -d --name lab-hello -p 127.0.0.1:3000:3000 lab-hello:1
c026802acfc0148871d4ec0a2d5755a6aacb7537933dc56097b92963c44779e7
$ curl -s http://127.0.0.1:3000/
Hello from inside a container
$ docker logs lab-hello
listening on port 3000
$ docker exec lab-hello id
uid=1000(node) gid=1000(node) groups=1000(node)
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker image ls lab-hello
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-hello:1 fe8cb4ba9cfd 269MB 69.9MB U

Docker 29's docker image ls shows two sizes. DISK USAGE is what the image occupies on this host, unpacked, including the base image layers it shares with other images. CONTENT SIZE is the compressed size of its layers, close to what a registry push or pull transfers. The U under EXTRA marks an image that a container is using. "Layers and the build cache" returns to these numbers.

What COPY . . picks up

COPY . . copies everything in the build context that isn't excluded. Projects collect files that must never reach an image: .env files with credentials, the .git directory, a local node_modules built for another platform. Simulate the first with a fake token and build again.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ printf 'API_TOKEN=example-only-not-real\n' > .env
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker build -t lab-hello:2 .
... #3 [internal] load .dockerignore #3 transferring context: 2B done #3 DONE 0.0s ...
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker run --rm lab-hello:2 cat .env
API_TOKEN=example-only-not-real

The token is now inside the image, readable by anyone who can pull it. Deleting the file in a later instruction wouldn't remove it from the earlier layer. A .dockerignore file next to the Dockerfile lists paths the build context excludes, using the same pattern style as .gitignore. Excluding the Dockerfile and .dockerignore themselves keeps them out of the image too, and the build still reads them.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ printf '%s\n' .env .git node_modules 'Dockerfile*' .dockerignore > .dockerignore cat .dockerignore
.env .git node_modules Dockerfile* .dockerignore
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker build -t lab-hello:3 .
... #3 [internal] load .dockerignore #3 transferring context: 89B done #3 DONE 0.0s ...
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker run --rm lab-hello:3 ls -A
node_modules package-lock.json package.json server.js

The .dockerignore step now transfers a file (89B, which includes BuildKit's metadata) instead of the empty 2B transfer before. The image contains only the application, its manifests and the installed node_modules. Write .dockerignore before a project's first build.

Watch out
A secret that reached an image layer is in every copy of that image: in your local store, in the registry and on every host that pulled it. Rotate the credential, do not just rebuild. Build-time secrets have a proper mechanism, RUN --mount=type=secret, covered in "BuildKit builds: stages, cache and mounts" (Docker in depth) and, with the ways images leak, in "Build-time secrets and how images leak them" (Advanced container security).

A container that will not stay up

The second classic mistake is a CMD that names a file that isn't there. Create a copy of the Dockerfile with the wrong filename and build it with -f, which selects a Dockerfile by name. The build succeeds, because Docker doesn't check what CMD refers to. The container starts and dies.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ sed 's/server.js/app.js/' Dockerfile > Dockerfile.typo tail -n 1 Dockerfile.typo
CMD ["node", "app.js"]
$ docker build -q -f Dockerfile.typo -t lab-hello:typo .
sha256:7161b2c87f0e34aa7a5cea086eb7b7b061b2593c3eee8d0a8de07085079bbf18
$ docker run -d --name lab-hello-typo lab-hello:typo
a1e0d3c1ef09b02fc4409f5fda7c6783e5efcc07819f33415888ae40cdd07716
$ docker ps --filter name=lab-hello-typo
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
$ docker ps -a --filter name=lab-hello-typo --format 'table {{.Names}}\t{{.Command}}\t{{.Status}}'
NAMES COMMAND STATUS lab-hello-typo "docker-entrypoint.s…" Exited (1) 2 seconds ago
$ docker logs lab-hello-typo
node:internal/modules/cjs/loader:1607 throw err; ^ Error: Cannot find module '/app/app.js' ... code: 'MODULE_NOT_FOUND', requireStack: [] } Node.js v26.12.1

Exited (1) means Node started and failed, and the log names the missing file, /app/app.js. The COMMAND column shows docker-entrypoint.s… because the node base image wraps every command in its own entrypoint script. "CMD, ENTRYPOINT and PID 1" explains how that works. The same two commands from "Inspect, logs and exec", docker ps -a and docker logs, answer this kind of failure in seconds.

From a Dockerfile to a running container
1Dockerfile + context
instructions and the files COPY may read
2docker build
BuildKit runs each instruction
3image
layers + config, tagged lab-hello:1
4docker run
adds a writable layer and starts CMD
5container
the running process
One image can start any number of containers. Rebuilding the image does not change containers that are already running.

Clean up

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.9.2
$ docker rm -f lab-hello lab-hello-typo docker rmi lab-hello:1 lab-hello:2 lab-hello:3 lab-hello:typo
lab-hello lab-hello-typo Untagged: lab-hello:1 Deleted: sha256:fe8cb4ba9cfd0bf24fb8f4c4d95be9d7ed81f10af79d0e6d9feba3f3a8fe8a61 Untagged: lab-hello:2 Deleted: sha256:d13ee76564d21ddad20eb29242f7885175a349a0da19c86c7210ac97c3dca8a7 Untagged: lab-hello:3 Deleted: sha256:6a01430350dac0bf26475261d0a60b439a395deefa73128d73e0fda40f441cdb Untagged: lab-hello:typo Deleted: sha256:7161b2c87f0e34aa7a5cea086eb7b7b061b2593c3eee8d0a8de07085079bbf18

Try this

Rewrite .dockerignore with the same printf, but leave out 'Dockerfile*'. Rebuild as lab-hello:3 and run docker run --rm lab-hello:3 ls -A again. Predict which extra names show up compared with the listing above, check, then restore the full .dockerignore and rebuild.

Bottom line

Write .dockerignore and commit the lockfile before the first build, not after. lab-hello:2 shows why: COPY . . baked the .env token into a layer, and with a real token the fix is rotating the credential, not just rebuilding. And a successful build says nothing about CMD, since lab-hello:typo built cleanly and then exited with status 1 on a missing /app/app.js.

Quick check
01A Dockerfile runs COPY . . and then RUN npm install, and the repository has no lockfile. Builds from the same commit sometimes behave differently. What change makes them repeatable?
Incorrect — --no-cache only reruns the steps. npm still resolves the version ranges anew and can pick newer releases.
Incorrect — The base image does not change how npm resolves ranges. Without a lockfile any base gives drifting results.
Correct — The lockfile records exact versions and checksums, and npm ci installs exactly that tree or fails.
Incorrect — Running it twice resolves the same ranges twice; nothing gets pinned.
02In docker build -t lab-hello:1 ., what does the final dot refer to?
Incorrect — The Dockerfile is found by name (Dockerfile, or -f). The dot names a directory.
Correct — COPY and ADD can only read from the context, and .dockerignore filters what is in it.
Incorrect — Without -t the image has no name at all; the dot plays no part in naming.
Incorrect — Paths inside the image come from WORKDIR and the COPY destination, not from the context argument.
03Your project directory holds a .env file with a database password. The Dockerfile ends with COPY . . and there is no .dockerignore. You build and push the image. What did you ship?
Incorrect — COPY copies dotfiles like any other file. Only .dockerignore excludes them.
Incorrect — The file stays in the layer COPY created. A later delete only hides it in the final view of the filesystem.
Incorrect — Docker stores the file content in the layer. There is no linking back to the build host.
Correct — COPY . . took the whole context, and the lab read the file back with cat from the image.

Related