Writing a Dockerfile
Build a small Node.js image instruction by instruction, with a lockfile and .dockerignore.
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: 5a74f1a60d3f6c07ee6f1d3d01ae4b8891dc947ca6ef38064410a45c22352510This 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.
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)));
{"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.
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
FROM node:24-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY . .USER nodeEXPOSE 3000CMD ["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.
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.
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.
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.
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.
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.
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.
Clean up
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?docker build -t lab-hello:1 ., what does the final dot refer to?.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?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.