Your first GitLab CI pipeline: jobs, stages, and a cache that actually works
A ground-up walkthrough of .gitlab-ci.yml: jobs, stages, runners, variables, and a dependency cache keyed on your lockfile. Plus the self-hosted cache trap that cost us 90 seconds per job for nothing.
- gitlab
- ci-cd
- devops
You push a commit and nothing happens. No lint, no tests, no build. Whether the code actually works is something you find out later, usually from someone else. Or you have the opposite problem: the repo already has a .gitlab-ci.yml, four hundred lines of it, written by someone who left the company, and the team’s strategy is to never touch it.
Both problems have the same fix: learning the small set of primitives GitLab CI is built on. We’ll build a pipeline for a Node app starting from an empty file: jobs, stages, runners, variables, a first pass at reuse, and a dependency cache. Everything here is a sanitized version of configuration I run in production, including the part where the cache silently did nothing for months.
A pipeline is jobs grouped into stages
When you push, GitLab reads .gitlab-ci.yml from the repo root and creates a pipeline: a set of jobs, organized into stages. A job is the unit of work. It runs your commands in a fresh environment and passes or fails. Here’s a minimal real pipeline for a pnpm-based app:
image: node:24-slim
stages:
- quality
- build
lint:
stage: quality
script:
- corepack enable
- pnpm install --frozen-lockfile
- pnpm lint
typecheck:
stage: quality
script:
- corepack enable
- pnpm install --frozen-lockfile
- pnpm typecheckThen the build stage, which only starts once quality passes:
build-app:
stage: build
script:
- corepack enable
- pnpm install --frozen-lockfile
- pnpm buildThe rules that make this tick:
- Any top-level key with a
scriptis a job. The name is yours to choose. stagesdefines execution order. Jobs in the same stage run in parallel (runner capacity permitting):lintandtypecheckrun side by side.- A stage starts only when the previous stage fully succeeds.
build-appwon’t run iflintortypecheckfails, which is the reason to order stages at all: cheap checks first, expensive work only when they pass. - Each job starts from a clean checkout of your repo inside a fresh container of
image. Nothing survives from one job to the next unless you explicitly make it survive, which is what cache and artifacts are for (cache is covered below).
That last rule is the mental model that explains most CI confusion: a job is a disposable machine that clones your repo, runs your script, and gets destroyed — not a terminal you left open.
Cutting the repetition: default and before_script
Three jobs, three identical preambles. GitLab has a dedicated place for setup commands (before_script) and a default block that every job inherits:
default:
image: node:24-slim
before_script:
- corepack enable
- pnpm install --frozen-lockfile
stages:
- quality
- build
lint:
stage: quality
script: pnpm lint
typecheck:
stage: quality
script: pnpm typecheck
build-app:
stage: build
script: pnpm buildbefore_script runs before script in the same shell, so anything it sets up is available to your commands. Any job can override any default by declaring its own value — convenient now, and a footgun once you start sharing CI config across repos.
Where jobs run: runners
A runner is the process that picks up jobs and executes them. If your project lives on GitLab.com, instance runners are available with zero configuration and your jobs just run, which is why the file above works out of the box. Companies usually end up operating their own runners instead: cheaper at volume, faster, and able to reach internal systems. Self-hosted runners are registered with tags, and jobs select them by tag:
default:
image: node:24-slim
tags:
- frontend-k8sIf your job sits in pending forever, check the tags first. It usually means no online runner matches what you asked for.
On picking image: use the smallest image that has your toolchain, and pin the version (node:24-slim, not node:latest) so your builds don’t change just because someone published a new image.
Variables: the three places they live
You’ll define variables in three places, in increasing order of secrecy:
Global, in the file. Visible to every job. Fine for paths and flags:
variables:
STORE_DIR: $CI_PROJECT_DIR/.pnpm-storePer job. Same syntax inside a job, scoped to it:
build-app:
stage: build
variables:
NODE_OPTIONS: --max-old-space-size=4096
script: pnpm buildProject settings, for secrets. Settings → CI/CD → Variables in the GitLab UI. Values live outside the repo, and you can mark them masked (redacted from job logs) and protected (only exposed on protected branches). Deploy tokens, API keys, npm tokens: they go here, never in the YAML.
GitLab also injects dozens of predefined variables into every job. Three you’ll use constantly:
CI_COMMIT_BRANCH: the branch being built.CI_DEFAULT_BRANCH: usuallymain. Comparing these two is the classic “am I on main?” check.CI_PROJECT_DIR: absolute path of the checkout, useful for building other paths (you just saw it inSTORE_DIR).
Reuse without copy-paste: hidden jobs and extends
A job whose name starts with a dot never runs. That makes it a template, and extends lets real jobs inherit from it:
.node-defaults:
image: node:24-slim
tags:
- frontend-k8s
before_script:
- corepack enable
- pnpm install --frozen-lockfile
lint:
extends: .node-defaults
stage: quality
script: pnpm lintThis overlaps with default, and for a single configuration default needs less syntax. Hidden jobs earn their keep when you need two families of defaults in one file (say, Node jobs and deploy jobs with different images), or when the templates live in a different repo entirely and ten projects inherit from them. That cross-repo setup is where this series ends up in part 8, and extends is what makes it possible.
YAML anchors (&name / *name) solve the same problem and you’ll see them in the wild, but they only work within a single file, while extends also works across included files. I default to extends.
Cache 101: stop reinstalling the world
Every job in our pipeline runs pnpm install from zero. On a real app that’s minutes per job, multiplied by every job, on every push. The fix is a cache: directories GitLab archives at the end of a job and restores at the start of the next.
The critical decision is the cache key. Key it on the lockfile, so the cache is reused while dependencies are unchanged and rebuilt when they change:
default:
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
before_script:
- corepack enable
- pnpm config set store-dir $CI_PROJECT_DIR/.pnpm-store
- pnpm install --frozen-lockfileTwo details that matter:
- Cached
pathsmust live inside the project directory. pnpm’s store defaults to a home-directory path, which is why we redirect it into the checkout withstore-dir. key: files:hashes the lockfile. When the lockfile changes, so does the key, and jobs start a fresh cache; until then the old one keeps being reused. You never invalidate anything by hand.
The first job on a new key pays full price and uploads the cache. Every job after that should log Restoring cache and install in a few seconds. Verify that in a real job log before you trust it.
The self-hosted cache trap
On GitLab.com’s instance runners, cache is distributed: it’s stored in object storage and any runner can restore what another runner saved. It works out of the box, which trains you to believe cache: in the YAML is all there is.
Then you move to self-hosted runners and that assumption quietly breaks. By default, a self-hosted runner stores cache locally, wherever the job executed. On a Docker executor that’s a volume on that one machine: caching works per-machine, with misses when another machine picks up your job. On a Kubernetes executor it’s worse: the cache is written inside the job’s pod, the pod is destroyed when the job ends, and nothing survives. Every single job is a cache miss, forever.
The nasty part is that nothing looks broken. The YAML is identical, the jobs are green, and the log even prints Saving cache right before the pod evaporates with the archive inside.
You can’t fix this from .gitlab-ci.yml. The setting lives in the runner’s own configuration file, config.toml, which means whoever operates the runner has to set it up. Cache needs an object-storage backend:
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "ci-runner-cache"
BucketLocation = "us-east-1"Type can be s3, gcs, or azure. Credentials can come from keys in the config or, on AWS, from the instance’s IAM profile. Shared = true lets all runners read and write the same bucket, which is what makes cache hits possible across machines and pods. The full reference is in the runner docs.
What bit us in production
We ran exactly this setup (YAML cache on a Kubernetes executor with no cache backend) for months without noticing, because green pipelines don’t invite questions.
What finally made us look wasn’t an error. Jobs just felt slower than they should be, so we started timing the sections of a job log, and one number stood out: about 90 seconds at the end of every job spent on Saving cache, archiving the pnpm store, roughly 165,000 small files.
The obvious follow-up question was when all of that got restored. We went looking for a Restoring cache line that pulled from a previous run and couldn’t find one, in any job, on any branch. It couldn’t exist: the cache was being written inside the job’s pod, and the pod was destroyed seconds later. Every job paid 90 seconds to warm a cache that no job could ever read.
The interim fix: disable caching entirely (our shared config resolves the cache block to cache: []) and leave the real configuration commented, ready for the day the runner gets an object-storage backend. Installs are slower than with a working cache, but we stopped paying 90 seconds per job for nothing.
Don’t trust the cache: block, read the log. Check that Restoring cache appears, that it restored from a run before this one, and that install time actually dropped. Five minutes doing that would have saved us months.
Where this leaves you
At this point you can read most .gitlab-ci.yml files you’ll run into, and write one from scratch that doesn’t reinstall the world on every job.
The next problems show up as soon as this pipeline grows, and the next three parts take them in order: part 2 makes the cache efficient (policies, fallback keys, and a cache split between protected and regular branches that GitLab doesn’t advertise), part 3 stops the redundant rebuilds with artifacts, and part 4 replaces stage barriers with a real dependency graph.