Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

GitLab logo

Do your integration tests need a real database, not a mock? GitLab CI/CD runs services (PostgreSQL, Redis, Elasticsearch...) as containers next to your job. Your code connects to them as if they were local. Combined with a well-configured advanced cache, your pipelines are both complete and fast.

The Artifacts and cache lesson covered the basics. This guide goes deeper into advanced cases: Docker services, cache key strategies, policies and distributed cache.

What you will learn

  • Run services (PostgreSQL, Redis, MongoDB) in a CI job
  • Configure the connection between the job and the service (hostname, variables)
  • Master cache policies: pull-push, pull, push
  • Design robust cache keys (lock file, combinations)
  • Manage the distributed cache (S3) for autoscaled runners
  • Diagnose service and cache problems

Prerequisites

This lesson assumes you can write a pipeline with cache: and variables:, and that you understand how a runner executes a job. The examples target a runner with the Docker executor: the job and each service run as containers. The runner's config.toml settings (service wait timeout, S3 distributed cache) need access to the runner machine, or an administrator who applies them for you; services and cache keys only need edits to the .gitlab-ci.yml.

In which context?

Your unit tests pass, but your integration tests fail because they need a database. You could mock it, but the tests would not be realistic. You need a real PostgreSQL (or Redis, or MongoDB) in your pipeline.

At the same time, your cache is unpredictable: sometimes it is there, sometimes it is empty. Builds take 3 minutes with the cache, 12 without. You want to understand why and make it reliable.

  • Your integration tests need an SQL or NoSQL database
  • You are testing a microservice that depends on Redis for sessions
  • Your app has a healthcheck that verifies the database connection
  • Your cache disappears when the runner changes
  • You have autoscaled runners and the local cache does not survive

CI services

How do services work?

When you declare services: in a job, GitLab Runner:

  1. Starts the service containers before the job
  2. Waits for the services to be ready (healthcheck)
  3. Creates a shared network between the job and the services
  4. Makes each service reachable through a hostname derived from the image name

PostgreSQL in a job

The most common example: integration tests with PostgreSQL.

.gitlab-ci.yml
test:integration:
stage: test
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
services:
- name: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
variables:
DATABASE_URL: "postgresql://testuser:testpass@db:5432/testdb"
script:
- npm ci
- npm run test:integration

Key to understanding: the service is reachable through the hostname db (defined by alias). Without an alias, the hostname would be postgres: the image name without its tag or digest (for a namespaced image such as registry.example.com/team/postgres, the / become -).

Redis for sessions

Redis needs no environment variable to start, unlike PostgreSQL: the official image accepts connections without authentication from launch. The only thing to remember from this example is therefore the construction of the connection URL, which reuses the service alias and the port exposed by default, 6379. If you want a password, it must be passed as a command to the service, not as a variable.

test:integration:
stage: test
image: python:3.12@sha256:7ad6d21a25a94b2c00e685e82c2fd298de814353d9ee0e3f7f2cd4fca063df60
services:
- name: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
alias: cache
variables:
REDIS_URL: "redis://cache:6379"
script:
- pip install -r requirements.txt
- pytest tests/integration/

Several simultaneous services

Does your application depend on PostgreSQL and Redis? Declare both:

test:integration:
stage: test
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
services:
- name: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
alias: db
variables:
POSTGRES_DB: app_test
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
- name: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
alias: cache
- name: elasticsearch:8.12.0@sha256:2529e4b8062e4b9f0a31bea3ee797a58dae4cfe2d2b5879b4a1b03250c7ad31c
alias: search
variables:
discovery.type: single-node
xpack.security.enabled: "false"
variables:
DATABASE_URL: "postgresql://app:secret@db:5432/app_test"
REDIS_URL: "redis://cache:6379"
ELASTICSEARCH_URL: "http://search:9200"
script:
- npm ci
- npm run test:integration

How do you wait for a service to be ready?

Services do not start instantly. PostgreSQL needs a few seconds to be ready. Two approaches:

test:integration:
services:
- name: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
alias: db
before_script:
- apt-get update && apt-get install -y postgresql-client
- |
for i in $(seq 1 30); do
pg_isready -h db -p 5432 && break
echo "Waiting for PostgreSQL... ($i/30)"
sleep 1
done
- npm ci
script:
- npm run test:integration

Advanced cache

Reminder: cache vs artifacts

The line that decides everything in this table is the lifetime. The cache is a best effort mechanism: GitLab never guarantees that it will be there at the next job, and a pipeline that fails when the cache is empty is a broken pipeline, not a slow one. Artifacts, on the other hand, are kept for the time declared by expire_in and are the only reliable way to pass a result from one job to another. If you hesitate between the two, ask yourself this question: does the next job fail without this file? If so, it is an artifact.

CacheArtifacts
PurposeSpeed up following buildsPass results between jobs
LifetimeBest effort (may disappear)Guaranteed during expire_in
StorageLocal to the runner (or distributed)Stored by GitLab (object storage)
Use casenode_modules/, .pip/, vendor/dist/, JUnit reports, binaries

→ Details in Artifacts and cache

Which cache key strategy should you choose?

The cache key determines when the cache is reused or invalidated.

cache:
key:
files:
- package-lock.json
paths:
- node_modules/

The best choice in most cases. The cache is invalidated only when the dependencies change. Two branches with the same package-lock.json share the cache.

This is the move the lab of the DAG and parallelism lesson makes you fix: its starting pipeline keys the cache on the commit SHA, so the cache is never reused from one commit to the next.

Fallback keys

When the exact cache does not exist, GitLab can look for a fallback cache. The point to understand before writing a single line: fallback_keys contains literal key names, not patterns. A fallback key therefore only brings back a cache if another pipeline actually pushed one under that exact name. This is why the example below does not use key.files: a key computed by hashing produces an unpredictable name, on which no fallback key can ever land.

cache:
key: "npm-$CI_COMMIT_REF_SLUG"
fallback_keys:
- "npm-$CI_DEFAULT_BRANCH"
- "npm-default"
paths:
- node_modules/

The search order, from a feature/paiement branch:

  1. npm-feature-paiement, the exact key of the current branch
  2. npm-main, written by the last pipeline of the default branch
  3. npm-default, a safety net that a maintenance job must feed

The second entry is the one that makes the mechanism useful: a freshly created branch retrieves the node_modules/ of the default branch instead of starting from an empty cache.

Policies: controlling who writes and who reads

The policy answers a question of cost, not of correctness: each cache write compresses a directory and sends it to storage, which takes time even when nothing has changed. On a pipeline where five jobs declare the same node_modules/, leaving the default value pull-push everywhere amounts to compressing and uploading the same content five times. Switching the consuming jobs to pull removes these useless writes and eliminates along the way the concurrent overwrites between parallel jobs.

PolicyBehaviorUsage
pull-pushReads the cache at the start, writes at the end (default)The job that feeds the cache (often the first)
pullReads the cache, does not writeThe following jobs (avoids concurrent writes)
pushDoes not use the existing cache, writes at the endRegenerating a corrupted cache
Example: one job feeds, the others consume
stages: [lint, test, build]
lint:
stage: lint
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push # First job → feeds the cache
script:
- npm ci
- npm run lint
test:
stage: test
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull # Following job → read-only
script:
- npm test
build:
stage: build
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull # Read-only too
script:
- npm run build

Notice that test and build repeat the key and the paths of the first job, and not only policy: pull. This is deliberate: a cache: block declared in a job replaces the one from default: entirely, with no merging. A job that wrote only policy: pull would have no key and no path, and would therefore restore nothing, without any error message. To avoid the repetition without losing this rule, put the shared block in a hidden job (.node-cache) and reuse it with extends:, overriding only the policy.

Multiple caches

A job can declare several independent caches:

test:python:
cache:
- key:
files:
- requirements.txt
paths:
- .pip/
- key:
files:
- package-lock.json
paths:
- node_modules/
script:
- pip install -r requirements.txt --cache-dir .pip
- npm ci
- npm test

Distributed cache (S3)

By default, the cache is stored locally on the runner. If your runners are autoscaled (ephemeral instances), the cache disappears with the instance. The solution: a distributed cache on S3 (or S3-compatible storage).

Configuration in the runner:

config.toml
[[runners]]
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "gitlab-ci-cache"
BucketLocation = "eu-west-1"
AccessKey = "AKIAIOSFODNN7EXAMPLE"
SecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

The last two lines are the example credentials published by AWS, they give access to nothing. In production, do not copy this pattern: config.toml is a plain-text file on the runner, readable by anyone who gets a shell on it. On EC2 or EKS, remove AccessKey and SecretKey and set AuthenticationType = "iam" so that the runner uses the IAM role of the instance. On S3-compatible storage with no role available, inject the keys through environment variables of the runner service and restrict the bucket policy to the strict minimum.

How do you clear the cache?

The cache can become large. Important point: there is no REST API endpoint to clear a runner's cache. The GitLab documentation only documents two ways, and the interface button is the most direct.

In the interface: Build > Pipelines, then Clear runner caches (button at the top right). On the next commit, jobs start again on a fresh cache location.

The second way is to change the cache key (for example by adding a prefix: v2 to it), which moves the jobs to a new entry without deleting the old one.

Do not confuse this with deleting artifacts, which falls under a completely different API endpoint: DELETE /projects/:id/artifacts deletes the eligible artifacts of a project, and DELETE /projects/:id/jobs/:job_id/artifacts those of a specific job. Neither touches the cache.

For a regular refresh, add a scheduled job that rebuilds the cache from scratch under the same key as the consuming jobs. Its push policy ignores the existing cache and overwrites it at the end of the job:

stages: [maintenance]
clean:cache:
stage: maintenance
cache:
key:
files:
- package-lock.json
policy: push
paths:
- node_modules/
script:
- rm -rf node_modules/
- npm ci
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"

Troubleshooting

Before going through this table, open the job logs and go back to the very beginning: the runner writes a collapsed section per service there, with the result of the port check. A WARNING: Service ... probably didn't start properly message points you to the service rows (connection refused, memory, MySQL), whereas a connection error without this message rather points to an alias or variables problem. The cache rows (empty cache, inconsistent cache, never invalidated, pull job that reads nothing) share one visible symptom: a build duration that varies for no apparent reason.

SymptomLikely causeSolution
Service connection refusedThe service is not readyAdd a wait script or increase wait_for_services_timeout
Service hostname not foundWrong alias or image syntaxCheck alias: and the hostname used in the connection
Empty cache on a new branchNo fallback configuredAdd fallback_keys pointing to the default branch
Inconsistent cache between jobsConcurrent writeUse policy: pull on all jobs except the first
Cache never invalidatedStatic key (key: "npm-cache")Use key.files with a lock file
pull job restores nothingIts cache: block replaces the default: one and lacks key or pathsRepeat key and paths in the job (see the policies section)
Out of memory with servicesToo many containersReduce the services or increase the runner resources
MySQL service Access deniedEnv variables not passed to the serviceDeclare the variables under services[].variables:

Key takeaways

  • CI services run sibling containers (PostgreSQL, Redis...) reachable by hostname
  • The hostname is the service alias or the simplified image name
  • Each service has its own variables (POSTGRES_DB, REDIS_URL...)
  • The cache key based on a lock file is the most reliable strategy
  • Use policy: pull-push on the first job and pull on the following ones, repeating key and paths in each: a job's cache: replaces the default: one entirely
  • Fallback keys avoid cache misses on new branches
  • The distributed cache (S3/MinIO) is essential for autoscaled runners
  • Cache = best effort (accelerator), artifacts = guaranteed (results)

Test your knowledge

Ten questions to check that the two mechanisms of the guide are mastered: the network naming of services and the key/policy pair of the cache. The pass mark is set at 70%. If you struggle on the policy questions, reread the three-job example above, it is the point that costs the most pipeline time in practice.

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn