Multi-Stage Dockerfile

Building BookNest's API in a Multi-Stage Dockerfile

Each FROM starts a stage, AS name labels it, and COPY --from=name copies files out of an earlier one. BookNest now has four stages: base holds the manifests, deps installs production dependencies, test adds the test suite, and runtime, the last and therefore default stage, is the image that ships:

Dockerfile: BookNest's API as a multi-stage buildDockerfile
# syntax=docker/dockerfile:1
# BookNest API. Targets: test (runs the suite) and runtime (the default, last stage).
ARG NODE_IMAGE=node:24-slim
FROM ${NODE_IMAGE} AS base
WORKDIR /app
COPY package.json package-lock.json ./
# npm's downloads live in a cache mount; an optional "npmrc" secret can carry a token.
FROM base AS deps
RUN --mount=type=cache,target=/root/.npm --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev --no-audit --no-fund
# All dependencies plus the tests; run it next to PostgreSQL. Nothing ships from here.
FROM base AS test
RUN --mount=type=cache,target=/root/.npm --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --no-audit --no-fund
COPY . .
ENV NODE_ENV=test
USER 1000:1000
CMD ["npm", "test"]
# The production image: runtime files only, unprivileged UID 1000.
FROM ${NODE_IMAGE} AS runtime
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV} PORT=3000
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json app.js server.js ./
COPY db ./db
COPY public ./public
USER 1000:1000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["node", "-e", \
  "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
CMD ["node", "server.js"]

An ARG before the first FROM parameterizes the base (--build-arg NODE_IMAGE=node:24.21.0-slim pins a release). A build step cannot reach PostgreSQL 1,289 , so the test stage is an image whose default command runs the tests. --target picks the stage to build; commit once the tests pass:

Building both targets, running the tests and committingShell
docker build -q -t l3-booknest-api:1.1 . >/dev/null
docker build -q --target test -t l3-booknest-api:1.1-test . >/dev/null
docker network create l3-bn >/dev/null
docker run -d --name l3-db --network l3-bn --network-alias postgres \
  --env-file ../booknest.env postgres:18 >/dev/null
sleep 6
docker run --rm --network l3-bn -e PGHOST=postgres l3-booknest-api:1.1-test |
  grep -E '^ℹ (tests|pass|fail)'
git commit -q -am "Split the API Dockerfile into deps, test and runtime stages"
git log --oneline -1
Output
ℹ tests 9
ℹ pass 9
ℹ fail 0
319a4fb Split the API Dockerfile into deps, test and runtime stages

Jenkins 8,793 (Jenkins) runs this same pair: the test target next to a database, then the runtime target if it passes.