GitLab CI/CD Pipeline: A Complete .gitlab-ci.yml Example
Build a GitLab CI/CD pipeline step by step: stages, rules, cache vs artifacts, a Postgres service, Docker images in the GitLab registry, and manual production deploys.
Prerequisites
- •A GitLab project with a Node.js app and a Dockerfile
- •Basic Git, YAML and Docker knowledge
- •A Linux server with Docker for the deploy steps (optional)
What You'll Learn
- Structure a .gitlab-ci.yml with stages, defaults, hidden jobs and workflow rules
- Use rules instead of only/except and choose correctly between cache and artifacts
- Test against a Postgres service and push SHA-tagged images to the GitLab container registry
- Deploy to environments with manual production jobs, protected variables and self-hosted runners
GitLab CI/CD is built into every GitLab project: commit a .gitlab-ci.yml file and GitLab runs it on every push. This tutorial builds a complete GitLab CI/CD pipeline for a Node.js app that lints and tests against a real Postgres database, builds a Docker image into the GitLab container registry, deploys to staging automatically and to production with a manual button. The full .gitlab-ci.yml is at the end of each step and can be copied as-is.
How GitLab pipelines are organised
.gitlab-ci.yml, triggered by a push, a merge request, a schedule, a tag or the API.If CI/CD itself is new, read what CI/CD is first. If you are weighing platforms, see GitHub Actions vs GitLab CI.
Step 1: stages, defaults and workflow rules
Create .gitlab-ci.yml in the repository root and start with the skeleton:
stages:
- test
- build
- deploy
default:
image: node:22-alpine
interruptible: true
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAGdefault sets values every job inherits. interruptible: true lets GitLab cancel an old pipeline when you push again to the same branch (enable "Auto-cancel redundant pipelines" in project settings).variables at the top level are available to every job. CI_REGISTRY_IMAGE and CI_COMMIT_SHORT_SHA are predefined variables, so IMAGE becomes something like registry.gitlab.com/acme/web:3f2a9c1.workflow: rules controls whether a pipeline is created at all. This standard pattern runs merge request pipelines for open MRs, branch pipelines otherwise, and avoids running two identical pipelines for the same commit.Step 2: test jobs with cache and a Postgres service
Append the test jobs:
.node:
cache:
key:
files:
- package-lock.json
paths:
- .npm/
before_script:
- npm ci --cache .npm --prefer-offline
lint:
stage: test
extends: .node
script:
- npm run lint
test:
stage: test
extends: .node
services:
- name: postgres:17-alpine
alias: postgres
variables:
POSTGRES_DB: app_test
POSTGRES_USER: runner
POSTGRES_PASSWORD: runner
DATABASE_URL: postgres://runner:runner@postgres:5432/app_test
script:
- npm run migrate
- npm test
artifacts:
when: always
reports:
junit: reports/junit.xml
expire_in: 1 weekWhat is going on:
.node starts with a dot, so it is a hidden job: GitLab never runs it, but other jobs can extends it. This is how you share setup without copy-paste.services starts a Postgres container alongside the job, reachable at the hostname given by alias. The POSTGRES_ variables configure the official image; DATABASE_URL tells your app where to connect. Use the same major version as production.artifacts: reports: junit uploads test results so failed tests appear directly in the merge request widget. when: always uploads them even when the job fails, which is exactly when you need them.Cache vs artifacts
These two are the most commonly confused GitLab keywords:
package-lock.json, so it refreshes when dependencies change) and shared between pipelines.Rule of thumb: node_modules or .npm goes in cache; dist/ and reports/ go in artifacts. Caching .npm rather than node_modules is safer because npm ci deletes node_modules before installing anyway.
Step 3: build and push to the GitLab container registry
Every project has a built-in container registry, and every job receives short-lived credentials for it in CI_REGISTRY, CI_REGISTRY_USER and CI_REGISTRY_PASSWORD. Append:
build-image:
stage: build
image: docker:28
services:
- docker:28-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build --pull -t "$IMAGE" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$IMAGE"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHdocker:28 with the docker:28-dind service is Docker-in-Docker: the job's Docker CLI talks to a Docker daemon running in a sibling container. Pin both to the current Docker major version and keep them identical.DOCKER_TLS_CERTDIR makes the CLI and the daemon talk over TLS, which is the recommended setup.rules with CI_DEFAULT_BRANCH means images are only built for main, not for every feature branch. Prefer rules over the older only/except keywords: rules are evaluated in order, support when, changes and exists, and only/except are no longer actively developed.On GitLab.com hosted runners Docker-in-Docker works out of the box. On self-hosted runners you must enable privileged mode (see Step 6), or switch to a daemonless builder such as BuildKit rootless or Buildah.
Step 4: environments and manual production deploys
Append the deploy jobs:
.deploy:
stage: deploy
image: alpine:3
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
script:
- ssh -o StrictHostKeyChecking=accept-new "deploy@$DEPLOY_HOST" "docker pull $IMAGE && IMAGE=$IMAGE docker compose -f /srv/app/compose.yml up -d"
deploy-staging:
extends: .deploy
variables:
DEPLOY_HOST: staging.example.com
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-production:
extends: .deploy
variables:
DEPLOY_HOST: prod.example.com
environment:
name: production
url: https://www.example.com
resource_group: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manualenvironment records each deployment under Operate > Environments, with a link to the URL and a one-click Rollback that re-runs the deploy job of an earlier pipeline.when: manual inside the rule adds the job to the pipeline but waits for someone to press the play button. Combine it with a protected environment (Settings > CI/CD > Protected environments) to restrict who can press it.resource_group makes sure two production deploys never run at the same time.The servers need to pull from your private registry. Create a deploy token with read_registry scope (Settings > Repository > Deploy tokens) and run docker login registry.gitlab.com with it once on each server.
Step 5: protected and masked variables
Add secrets under Settings > CI/CD > Variables:
SSH_PRIVATE_KEY: type File, so GitLab writes the key to a temporary file and the variable holds its path (that is why the job runs ssh-add "$SSH_PRIVATE_KEY").main is a protected branch.[MASKED] in job logs and, if hidden, cannot be viewed again in the UI.production and it is only available to jobs whose environment: name matches. This lets staging and production use the same variable name with different values.Step 6: shared vs self-hosted runners
To add a self-hosted runner, go to Settings > CI/CD > Runners > New project runner, choose tags (for example docker), and copy the glrt- authentication token. On a Linux host with Docker:
docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
docker exec -it gitlab-runner gitlab-runner register \
--non-interactive --url https://gitlab.com \
--token glrt-YOUR_TOKEN --executor docker \
--docker-image alpine:3 --docker-privileged--docker-privileged is required for the Docker-in-Docker build job. Jobs pick a runner by tag: add tags: [docker] to a job to send it to this runner.
Common mistakes
workflow: rules block from Step 1.rules with only/except in one job. GitLab rejects that config. Pick rules everywhere.main, or the tag pattern you release from.docker and docker:dind versions, or forgetting privileged mode on self-hosted runners. Both show up as "Cannot connect to the Docker daemon".latest. Deploy the SHA-tagged $IMAGE so the environment history and rollbacks point at exact builds.
