Skip to content
Chapter 4Docker28.x

Your first real Dockerfile, line by line

Containerise a working Node API from scratch. Every instruction is explained, and the final image is one you would actually be willing to deploy.

4 min read

We are going to containerise a small API. Type it out rather than copying — the point is to understand each line, and the mistakes you make typing it are the ones worth making now.

The application

Create a directory and three files.

mkdir docker-first && cd docker-first

package.json:

{
  "name": "docker-first",
  "version": "1.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": { "start": "node server.js" },
  "dependencies": { "express": "^5.0.0" }
}

server.js:

import express from 'express';

const app = express();
const port = process.env.PORT ?? 3000;

app.get('/', (req, res) => {
  res.json({ message: 'Running in a container', hostname: process.env.HOSTNAME });
});

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.listen(port, '0.0.0.0', () => console.log(`listening on ${port}`));

The naive Dockerfile

Start with the version most tutorials give you, so we can see what is wrong with it.

FROM node:22
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

Build and run it:

docker build -t first-api:naive .
docker run --rm -p 3000:3000 first-api:naive

Visit http://localhost:3000. It works. Now check the size:

docker images first-api:naive

Roughly 1.1 GB — for an application that is a few kilobytes. And every source change re-installs every dependency, because COPY . . invalidates the cache before npm install runs.

The Dockerfile you would actually ship

# syntax=docker/dockerfile:1

# ---- build stage -----------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

# ---- runtime stage ---------------------------------------------------------
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app

RUN addgroup -S app && adduser -S app -G app

COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --chown=app:app . .

USER app
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "server.js"]

Build it and compare:

docker build -t first-api:good .
docker images first-api

About 180 MB, and changing server.js now rebuilds in under a second.

Every line, explained

# syntax=docker/dockerfile:1 — opts into the current BuildKit frontend, which gives you better caching and features like RUN --mount. Cheap, always worth it.

FROM node:22-alpine AS build — Alpine is ~5 MB against ~350 MB for the default Debian-based image. AS build names this stage so a later stage can copy from it.

COPY package*.json ./ before RUN npm ci — the cache trick from chapter three. Dependencies only reinstall when the manifest changes, not on every source edit. This one line is the difference between a 40-second and a 1-second rebuild.

npm ci --omit=devci installs exactly what the lockfile says and fails if it disagrees with package.json. npm install can silently update it. In a build, you always want ci.

The second FROM — this is the multi-stage build, and it is where the size goes. The runtime stage starts fresh; only what you explicitly COPY --from=build comes across. Build tools, caches and intermediate files stay behind.

RUN addgroup … && adduser … plus USER app — by default a container runs as root, and a container escape then has root on the host. Running as an unprivileged user costs two lines.

EXPOSE 3000 — documentation only. It does not publish anything; -p does that. It tells readers and some tooling which port the image expects to serve on.

HEALTHCHECK — lets Docker, Compose and orchestrators know whether the process is actually serving, not merely running. Compose’s depends_on: condition: service_healthy relies on it.

CMD ["node", "server.js"] — note the JSON array form. The shell form (CMD node server.js) wraps your process in /bin/sh, which does not forward signals, so docker stop waits the full ten seconds and then kills it. Always use the array form.

Add a .dockerignore

Create .dockerignore next to the Dockerfile:

node_modules
npm-debug.log
.git
.gitignore
.env
*.md
dist
coverage
.DS_Store

Without it, COPY . . sends your entire node_modules and .git to the build daemon, then overwrites the carefully installed modules with your host’s — which may be built for a different platform. This is a genuinely common and very confusing bug.

Run it properly

docker run -d --name api -p 3000:3000 first-api:good
docker ps
curl localhost:3000/health
docker logs api
docker stop api && docker rm api

You now have a small, cached, non-root, health-checked image. Next: the commands you will actually use every day.