intermediate9 min readPart 1 of 1

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.

TechLogHub
·

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

  • A pipeline is one run of your .gitlab-ci.yml, triggered by a push, a merge request, a schedule, a tag or the API.
  • Stages run in order. Every job in a stage must pass before the next stage starts.
  • Jobs are the units of work. Jobs in the same stage run in parallel, each in a fresh container on a runner.
  • Rules decide whether a job is added to a pipeline and how it runs (automatically, manually, or never).
  • 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_TAG

  • default 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 week

    What 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:

  • Cache is a speed optimisation for dependencies. It is best-effort: a job must still work if the cache is empty. It is keyed (here on package-lock.json, so it refreshes when dependencies change) and shared between pipelines.
  • Artifacts are build outputs that later jobs or humans need: compiled files, test reports, coverage. They are guaranteed to be passed to jobs in later stages of the same pipeline and can be downloaded from the UI until they expire.
  • 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_BRANCH

  • docker: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: manual

  • environment 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").
  • Protected: the variable is only exposed to pipelines on protected branches and tags. Feature branches, and merge requests from forks, cannot read it. Mark every deploy secret protected and make sure main is a protected branch.
  • Masked (or Masked and hidden): the value is replaced with [MASKED] in job logs and, if hidden, cannot be viewed again in the UI.
  • Environment scope: set a variable to 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

  • Hosted (shared) runners on GitLab.com are ready to use, run each job in a fresh VM, and consume your namespace's compute minutes. They are the right default.
  • Self-hosted runners run on your own machines. Use them when you need more minutes, private network access to deploy targets, larger machines, or GPUs.
  • 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

  • Duplicate pipelines for every push to an MR branch. Fix it with the workflow: rules block from Step 1.
  • Mixing rules with only/except in one job. GitLab rejects that config. Pick rules everywhere.
  • Using cache to pass build output between stages. Cache can be missing; use artifacts for anything a later job depends on.
  • Secrets that are protected but the branch is not. The variable comes through empty and the deploy fails with a confusing auth error. Protect main, or the tag pattern you release from.
  • Mismatched docker and docker:dind versions, or forgetting privileged mode on self-hosted runners. Both show up as "Cannot connect to the Docker daemon".
  • Deploying latest. Deploy the SHA-tagged $IMAGE so the environment history and rollbacks point at exact builds.
  • Where to go next

  • See the same pipeline on GitHub in the GitHub Actions CI/CD tutorial, or the self-hosted alternative in the Jenkins CI/CD pipeline tutorial.
  • Learn the image side properly with the Docker cheat sheet and the Docker glossary entry.
  • Graduate from SSH deploys to Kubernetes using GitLab's agent for Kubernetes, and replace manual steps with environment approvals.
  • gitlabgitlab cici/cddockerdevops

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.