GitLab CI Multi-Environment Deploy

A production-grade .gitlab-ci.yml that builds a Docker image and pushes it to the GitLab Container Registry, then deploys to staging automatically and production on manual approval — using YAML anchors to eliminate duplication.

🛠 Paste this YAML into the validator to check it instantly.

Open Validator →

Overview

Real-world projects almost always need to deploy to more than one environment. A typical progression is: build a Docker image once, push it to a registry, deploy that exact image to a staging environment for QA, and then — after human approval — roll the same image to production. This pipeline implements that pattern entirely within GitLab CI without any external tooling beyond kubectl and the GitLab Container Registry.

The pipeline makes heavy use of GitLab's predefined CI/CD variables such as $CI_REGISTRY_IMAGE, $CI_COMMIT_SHA, and $CI_ENVIRONMENT_SLUG. These variables are injected automatically by GitLab into every job and give you stable, unique references for your images and environments without any manual configuration. Using the commit SHA as the image tag means every image is traceable back to an exact commit — far safer than the mutable :latest tag.

Complete YAML

.gitlab-ci.yml
stages:
  - build
  - test
  - deploy

.deploy-template: &deploy-template
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/my-app my-app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  environment:
    url: https://$CI_ENVIRONMENT_SLUG.example.com

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

test:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm test

deploy-staging:
  stage: deploy
  <<: *deploy-template
  environment:
    name: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

deploy-production:
  stage: deploy
  <<: *deploy-template
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

YAML anchors and merge keys

YAML anchors (&name) and aliases (*name) are a core YAML feature — not a GitLab-specific extension — that allows you to define a block of content once and reference it multiple times. In this pipeline, .deploy-template: &deploy-template defines a hidden job (the leading dot prevents GitLab from running it as a real job) that holds the shared deploy configuration: the kubectl image, the rollout command, and the environment URL pattern.

The <<: *deploy-template syntax is a YAML merge key. It takes every key-value pair from the anchored block and injects it into the current mapping, as if you had typed it manually. The deploy-staging and deploy-production jobs both inherit the image, script, and environment.url from the template, then each adds its own environment.name and rules. If you ever need to change the deploy command, you change it in one place and both jobs update automatically.

Keys defined directly in the job take precedence over merged keys — this is the "override" behaviour. Notice that environment.name is defined per-job, overriding the template which only sets environment.url. GitLab merges these into a single environment object with both name and url populated.

Docker-in-Docker build

The build job uses Docker-in-Docker (DinD) to build a container image from within a CI container. This requires two things: the docker:24 image for the job itself (which provides the docker CLI), and the docker:24-dind service (which runs the Docker daemon as a sidecar container). GitLab runners link the two containers on a shared network automatically.

The DOCKER_TLS_CERTDIR: "/certs" variable enables TLS between the Docker client and the DinD daemon, which is required for GitLab's modern runner configuration and avoids the security risks of running with --privileged in an unencrypted mode. The certificates are generated automatically at startup and shared between the job container and the DinD service via a volume.

The three predefined variables used in the script — $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD, and $CI_REGISTRY — are injected automatically by GitLab and always refer to the built-in Container Registry for your project. You do not need to create any CI/CD variable in the project settings for these; they just work. $CI_REGISTRY_IMAGE resolves to the full registry path for your project, and $CI_COMMIT_SHA provides the 40-character git commit hash as an immutable image tag.

Rules and manual gates

The rules keyword is the modern successor to only / except. Instead of a simple list of branch names, rules accepts an ordered list of conditions evaluated top to bottom; the first matching rule determines whether the job runs and how. This example uses if: $CI_COMMIT_BRANCH == "develop" for staging — the job runs automatically whenever a commit lands on the develop branch.

The production deploy adds when: manual to the matching rule. This tells GitLab to create the job in the pipeline and show it in the UI, but not to run it automatically — a human must click the "Play" button in the GitLab pipeline view to trigger it. This single-line addition gives you a gated production release process without any external tooling: every deployment is still tracked, auditable, and rollback-capable via the Environments UI.

If no rule matches (for example, a push to a feature branch other than develop or main), the deploy jobs are simply not added to the pipeline at all. This is cleaner than only, which could sometimes show jobs as "skipped" cluttering the pipeline graph. With rules, the pipeline graph only shows jobs that are actually relevant to the current context.

Tip: You can extend rules to also check for changes to specific files using changes — for example, only trigger the Docker build if Dockerfile or package.json changed. This can drastically reduce unnecessary pipeline runs on large monorepos.