Sign in
← CI/CD: hands-on
14 min · easy · Lesson 7 of 10

A real build: your own image and npm in the pipeline

We swap echo for a real bundler: a node image, npm ci, esbuild, and dist in the artifacts. We dig into where a job's environment comes from.

What was wrong with the previous build

Until now, the "build" was the cp command. A real project is built with a tool: a bundler, a compiler, a static site generator. You have to get that tool from somewhere, and that's where a job property we haven't touched yet comes in: the image it runs in.

Grab the lesson materials:

git pull --no-rebase upstream main
cp -r lesson-07-build/src lesson-07-build/package.json lesson-07-build/package-lock.json .
cp lesson-07-build/pipeline.yml .gitlab-ci.yml

src/ has index.html and app.js; style.css stays in the root from previous lessons.

image: the config sets the environment

Every job runs in a container, and you choose the image:

build:
  stage: build
  image: node:22.11.0-alpine
  script:
    - npm ci
    - npm run build

Without image, the runner uses its own default image. On GitLab.com's shared runners that's ruby:3.1: there's no Node or npm in it, and the job fails with sh: npm: not found or a similar error. On a self-hosted runner, whoever set it up decides the default image. An explicit image settles the "works on my machine, not on CI" question: the tool version is recorded in the repository next to the code.

About the tag. node:22.11.0-alpine pins the exact Node version. The tag node:22-alpine (the one in pipeline.yml from ci-start) moves: today it points to one 22.x minor version, in a month to another. The build usually doesn't break because of this, but "it built yesterday, not today" starts exactly this way. That's why the course labs use an exact tag. The alpine variant is used for its size: node:22.11.0-alpine weighs around 150 MB, while the full node:22.11.0 is several times heavier. We'll get to what happens with an image with no tag at all in the lesson on anti-patterns.

npm ci, not npm install

    - npm ci
    - npm run build

npm ci installs dependencies strictly from package-lock.json and fails if the lock is out of sync with package.json. It doesn't touch package.json or the lock, and it removes the node_modules folder before installing. npm install in the same situation would silently patch the lock and move on, and CI would end up with different versions than you have locally.

The rule is simple: npm install on your machine, npm ci in the pipeline.

The build itself is described in package.json:

"scripts": {
  "build": "mkdir -p dist && esbuild src/app.js --bundle --minify --outfile=dist/app.js && cp src/index.html style.css dist/"
}

The pipeline knows nothing about esbuild. It calls npm run build, and the project decides what exactly builds it. You can swap the tool without touching CI.

Shipping the result as an artifact

  artifacts:
    paths:
      - dist

The build output sits in dist, and Pages expects a public folder, so the deploy stage just renames it:

pages:
  stage: deploy
  script:
    - mv dist public
  artifacts:
    paths:
      - public
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

pages has no image: node and no dependency install. Node is only needed for the build; deploy gets by with the default image and one mv command. Every job pulls in exactly the environment it needs.

How long this takes

There are two packages in the project: esbuild and its platform-specific binary (@esbuild/linux-x64 or @esbuild/linux-arm64), so npm ci prints added 2 packages and finishes in seconds. The rest of the job's time goes into pulling the image and starting the container.

On a real frontend with hundreds of dependencies, installing them becomes the longest part of the pipeline. What to do about that is in the next lesson.

Common mistakes

npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.

The lock is stale: someone changed package.json and didn't commit the updated lock. Further down in the same log, npm says which package didn't match. Fix it locally: npm install, then commit the updated package-lock.json.

sh: esbuild: not found

Dependencies weren't installed, or were installed in a different job. Every job starts from a clean container and a fresh copy of the repository, node_modules from a neighboring job doesn't carry over. That's why npm ci runs in the same job as the build.

sh: npm: not found: the job has no image with Node, and it runs in the default image.

npm error code EACCES in a homemade image: the build runs as a user without permission on the folder. The official node image runs as root by default, so this error doesn't happen there.

What to remember

image in a job sets the environment: the tool version lives in the repository, not in the head of whoever configured the runner. Write an exact tag.

npm ci is mandatory in CI: it installs exactly what's in the lock file and fails on a mismatch.

What exactly gets built is described by the project in package.json, the pipeline only calls npm run build.

Every job needs its own image and its own steps. Deploy doesn't need Node.

What you'll need for the tasks

The pipeline in the labs runs a learning runner on your laptop. Like a GitLab Runner with the Docker executor, it runs every job in a clean container with a fresh copy of the commit, and passes files between stages only through artifacts. Its default image is alpine:3.20.3. The job containers reach the internet: npm ci downloads packages from registry.npmjs.org.

Next we speed up installing dependencies with a cache, and look at how a cache differs from artifacts.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson cicd-practice-07-real-build