Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive ยท Docker

Writing Your First Dockerfile

Build a small Node.js image properly: layer caching, .dockerignore, a non-root user, health checks and the habits that keep images small and safe.

3 min read

  • Docker Engine 19.03.9
  • Node.js 12 LTS
  • node:12-alpine
  • Ubuntu 20.04 LTS

A Dockerfile is a recipe that builds an image, one instruction at a time. Each instruction creates a layer, and Docker caches layers, so the order of instructions decides whether a rebuild takes two seconds or two minutes. This post builds a small Node.js API image the way you would for production.

Versions in use on this date. Docker Engine 19.03.9, Node.js 12 (Active LTS; Node 14 is the current release and becomes LTS in October). node:12-alpine is based on Alpine Linux and is much smaller than the Debian-based image, with the caveat that Alpine uses musl instead of glibc, which some native modules do not like.

Build flow and caching

flowchart TB
  CTX[Build context<br/>files minus .dockerignore] --> F[FROM node 12-alpine]
  F --> W[WORKDIR /app]
  W --> P[COPY package files]
  P --> I[RUN npm ci --only=production<br/>cached until package files change]
  I --> S[COPY source code<br/>changes often]
  S --> U[USER node]
  U --> E[CMD node server.js]
  E --> IMG[Image tagged myapi 1.0.0]
  P -.->|unchanged| CA[Cache hit and skip install]

Put things that change rarely (dependency install) before things that change often (your source). Then editing a source file does not reinstall every dependency.

The application

// server.js
const http = require('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({ message: 'hello', version: process.env.APP_VERSION || 'dev' }));
});
server.listen(port, () => console.log(`listening on ${port}`));
process.on('SIGTERM', () => server.close(() => process.exit(0)));

The SIGTERM handler lets docker stop shut the process down cleanly instead of waiting 10 seconds for a kill.

The Dockerfile

FROM node:12-alpine
ENV NODE_ENV=production
WORKDIR /app

# dependencies first, to use the build cache
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force

# then the source
COPY . .

# do not run as root
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]

.dockerignore keeps junk out of the build context (faster builds, no leaked secrets):

node_modules
.git
.env
*.log
Dockerfile
npm-debug.log

Build and run

docker build -t myapi:1.0.0 .
docker images myapi
docker run -d --name myapi -p 3000:3000 -e APP_VERSION=1.0.0 myapi:1.0.0
curl localhost:3000
docker inspect --format '{{.State.Health.Status}}' myapi
docker history myapi:1.0.0            # layers and sizes

Rebuild after editing server.js: the npm ci layer shows Using cache, and only the COPY . . layer and later ones are re-run.

Instruction cheat sheet

Instruction Purpose
FROM Base image; pin a version tag
WORKDIR Set (and create) the working directory
COPY / ADD Copy files in; prefer COPY (ADD also fetches URLs and unpacks archives)
RUN Execute during build and bake the result into a layer
ENV / ARG Runtime variable / build-time variable
EXPOSE Documentation of the port; does not publish it
USER Drop root for the process
ENTRYPOINT / CMD The program, and its default arguments; use the JSON (exec) form so signals reach your app
HEALTHCHECK Tells Docker how to test the container

Habits that matter

  • Never put secrets in the image (ENV PASSWORD=..., copied .env): they remain in layers and in docker history. Pass them at runtime or use a secret store.
  • Pin base image versions (node:12.18-alpine), and rebuild regularly to receive security patches.
  • Combine related commands in one RUN and clean up in the same layer (rm -rf /var/cache/apk/*), because deleting a file in a later layer does not shrink the image.
  • One process per container, logging to stdout.
  • Keep images small: fewer packages mean fewer vulnerabilities (scanning is covered in a later post).
  • Tag with versions or Git commit hashes, not just latest.
  • Multi-stage builds, which separate build tools from the runtime image, are the next step; see the multi-stage post later in this series.