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: 16a42f7476055c8f5d9d16758a11c0cb28775d343a981ee95479e9f2e641cfe7You'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.
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. 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.
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
FROM node:26-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'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.
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.
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.
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.
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.
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.
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
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.
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?