Skip to content
OpsAtlas

Write Your First Dockerfile

By OpsAtlas · · 4 min read

Table of contents

A Dockerfile is a recipe for a container image: start from a base image, copy your code in, install what it needs and say how to start it. In this tutorial you write one for a small Node.js API called notes-api, build it, run it and then improve it until it passes a best-practice review.

You need: Docker Desktop (or Docker Engine on Linux) and Node.js to create the lockfile. You don’t need to know Docker yet.

1. Create the app

Make a folder called notes-api with two files. The API has no dependencies, so it stays small:

{
  "name": "notes-api",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  }
}

Save that as package.json, and this as server.js:

const http = require('node:http');

const port = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200);
    return res.end('ok');
  }
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ notes: [] }));
});

server.listen(port, () => console.log(`notes-api listening on ${port}`));

// Exit promptly when Docker asks the container to stop.
process.on('SIGTERM', () => server.close(() => process.exit(0)));

Run npm install once. With no dependencies it installs nothing, but it creates package-lock.json, which the Dockerfile uses later.

2. Write the Dockerfile

Create a file named Dockerfile (no extension) next to the code:

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start

Line by line:

  • FROM node:22-slim starts from the official Node.js 22 image. slim is a smaller Debian variant without compilers.
  • WORKDIR /app creates /app and makes it the current folder for the following instructions and for the running container.
  • COPY . . copies everything in your project folder into /app.
  • RUN npm install runs at build time and installs the dependencies into the image.
  • EXPOSE 3000 documents the port the app listens on. It does not publish the port; docker run -p does that.
  • CMD npm start is the command the container runs when it starts.

Each instruction adds a layer to the image. Docker caches layers and reuses them on the next build if nothing they depend on has changed. That detail matters in step 5.

3. Build and run it

docker build -t notes-api:1.0.0 .
docker run --rm -p 3000:3000 --name notes-api notes-api:1.0.0
  • -t notes-api:1.0.0 names the image and gives it a version tag.
  • -p 3000:3000 maps port 3000 on your machine to port 3000 in the container.
  • --rm deletes the container when it stops.

Open http://localhost:3000 and you get {"notes":[]}. In a second terminal, run docker stop notes-api. Notice how long it takes. You’ll fix that in step 4.

4. Lint it and apply the safe fixes

The image works, but “works” is not the same as “good”. Paste the Dockerfile into the Dockerfile linter. It scores 79/100 (B) with five findings:

FindingSeverityWhat it meansFix
Runs as roothighNo USER, so the app runs as root inside the container.Apply fix: add a non-root USER
Shell-form CMDmediumCMD npm start runs through /bin/sh -c, which doesn’t pass the stop signal on. That’s why docker stop waited.Apply fix: CMD ["npm", "start"]
npm installlowIt can install different versions than the lockfile. npm ci installs exactly what package-lock.json pins.Apply fix: use npm ci
COPY . . before installinglowEvery code change invalidates the cache, so dependencies reinstall on every build.by hand, step 5
No HEALTHCHECKinfoDocker can’t tell a hung container from a healthy one.by hand, step 5

Click Apply fix on the first three. The changed lines are highlighted, and the score rises to 98/100 (A):

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
EXPOSE 3000
USER 10001
CMD ["npm", "start"]

If you change your mind about a fix, its card stays in the list with an Undo button.

5. Finish the job by hand

Two findings need a decision from you. Here is the final Dockerfile, with the reasons below:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER 1000
HEALTHCHECK CMD ["node", "-e", "fetch('http://localhost:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "server.js"]
  • Copy the lockfile first. package.json and package-lock.json rarely change, so the npm ci layer stays cached while you edit server.js. Only the last COPY . . reruns.
  • --omit=dev skips development dependencies the running app doesn’t need.
  • USER 1000 instead of the linter’s 10001. The official Node.js images already have a non-root user called node with ID 1000. Keep it numeric: Kubernetes can only check that a container is non-root when the user is a number, which matters as soon as you deploy to Kubernetes.
  • CMD ["node", "server.js"] starts Node directly, so the SIGTERM handler in server.js receives the stop signal. Running it through npm start adds an extra process in between.
  • HEALTHCHECK calls /healthz with Node’s built-in fetch, because the slim image has no curl.

Paste it into the linter again: 100/100 (A), no findings.

Finally, add a .dockerignore so COPY . . doesn’t copy things that don’t belong in the image. The linter can’t see this file, so it’s up to you:

node_modules
.git
.env
Dockerfile

6. Rebuild and check

docker build -t notes-api:1.0.0 .
docker run -d --rm -p 3000:3000 --name notes-api notes-api:1.0.0
docker ps                              # STATUS: "health: starting", then "(healthy)" after ~30 s (the default interval)
docker exec notes-api id               # uid=1000(node) — not root
time docker stop notes-api             # stops in well under a second

Change a line in server.js and build again. The npm ci step now shows CACHED.

Next steps

You have an image that is small, starts as a non-root user, stops cleanly and rebuilds quickly. Next, run it alongside a database in A Local Dev Stack with Docker Compose. To understand the mistakes the linter checks for in more depth, read Dockerfile mistakes that leak secrets and bloat images.

Try it yourself

  • Dockerfile linter and fixer

    Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.

    DockerBeta