Overview
A Dockerfile that works is only the start. In this lab you take a TypeScript API, containerise it the obvious way, and measure the result. Then you rebuild it properly, using a smaller base image, a cache-friendly order of instructions and a multi-stage build, and measure again. When this lab was written the image went from 1.7 GB to 247 MB, rebuilt in seconds after a code change, and carried a small fraction of the known vulnerabilities.
Learning Objectives
By the end of this lab you will be able to:
- Measure an image and find where its size comes from with docker history
- Compare full, slim and Alpine base images and choose between them
- Order Dockerfile instructions so that the build cache is used effectively
- Write a multi-stage build that leaves build tools out of the final image
- Harden an image with production-only dependencies, a non-root user and a health check
- Scan an image for known vulnerabilities and compare the results
Concept
Image size and build speed are not vanity metrics. Every server, pipeline run and developer pulls the image, so a large one costs time, bandwidth and storage again and again. Every program inside an image is a program that might contain a vulnerability, so a large one also has a larger attack surface. And a build that takes minutes after a one-line change slows down every developer every day.
Four techniques deal with almost all of this.
- Choose a small base image.
node:22is built on full Debian with compilers and many tools.node:22-slimis Debian with most of those removed.node:22-alpineis built on Alpine Linux, a distribution designed to be tiny. Alpine uses a different C library (musl instead of glibc), which very occasionally breaks native packages, so slim is the safer choice when that happens. - Order instructions from least to most frequently changed. Layers after the first changed one are rebuilt, so slow steps that depend on rarely changed files, such as installing dependencies, belong before frequently changed files are copied.
- Use a multi-stage build. A Dockerfile can contain several
FROMinstructions, each starting a new stage. Early stages hold compilers and development packages and produce the build output. The final stage starts clean and usesCOPY --from=to take only that output. Everything else is discarded and never shipped. - Ship only what runs. Install production dependencies only, run as an unprivileged user, and leave out source code, tests and tools.
Tasks
Task 1 - Create the quotes API
This lab and the next three use a small API that stores quotations. It is written in TypeScript, so unlike hello-api it must be compiled to JavaScript before it can run, and that is what makes its image interesting. When the environment variable DATABASE_URL is set it stores quotes in Postgres. Otherwise it keeps them in memory.
Create a folder called quotes to hold the whole application. Inside it create a folder called quotes-api containing the following files, and run every command in this lab from inside quotes-api.
{
"name": "quotes-api",
"version": "1.0.0",
"private": true,
"description": "A small TypeScript API that stores quotes in Postgres or in memory",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^5.1.0",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.15.0",
"@types/pg": "^8.15.0",
"tsx": "^4.20.0",
"typescript": "^5.8.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
import { Pool } from 'pg';
export interface Quote {
id: number;
text: string;
author: string;
}
export interface QuoteStore {
kind: string;
list(): Promise<Quote[]>;
add(text: string, author: string): Promise<Quote>;
}
const SEED = [
{ text: 'Simplicity is prerequisite for reliability.', author: 'Edsger Dijkstra' },
{ text: 'It works on my machine.', author: 'Every developer, once' },
];
// Keeps quotes in the memory of this process. They vanish when it stops.
export class MemoryStore implements QuoteStore {
kind = 'memory';
private quotes: Quote[] = SEED.map((q, i) => ({ id: i + 1, ...q }));
async list() {
return this.quotes;
}
async add(text: string, author: string) {
const quote = { id: this.quotes.length + 1, text, author };
this.quotes.push(quote);
return quote;
}
}
// Keeps quotes in Postgres, so they outlive any one container.
export class PostgresStore implements QuoteStore {
kind = 'postgres';
private pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({ connectionString });
}
async init() {
await this.pool.query(
'CREATE TABLE IF NOT EXISTS quotes (id SERIAL PRIMARY KEY, text TEXT NOT NULL, author TEXT NOT NULL)',
);
const { rows } = await this.pool.query('SELECT COUNT(*)::int AS n FROM quotes');
if (rows[0].n === 0) {
for (const q of SEED) await this.add(q.text, q.author);
}
}
async list() {
const { rows } = await this.pool.query<Quote>('SELECT id, text, author FROM quotes ORDER BY id');
return rows;
}
async add(text: string, author: string) {
const { rows } = await this.pool.query<Quote>(
'INSERT INTO quotes (text, author) VALUES ($1, $2) RETURNING id, text, author',
[text, author],
);
return rows[0];
}
}
export async function createStore(): Promise<QuoteStore> {
const url = process.env.DATABASE_URL;
if (!url) return new MemoryStore();
const store = new PostgresStore(url);
await store.init();
return store;
}
import express from 'express';
import os from 'node:os';
import { createStore } from './store';
async function main() {
const store = await createStore();
const app = express();
app.use(express.json());
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', store: store.kind, version: process.env.APP_VERSION ?? 'dev' });
});
// Which container answered. Used to watch a load balancer share out requests.
app.get('/api/whoami', (_req, res) => {
res.json({ host: os.hostname() });
});
app.get('/api/quotes', async (_req, res) => {
res.json(await store.list());
});
app.post('/api/quotes', async (req, res) => {
const { text, author } = req.body ?? {};
if (typeof text !== 'string' || typeof author !== 'string' || !text || !author) {
res.status(400).json({ error: 'text and author are required' });
return;
}
res.status(201).json(await store.add(text, author));
});
const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
console.log(`quotes-api on port ${port} using ${store.kind} storage`);
});
// docker stop sends SIGTERM. Closing cleanly lets the container stop at once.
process.on('SIGTERM', () => server.close(() => process.exit(0)));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
node_modules
dist
npm-debug.log
.git
Dockerfile*
.dockerignore
Create the lock file with a container, as you did in the Writing Your First Dockerfile lab.
docker run --rm -v "$(pwd):/app" -w /app node:22-alpine npm install --package-lock-only
Task 2 - Containerise it the obvious way
Here is the Dockerfile most people write first. Save it as Dockerfile.naive. It works, and that is all that can be said for it.
# A first attempt that works, but is slow to rebuild and large to ship.
FROM node:22
WORKDIR /app
# Copying everything first means any code change invalidates the npm install below
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
-f tells Docker which Dockerfile to use when it is not called Dockerfile.
docker build -f Dockerfile.naive -t quotes-api:naive .
docker run -d --name naive -p 3000:3000 quotes-api:naive
curl http://localhost:3000/api/quotes
docker image ls quotes-api
Success condition: the API returns two quotes, and the image is roughly 1.7 GB. Your figure will differ slightly. Record it, because you will compare against it.
Task 3 - Find out where the size comes from
docker history quotes-api:naive
Read the SIZE column. Your own layers, mostly the npm install, add up to about 84 MB. Everything else, about 1.6 GB, was inherited from node:22 before you wrote a single instruction. Compare the three common Node.js base images.
docker pull node:22
docker pull node:22-slim
docker pull node:22-alpine
docker image ls node
When this lab was written the sizes were as follows.
| Base image | Built on | Size |
|---|---|---|
node:22 | Debian, with build tools | 1.6 GB |
node:22-slim | Debian, minimal | 326 MB |
node:22-alpine | Alpine Linux | 238 MB |
Every one of those runs Node.js 22 equally well. The difference is how much of an operating system comes with it.
An application uses a native package that crashes on Alpine because of the C library. Which base image is the sensible next choice?
Task 4 - See the build cache at work
Use the simulator to compare two orders of instructions. For each order, change the source code and then package.json, and watch which layers must be rebuilt.
Now confirm it for real. Add a comment line anywhere in src/server.ts, save it, and rebuild the naive image. time reports how long a command took.
time docker build -f Dockerfile.naive -t quotes-api:naive .
Success condition: the RUN npm install step runs again in full, even though no dependency changed. COPY . . copied the edited file, so its layer changed, and every layer after it was rebuilt.
Task 5 - Write a multi-stage Dockerfile
Create a new file called Dockerfile. The first stage, named build, compiles the TypeScript. It needs the full set of dependencies, including TypeScript itself. The --mount=type=cache option gives npm a download cache that persists between builds without becoming part of any layer.
The second stage is the image that ships. Complete each TODO.
- Copy the package files and install production dependencies only, with
npm ci --omit=dev. Use the same cache mount as the first stage. - Copy the compiled
/app/distfolder from thebuildstage to./dist. - Switch to the unprivileged user called
node, which the base image already provides. - Start the server with
node dist/server.js.
# Stage 1: compile TypeScript. This stage has every development tool.
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Stage 2: the image that ships. Only runtime dependencies and compiled code.
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
# TODO: copy the package files
# TODO: install production dependencies only
# TODO: copy the compiled code from the build stage
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
ENV PORT=3000
EXPOSE 3000
# TODO: run as the unprivileged user that the node image provides
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
# TODO: run node directly, not through npm, so it receives the stop signal
Two new instructions appear. ARG declares a value supplied at build time with --build-arg, here copied into an environment variable so the running API can report which version it is. HEALTHCHECK tells Docker how to test whether the application is actually working, not merely whether its process exists.
Task 6 - Build and compare
docker build --build-arg APP_VERSION=1.0 -t quotes-api:1.0 .
docker image ls quotes-api
Success condition: the new image is roughly 247 MB against 1.7 GB for the naive one, about 7 times smaller. Now repeat the experiment from Task 4. Add another comment to src/server.ts and time the rebuild.
time docker build --build-arg APP_VERSION=1.0 -t quotes-api:1.0 .
Success condition: both npm ci steps say CACHED. Only the steps from COPY src onwards ran.
Task 7 - Check what shipped
docker rm -f naive
docker run -d --name quotes -p 3000:3000 quotes-api:1.0
curl http://localhost:3000/api/health
docker exec quotes whoami
docker exec quotes ls
docker exec quotes ls node_modules
Success condition: the health check reports "version":"1.0". The user is node, not root, so a flaw in the application would give an attacker far less control. The /app folder holds dist, node_modules and the package files, and nothing else. There is no src folder and no typescript package. The compiler did its job in a stage that no longer exists.
Wait twenty seconds and list the containers.
docker ps
The status reads (healthy). Docker is running the health check every ten seconds. Orchestrators, which you will meet in the Docker at Scale lab, use exactly this signal to decide whether to send a container traffic or replace it.
Task 8 - Scan both images
Trivy is a widely used open source vulnerability scanner. It is itself distributed as an image, so nothing needs installing. This command lets it read your local images through Docker and reports only HIGH and CRITICAL findings. The first run downloads a vulnerability database, which takes a minute.
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache \
ghcr.io/aquasecurity/trivy:latest image --severity HIGH,CRITICAL quotes-api:naive
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache \
ghcr.io/aquasecurity/trivy:latest image --severity HIGH,CRITICAL quotes-api:1.0
New vulnerabilities are published every week, so your numbers will differ. When this lab was written the naive image had 608 high or critical findings, nearly all in Debian packages that the API never uses. The multi-stage image had 11, and every one was inside npm itself, which the base image includes. The Containerising the Application lab shows how to remove that too.
Success condition: you can say for each image roughly how many findings there are and in which part of the image they live.
Task 9 - Tidy up and evaluate
docker rm -f quotes
Explain, in terms of layers, why deleting files in a later RUN instruction does not make an image smaller, and how a multi-stage build avoids the problem.
Your team says image size does not matter because disk is cheap. Give three reasons it still matters.
Why does the final stage run node dist/server.js directly instead of npm start?
Solution
Try the tasks above before revealing the solution.