Overview
So far you have run images that other people built. In this lab you build your own. You will write a Dockerfile for a small Node.js web API, build it into an image, run it, configure it, change it and rebuild it. You do not need Node.js installed on your computer. Everything the application needs will come from the image, which is the point.
Learning Objectives
By the end of this lab you will be able to:
- Write a Dockerfile using FROM, WORKDIR, COPY, RUN, ENV, EXPOSE and CMD
- Explain the build context and use a .dockerignore file to control it
- Build, tag and run an image of your own
- Configure one image differently at run time with environment variables
- Use the edit, rebuild and rerun loop, and recognise cached build steps
- Distinguish CMD from ENTRYPOINT
Concept
A Dockerfile is a plain text recipe for an image. Each line is an instruction, and Docker runs them in order, top to bottom. The instructions you will use most are these.
| Instruction | What it does |
|---|---|
FROM | The starting image. Almost every Dockerfile builds on an existing image, such as one that already contains Node.js. |
WORKDIR | Sets the folder inside the image that later instructions work in, creating it if needed. |
COPY | Copies files from your project into the image. |
RUN | Runs a command while building, such as installing packages. Its result is saved in the image. |
ENV | Sets an environment variable that the running application will see. |
EXPOSE | Documents which port the application listens on. It does not publish it. |
CMD | The command to run when a container starts. Only the last CMD counts. |
The difference between RUN and CMD trips up most beginners. RUN happens once, at build time, on the build machine. CMD happens every time a container starts.
Each instruction that changes the file system produces a layer, a set of file changes stacked on the layers before it. Docker remembers layers in a build cache. On a rebuild, if an instruction and everything it depends on are unchanged, Docker reuses the cached layer instead of running the instruction again. As soon as one layer changes, every layer after it must be rebuilt. The Building Efficient Images lab is about using this to make builds fast.
When you run docker build, the folder you name, usually . for the current folder, is the build context. Docker sends the whole context to the engine, and COPY can only see files inside it. A .dockerignore file lists things to leave out.
Tasks
Task 1 - Create the application
Create a new folder called hello-api, open it in your editor and create two files. The first is a tiny web API written with Express, a popular Node.js web framework. It answers with a greeting and the host name of the machine it runs on. It reads its port and greeting from environment variables, which is what lets one image be configured in different ways.
// A web API with two routes. The greeting and port come from environment
// variables so the same image can be configured differently at run time.
const express = require('express');
const os = require('node:os');
const app = express();
const port = Number(process.env.PORT ?? 3000);
const greeting = process.env.GREETING ?? 'Hello';
app.get('/', (req, res) => {
res.json({ message: `${greeting} from inside a container`, host: os.hostname() });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(port, () => {
console.log(`hello-api listening on port ${port}`);
});
The second file lists the packages the application depends on.
{
"name": "hello-api",
"version": "1.0.0",
"private": true,
"description": "The smallest useful web API, used to write a first Dockerfile",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
Task 2 - Use a container as a tool
Node.js projects record the exact version of every dependency in package-lock.json, so that every install is identical. Creating it normally needs Node.js on your computer. Instead, borrow Node.js from a container for a few seconds. Run this from inside the hello-api folder.
docker run --rm -v "$(pwd):/app" -w /app node:22-alpine npm install --package-lock-only
This mounts your folder into a node:22-alpine container at /app, makes that the working folder with -w, and runs npm there. The container is deleted afterwards, but the file it wrote is in your folder.
Success condition: package-lock.json now exists beside your other two files. Containers are often used like this, to run a tool without installing it.
Task 3 - Write the Dockerfile
Create a file named exactly Dockerfile, with a capital D and no extension, in the hello-api folder. Complete each TODO using the table in the concept section.
- Start from the image
node:22-alpine. - Work in the folder
/app. - Copy
package.jsonandpackage-lock.jsoninto the working folder. The patternpackage*.jsonmatches both, and./means the current working folder. - Run
npm ci, which installs exactly what the lock file records. - Copy
server.jsinto the working folder. - Start the application with
node server.js, written as a JSON array of words.
# TODO: start from an official image that already contains Node.js
# TODO: every later instruction runs inside this folder in the image
# TODO: copy the dependency list
# TODO: install the dependencies
# TODO: copy the application code
# Default configuration, which can be overridden with docker run -e
ENV PORT=3000
ENV GREETING=Hello
# Documents the port the app listens on. It does not publish it.
EXPOSE 3000
# TODO: the command a container runs when it starts
Now create .dockerignore. If you ever run npm install on your own machine, a node_modules folder appears, built for your operating system. It must never be copied into a Linux image, and leaving it out keeps the build context small.
node_modules
npm-debug.log
.git
Dockerfile
.dockerignore
.devcontainer
Task 4 - Build the image
docker build -t hello-api:1.0 .
-t gives the image a name and a tag, in the form name:tag. The final . is the build context. Watch the numbered steps in the output, one for each instruction, such as [4/5] RUN npm ci.
Success condition: the build ends with naming to docker.io/library/hello-api:1.0, and docker image ls hello-api lists your image. If the build fails, read the step number in the error. It tells you which instruction to look at.
docker image ls hello-api
docker history hello-api:1.0
docker history lists the layers of the image, newest first. The top ones are yours, with the instruction that made each and its size. Below them are the layers inherited from node:22-alpine.
Task 5 - Run it
docker run -d --name hello -p 3000:3000 hello-api:1.0
curl http://localhost:3000
curl http://localhost:3000/health
You can also open http://localhost:3000 in a browser.
Success condition: the API answers {"message":"Hello from inside a container","host":"..."}. The host is the container ID. Your own computer's Node.js, if it has one, played no part.
Look inside to see exactly what went into the image.
docker exec hello ls
There is node_modules, installed during the build, the two package files and server.js. There is no Dockerfile and no .dockerignore, because .dockerignore kept them out of the build context.
Task 6 - One image, many configurations
Start a second container from the same image, with a different greeting and on a different port of your machine.
docker run -d --name hello-cy -p 3001:3000 -e GREETING="Bore da" hello-api:1.0
curl http://localhost:3000
curl http://localhost:3001
Success condition: port 3000 says Hello, port 3001 says Bore da, and each reports a different host. Both containers listen on port 3000 internally without conflict, because each has its own network namespace. The value given with -e overrides the default set by ENV in the Dockerfile. Building once and configuring at run time is how the same image moves unchanged from a laptop to testing to production.
Task 7 - Change the code and rebuild
Edit server.js so the message says from my rebuilt image instead of from inside a container. Save it, then ask the running container again.
curl http://localhost:3000
The old message is still there. The container is running the copy of server.js that was put into the image at build time. Editing your file changes nothing until you rebuild the image and replace the container.
docker build -t hello-api:1.1 .
docker rm -f hello
docker run -d --name hello -p 3000:3000 hello-api:1.1
curl http://localhost:3000
Success condition: the new message appears. Look back at the build output. Steps up to and including RUN npm ci say CACHED. Only the step that copies server.js ran again, because only that file changed. This is why the Dockerfile copies the package files and installs dependencies before copying the code.
Task 8 - CMD and ENTRYPOINT
Anything you type after the image name in docker run replaces CMD for that one container.
docker run --rm hello-api:1.1 node --version
docker run --rm hello-api:1.1 ls
Each container ran your command instead of starting the server, then exited. A second instruction, ENTRYPOINT, behaves differently. When an image has an ENTRYPOINT, the words after the image name are added to it as arguments instead of replacing it. It suits images that wrap a single tool, so that docker run mytool --help runs mytool --help.
# Image that wraps one tool
ENTRYPOINT ["curl"]
CMD ["--help"]
# docker run mycurl runs: curl --help
# docker run mycurl https://example.com runs: curl https://example.com
Always write CMD and ENTRYPOINT in the JSON array form, as you did. The plain form CMD node server.js starts a shell which then starts Node, and the shell does not pass on the signal that docker stop sends. Your application then cannot shut down cleanly and is killed after ten seconds.
Which instruction runs while the image is being built?
An image was built with EXPOSE 3000 and run with docker run -d myimage. Why can the browser not reach it?
You edit package.json to add a dependency and rebuild. Which steps run again?
Task 9 - Tidy up and evaluate
docker rm -f hello hello-cy
A colleague puts COPY . . as the first instruction after WORKDIR, followed by RUN npm ci. The image works. What will they notice over a day of development, and why?
Why is it better to pass the greeting as an environment variable than to build a separate image for each greeting?
Solution
Try the tasks above before revealing the solution.