University of Wales Trinity Saint David
ACCA7023 - Agile and DevOps

Writing Your First Dockerfile

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:

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.

InstructionWhat it does
FROMThe starting image. Almost every Dockerfile builds on an existing image, such as one that already contains Node.js.
WORKDIRSets the folder inside the image that later instructions work in, creating it if needed.
COPYCopies files from your project into the image.
RUNRuns a command while building, such as installing packages. Its result is saved in the image.
ENVSets an environment variable that the running application will see.
EXPOSEDocuments which port the application listens on. It does not publish it.
CMDThe 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.

server.js
// 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.

package.json
{
  "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.

  1. Start from the image node:22-alpine.
  2. Work in the folder /app.
  3. Copy package.json and package-lock.json into the working folder. The pattern package*.json matches both, and ./ means the current working folder.
  4. Run npm ci, which installs exactly what the lock file records.
  5. Copy server.js into the working folder.
  6. Start the application with node server.js, written as a JSON array of words.
Dockerfile
# 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.

.dockerignore
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.

Dockerfile
# 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?

RUN executes during the build and its result is saved as a layer. CMD and ENTRYPOINT run each time a container starts.

An image was built with EXPOSE 3000 and run with docker run -d myimage. Why can the browser not reach it?

EXPOSE is documentation for people and tools. Publishing needs -p when the container is run.

You edit package.json to add a dependency and rebuild. Which steps run again?

The first instruction whose input changed is COPY package*.json. It and every later layer must be rebuilt.

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?

Every build will reinstall every dependency, even when only one line of code changed. COPY . . copies all files, so any edit changes that layer. Because RUN npm ci comes after it, its cached layer can no longer be used and it runs again in full, which turns a one second rebuild into a much longer one. Copying the package files first and installing before copying the source keeps the slow step cached until the dependencies actually change.

Why is it better to pass the greeting as an environment variable than to build a separate image for each greeting?

One image can be built and tested once and then run anywhere with different settings, so what reaches production is exactly what was tested. Building an image per configuration multiplies build time and storage, and creates images that could differ in ways other than the setting. Keeping configuration out of the image also means secrets and environment specific values are never baked into a file that might be shared.

Solution

Try the tasks above before revealing the solution.