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: 5a74f1a60d3f6c07ee6f1d3d01ae4b8891dc947ca6ef38064410a45c22352510

This lesson writes a Dockerfile for a small Node.js web service, builds it, runs it, and covers the two mistakes that show up in almost every first Dockerfile: copying files you never meant to ship, and a CMD that points at the wrong file. It installs 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 that docker build executes in order to produce an image. Use the main lab VM and unpack the lesson files into ~/lab/dockerfile. You do not 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 and 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, including the dependencies of Express itself, together with a checksum of each 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, and -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.8.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:24-alpine npm install --package-lock-only
up to date, audited 69 packages in 3s 28 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 Express 5.2.1; 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:24-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 is 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 leave it out.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.8.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:24-alpine #2 DONE 0.0s #3 [internal] load .dockerignore #3 transferring context: 2B done #3 DONE 0.0s #4 [internal] load build context #4 transferring context: 31.80kB done #4 DONE 0.0s #5 [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 #5 resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s done #5 DONE 0.0s #6 [2/5] WORKDIR /app #6 CACHED #7 [3/5] COPY package.json package-lock.json ./ #7 DONE 0.1s #8 [4/5] RUN npm ci --omit=dev #8 1.744 #8 1.744 added 68 packages, and audited 69 packages in 1s #8 1.744 #8 1.744 28 packages are looking for funding #8 1.744 run `npm fund` for details #8 1.744 #8 1.744 found 0 vulnerabilities ... #8 DONE 1.8s #9 [5/5] COPY . . #9 DONE 0.1s #10 exporting to image #10 exporting layers #10 exporting layers 0.2s done #10 exporting manifest sha256:86f488818026e161726d7bcfeaf5d7436147a2e7a0a9cf843f3c02053f2f7e55 done #10 exporting config sha256:10be5d3e9f8d56270adc65f209f63e3e74c198444c27995f15ed08a95a89ea66 done #10 exporting attestation manifest sha256:3ad33f809aed929234fcc98bbcc824b0958fd481d78210cc32769af385837b40 0.0s done #10 exporting manifest list sha256:f6c9e8f5aa97ebe36551a88454432e6aeeb91175cdb451dd48f0228017cfcc96 0.0s done #10 naming to docker.io/library/lab-hello:1 done #10 unpacking to docker.io/library/lab-hello:1 #10 unpacking to docker.io/library/lab-hello:1 0.2s done #10 DONE 0.5s

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:24-alpine to a content digest, 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; the steps that do real work all ran. npm ci added 68 packages in about a second.

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 (a small provenance record describing how the image was built, covered in "Pinning, SBOMs, provenance and scanning" in Advanced container security), and a manifest list that ties them together. "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.8.2
$ docker run -d --name lab-hello -p 127.0.0.1:3000:3000 lab-hello:1
3f2a8480f1fbce08639ca79094829bf3cdfda439beba8518a6e4259d2fd92178
$ 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.8.2
$ docker image ls lab-hello
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-hello:1 f6c9e8f5aa97 249MB 64MB 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 is not 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.8.2
$ printf 'API_TOKEN=example-only-not-real\n' > .env
ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.8.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.8.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, and deleting the file in a later instruction would not 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; the build still reads them.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.8.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.8.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.8.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, and 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 is not 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 does not check what CMD refers to. The container starts and dies.

ubuntu@secopslog-docker:~/lab/dockerfile · Docker 29.8.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:5c2094a5164bd11f01fb6d45c92221a85f8c6c24ab952f558e1f4bcbe1cd666c
$ docker run -d --name lab-hello-typo lab-hello:typo
c7bff32caf13d235951a7fc51a6f7cda07d019ef853c9c0e84f82f795cc81495
$ 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:1568 throw err; ^ Error: Cannot find module '/app/app.js' ... code: 'MODULE_NOT_FOUND', requireStack: [] } Node.js v24.21.0

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.8.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:f6c9e8f5aa97ebe36551a88454432e6aeeb91175cdb451dd48f0228017cfcc96 Untagged: lab-hello:2 Deleted: sha256:796888f68b3f8f62da4e6e1bdc97fb6600b9182d0800730005fb2cc53270cd87 Untagged: lab-hello:3 Deleted: sha256:98b5c02d05008d75c7dddcfbde1c2c28298bc6f46c884febe6ff66e60a1b9175 Untagged: lab-hello:typo Deleted: sha256:5c2094a5164bd11f01fb6d45c92221a85f8c6c24ab952f558e1f4bcbe1cd666c
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.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from writing a dockerfile, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related