Tests usually need a specific toolchain and a real database, and services provide both without extra infrastructure.
With the Docker executor every job runs inside the image named by image. Pin a tag (postgres:17, node:22-alpine) rather than latest so pipelines are reproducible. Images can come from Docker Hub, GitLab's own container registry ($CI_REGISTRY_IMAGE) or any registry the runner can reach.
Services are extra containers started alongside the job, typically databases, caches or brokers. The job reaches a service by a hostname derived from the image name (postgres:17 becomes postgres) or by an explicit alias. Service containers receive the job's variables, which is how you set the database password.
default: applies keywords to every job unless a job overrides them: image, services, before_script, after_script, tags, retry, timeout, interruptible and a few others.
Building Docker images inside a job requires access to a Docker daemon. The common patterns are a docker:dind service (Docker-in-Docker, needs a privileged runner) or a daemonless builder such as BuildKit or Kaniko. GitLab provides $CI_REGISTRY, $CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD so jobs can push to the project's registry without storing credentials.
Example
default:
image: python:3.13-slim
retry: 1
integration:
services:
- name: postgres:17
alias: db
variables:
POSTGRES_PASSWORD: test # also passed to the service container
DATABASE_URL: postgresql://postgres:test@db:5432/postgres
script:
- pip install -r requirements.txt
- pytest tests/integration
build-image:
image: docker:28
services: [docker:28-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" Remember
- image sets the job container; pin its tag
- services start side containers reachable by name or alias
- default: shares image, before_script, retry and more across jobs
Watch out for
- Connecting to localhost for a service: with the Docker executor the service has its own hostname.