Scenario
Section titled “Scenario”A Node.js service is packaged as a container. The Dockerfile has a test stage that runs the
suite, so the team believes a broken test cannot produce an image. Last week a broken test
produced an image.
Nobody had checked whether the test stage actually ran.
Objective
Section titled “Objective”Build a three-stage Dockerfile — dependencies, test, runtime — and prove three things locally
before writing the workflow: that the test stage is skipped by a default build, that
.dockerignore changes what gets sent to the daemon, and that a source-only change leaves the
dependency layer cached. Then write the CI job that builds the test target explicitly.
Prerequisites
Section titled “Prerequisites”- Docker CI with GitHub Actions — the reference pipeline
- Lab 14 provides the application; this lab packages it
- Docker installed and running (
docker version)
Starting state
Section titled “Starting state”The Node package from lab 14, plus one real dependency so the image has something to install:
mkdir -p /tmp/lab-dockerci/src /tmp/lab-dockerci/test && cd /tmp/lab-dockercigit init -q -b main .git config user.email "lab@example.com"git config user.name "Lab User"
cat > package.json <<'EOF'{ "name": "calc", "version": "0.1.0", "type": "module", "engines": { "node": ">=22" }, "scripts": { "test": "node --test", "start": "node src/cli.js" }, "dependencies": { "picocolors": "1.1.1" }}EOF
cat > src/calc.js <<'EOF'export function add(a, b) { return a + b;}
export function divide(a, b) { if (b === 0) throw new RangeError('cannot divide by zero'); return a / b;}EOF
cat > src/cli.js <<'EOF'import pc from 'picocolors';import { add } from './calc.js';
console.log(pc.green('add(2, 3) =') + ' ' + add(2, 3));EOF
cat > test/calc.test.js <<'EOF'import { test } from 'node:test';import assert from 'node:assert/strict';import { add, divide } from '../src/calc.js';
test('add', () => { assert.equal(add(2, 3), 5);});
test('divide', () => { assert.equal(divide(6, 3), 2);});
test('divide by zero throws', () => { assert.throws(() => divide(1, 0), RangeError);});EOF
npm install --package-lock-onlymkdir -p node_modules/.cache && echo junk > node_modules/.cache/x # a local install, as any dev machine hasgit add package.json package-lock.json src testgit commit -q -m "Node service with a dependency"-
Write the multi-stage Dockerfile. Dependencies, then tests, then a runtime that copies only what it needs:
Terminal window cat > Dockerfile <<'EOF'# syntax=docker/dockerfile:1FROM node:22-alpine AS baseWORKDIR /app# Dependencies first, so the layer is cached until the lockfile changes.FROM base AS depsCOPY package.json package-lock.json ./RUN npm ci --omit=dev# Tests in their own stage: test files never reach the runtime image.FROM deps AS testCOPY src ./srcCOPY test ./testRUN npm test# Runtime: only what is needed to run.FROM base AS runtimeENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY src ./srcUSER nodeCMD ["node", "src/cli.js"]EOF -
Build it, watching the context. No
.dockerignoreexists yet:Terminal window docker build --no-cache --progress=plain -t lab-calc:v1 . 2>&1 | grep 'transferring context'Note the size. Then add a
.dockerignoreand build again:Terminal window printf 'node_modules\n.git\n*.md\n' > .dockerignoredocker build --no-cache --progress=plain -t lab-calc:v1 . 2>&1 | grep 'transferring context' -
Prove the cache survives a source change. Build once more to warm the cache, then touch a source file and rebuild:
Terminal window docker build -q -t lab-calc:v1 .echo "// touched" >> src/calc.jsdocker build --progress=plain -t lab-calc:v1 . 2>&1 | grep -A1 'RUN npm ci'Is
npm cire-run orCACHED? -
Break a test and build with the default target:
Terminal window sed -i 's/assert.equal(add(2, 3), 5)/assert.equal(add(2, 3), 6)/' test/calc.test.jsdocker build -t lab-calc:v2 . ; echo "exit: $?"docker build --progress=plain -t lab-calc:v2 . 2>&1 | grep -c '\[test'The build succeeds. The second command counts how many log lines mention the test stage. What is the number, and what does it tell you?
-
Build the test stage explicitly:
Terminal window docker build --target test -t lab-calc:test . ; echo "exit: $?"sed -i 's/assert.equal(add(2, 3), 6)/assert.equal(add(2, 3), 5)/' test/calc.test.jsdocker build --target test -t lab-calc:test . ; echo "exit: $?" -
Write the CI job so the test target is built before the runtime target:
Terminal window mkdir -p .github/workflowscat > .github/workflows/docker.yml <<'EOF'name: Docker CIon:pull_request:push:branches: [main]permissions:contents: readjobs:build:runs-on: ubuntu-latesttimeout-minutes: 15steps:- uses: actions/checkout@v7- uses: docker/setup-buildx-action@v4- name: Run tests inside the imageuses: docker/build-push-action@v7with:context: .target: testpush: falsecache-from: type=ghacache-to: type=gha,mode=max- name: Build runtime imageuses: docker/build-push-action@v7with:context: .target: runtimepush: falsetags: calc:${{ github.sha }}cache-from: type=ghaEOFgit add . && git commit -q -m "Add Dockerfile, .dockerignore and CI" -
Push and open a pull request with the broken test. The first step fails; the runtime image is never built. Fix and push; both steps pass and the second run’s cache-from hits.
Validation
Section titled “Validation”Step 2’s context drops from over a kilobyte to 63 bytes. Step 3 shows CACHED under
RUN npm ci. Step 4 exits 0 with zero lines mentioning the test stage. Step 5 exits 1 with
the broken test and 0 after the fix. On GitHub, the broken PR fails at “Run tests inside the
image”.
Step 2. The context is everything in the directory not excluded by .dockerignore, sent to
the daemon before the first instruction runs. node_modules is usually the bulk of it — and it is
rebuilt inside the image anyway.
Step 3. COPY package.json package-lock.json comes before COPY src, so a change in src
does not invalidate the npm ci layer. Reverse the order and every source edit reinstalls
everything.
Step 4. BuildKit builds only the stages the target depends on. runtime copies from deps,
not from test. test is unreachable, so it is skipped — silently.
Step 5. --target test makes the test stage the goal, so it runs and its exit code is the
build’s exit code.
Solution
Section titled “Solution”Step 2 — context before and after .dockerignore:
#6 transferring context: 1.14kB done#5 transferring context: 63B doneStep 3 — source-only change:
#9 [deps 2/2] RUN npm ci --omit=dev#9 CACHEDStep 4 — broken test, default target:
exit: 00Zero log lines mention [test. The stage did not run.
Step 5 — broken test, --target test:
#13 1.562 not ok 1 - add#13 ERROR: process "/bin/sh -c npm test" did not complete successfully: exit code: 1exit: 1After the fix:
#13 1.517 # pass 3exit: 0The final image runs and is 232 MB (mostly the Node runtime):
$ docker run --rm lab-calc:v1add(2, 3) = 5Explanation
Section titled “Explanation”Unreferenced stages do not run. A multi-stage build is a graph, and the builder walks it backwards from the target. A stage nothing depends on is never visited. This is by design — it is what lets one Dockerfile hold dev, test and runtime variants — and it is exactly why “we have a test stage” is not the same as “tests run”.
Two targets, two steps. The CI job builds test first as a gate, then runtime. With
cache-from: type=gha, the second build reuses the deps layer the first one produced, so the
cost of the extra step is close to zero.
Layer order is cache strategy. Copy the things that change least, first. The lockfile changes weekly; source changes hourly. Installing dependencies before copying source is what makes the hourly builds fast.
.dockerignore is a security control as well as a speed one. .git, .env files and local
build output are all things you do not want in the context — and anything in the context can end
up in a layer.
Troubleshooting
Section titled “Troubleshooting”COPY --from=deps /app/node_modules fails with “not found”. The package has no dependencies,
so npm ci created no node_modules. The starting state adds one dependency for exactly this
reason.
Step 4 shows lines mentioning [test. You built with --target test earlier and the cache
served it. Add --no-cache to see the default graph, or check the target you passed.
type=gha cache errors locally. The GitHub Actions cache backend only works on GitHub-hosted
runners. Locally, omit cache-from/cache-to; the local layer cache does the same job.
Clean up
Section titled “Clean up”docker rmi -f lab-calc:v1 lab-calc:v2 lab-calc:test 2>/dev/nullcd /tmp && rm -rf lab-dockerciRelated lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Migrate a workflow from stored secrets to OIDC — remove the long-lived cloud key from the repository and prove nothing else can assume the role.