Skip to content

Lab: Build a Docker CI Pipeline

Lesson 4 of 4Intermediate → Advanced5 min readHands-On Git & GitHub Labs · CI/CD LabsVerified: Docker 29.7.2, Node.js 22 (node:22-alpine image)
Time30 minutes
LevelIntermediate → Advanced
You needDocker locally, and a GitHub repository to push to

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.

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.

The Node package from lab 14, plus one real dependency so the image has something to install:

Terminal window
mkdir -p /tmp/lab-dockerci/src /tmp/lab-dockerci/test && cd /tmp/lab-dockerci
git 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-only
mkdir -p node_modules/.cache && echo junk > node_modules/.cache/x # a local install, as any dev machine has
git add package.json package-lock.json src test
git commit -q -m "Node service with a dependency"
  1. 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:1
    FROM node:22-alpine AS base
    WORKDIR /app
    # Dependencies first, so the layer is cached until the lockfile changes.
    FROM base AS deps
    COPY 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 test
    COPY src ./src
    COPY test ./test
    RUN npm test
    # Runtime: only what is needed to run.
    FROM base AS runtime
    ENV NODE_ENV=production
    COPY --from=deps /app/node_modules ./node_modules
    COPY src ./src
    USER node
    CMD ["node", "src/cli.js"]
    EOF
  2. Build it, watching the context. No .dockerignore exists yet:

    Terminal window
    docker build --no-cache --progress=plain -t lab-calc:v1 . 2>&1 | grep 'transferring context'

    Note the size. Then add a .dockerignore and build again:

    Terminal window
    printf 'node_modules\n.git\n*.md\n' > .dockerignore
    docker build --no-cache --progress=plain -t lab-calc:v1 . 2>&1 | grep 'transferring context'
  3. 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.js
    docker build --progress=plain -t lab-calc:v1 . 2>&1 | grep -A1 'RUN npm ci'

    Is npm ci re-run or CACHED?

  4. 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.js
    docker 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?

  5. 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.js
    docker build --target test -t lab-calc:test . ; echo "exit: $?"
  6. Write the CI job so the test target is built before the runtime target:

    Terminal window
    mkdir -p .github/workflows
    cat > .github/workflows/docker.yml <<'EOF'
    name: Docker CI
    on:
    pull_request:
    push:
    branches: [main]
    permissions:
    contents: read
    jobs:
    build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
    - uses: actions/checkout@v7
    - uses: docker/setup-buildx-action@v4
    - name: Run tests inside the image
    uses: docker/build-push-action@v7
    with:
    context: .
    target: test
    push: false
    cache-from: type=gha
    cache-to: type=gha,mode=max
    - name: Build runtime image
    uses: docker/build-push-action@v7
    with:
    context: .
    target: runtime
    push: false
    tags: calc:${{ github.sha }}
    cache-from: type=gha
    EOF
    git add . && git commit -q -m "Add Dockerfile, .dockerignore and CI"
  7. 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.

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.

Step 2 — context before and after .dockerignore:

#6 transferring context: 1.14kB done
#5 transferring context: 63B done

Step 3 — source-only change:

#9 [deps 2/2] RUN npm ci --omit=dev
#9 CACHED

Step 4 — broken test, default target:

exit: 0
0

Zero 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: 1
exit: 1

After the fix:

#13 1.517 # pass 3
exit: 0

The final image runs and is 232 MB (mostly the Node runtime):

$ docker run --rm lab-calc:v1
add(2, 3) = 5

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.

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.

Terminal window
docker rmi -f lab-calc:v1 lab-calc:v2 lab-calc:test 2>/dev/null
cd /tmp && rm -rf lab-dockerci

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.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.