GitLab CI #

GitLab CI offers several advantages over GitHub Actions for teams already in the GitLab ecosystem: more flexible pipelines with include and extends, environments with integrated deployment tracking, and the ability to run fully self-hosted pipelines without depending on external infrastructure. This article discusses comprehensive Ansible integration patterns with GitLab CI, complementing the previous GitHub Actions article — both apply the design principles discussed in the Pipeline Design article.

GitLab CI Anatomy for Ansible #

Unlike GitHub Actions which is event-trigger oriented, GitLab CI is oriented toward stages that run sequentially. The concept is closer to traditional Makefile or Jenkins pipelines: each stage has a list of jobs, all jobs in one stage run in parallel, and the next stage only starts after the previous stage finishes.

flowchart TB
    subgraph S1["Stage: validate"]
        V1["lint"]
        V2["syntax-check"]
        V3["check-production --check --diff"]
    end

    subgraph S2["Stage: test"]
        T1["molecule-common"]
        T2["molecule-nginx"]
        T3["molecule-postgresql"]
        T4["molecule-docker"]
    end

    subgraph S3["Stage: build"]
        B1["build-image"]
    end

    subgraph S4["Stage: deploy-staging"]
        D1["deploy-staging"]
    end

    subgraph S5["Stage: verify-staging"]
        V4["verify-staging smoke test"]
    end

    subgraph S6["Stage: deploy-production"]
        D2["deploy-production manual"]
    end

    V1 --> S2
    V2 --> S2
    V3 --> S2
    S2 --> B1
    B1 --> D1
    D1 --> V4
    V4 --> D2

Three important concepts in GitLab CI that don’t exist in GitHub Actions with the same names: stages defines the execution order (validate → test → build → deploy), extends reuses configuration between jobs (like inheritance in OOP), and !reference allows composing before_script from several templates at once. All three keep pipelines for large Ansible fleets manageable.

When first designing a GitLab CI pipeline, write the stages: with meaningful names first, then add jobs to each stage. Don’t start from jobs — start from the lifecycle order. This prevents forgetting to separate build jobs from deploy jobs in the same stage.

Pipeline Structure with Stages #

The .gitlab-ci.yml file is the single source of truth for the entire pipeline. Unlike GitHub Actions which can have many workflow files, GitLab CI is centralized in one file (or included from many files via include:).

# .gitlab-ci.yml
stages:
  - validate       # Lint and syntax checks
  - test           # Unit tests and molecule
  - build          # Build the Docker image
  - deploy-staging
  - verify-staging
  - deploy-production

variables:
  ANSIBLE_FORCE_COLOR: "true"
  ANSIBLE_HOST_KEY_CHECKING: "false"
  PY_COLORS: "1"
  IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"
  REGISTRY: "${CI_REGISTRY}"
  IMAGE_NAME: "${CI_REGISTRY_IMAGE}"

Notice two GitLab-specific variables: ${CI_COMMIT_SHORT_SHA} is the short git SHA (first 8 characters) used as the image tag — immutable and traceable to a specific commit. ${CI_REGISTRY_IMAGE} is the default container registry path already configured for our GitLab project. No additional credential setup needed; the GitLab Runner automatically mounts a token to the runner when the job runs.

ANSIBLE_HOST_KEY_CHECKING: "false" is set in the pipeline to prevent deploy jobs from hanging at the SSH “Are you sure you want to continue connecting?” prompt. In production with frequently rebuilt managed nodes, this prompt appears whenever there’s a new host and will hang the CI job. Set it to false in the pipeline; in local development Ansible config, leave it true for security.

Templates with extends and before_script #

One of GitLab CI’s greatest strengths over other platforms: its template system is very flexible. Two jobs with similar setups don’t need duplicated configuration.

# Template for all Ansible jobs
.ansible_base:
  image: python:3.11-slim
  before_script:
    - pip install ansible --quiet
    - ansible-galaxy install -r requirements.yml --force
  cache:
    key: ansible-$CI_COMMIT_REF_SLUG
    paths:
      - ~/.ansible/roles
      - .cache/pip

# Template for deployments (needs SSH)
.deploy_base:
  extends: .ansible_base
  before_script:
    - !reference [.ansible_base, before_script]
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$VAULT_PASSWORD" > /tmp/.vault_pass
    - chmod 600 /tmp/.vault_pass
  after_script:
    - rm -f /tmp/.vault_pass

Templates start with a dot (.ansible_base) — this marks them as templates, not jobs. Jobs extending a template inherit all its fields, except those overridden in the job itself. !reference is the way to reuse a specific part of another template (in this case before_script from .ansible_base) while adding new steps to that same before_script.

The tr -d '\r' in echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - is small but important — SSH key variables in GitLab CI sometimes have trailing carriage returns (especially if pasted from Windows), and the SSH agent immediately rejects keys with odd formatting. Strip the \r characters before adding.


The Validate and Test Stages #

Once templates are defined, jobs in each stage become very concise — everything extends from the appropriate template:

# Stage: validate
lint:
  extends: .ansible_base
  stage: validate
  script:
    - pip install ansible-lint --quiet
    - ansible-lint --profile production
    - |
      for playbook in playbooks/*.yml; do
        ansible-playbook "$playbook" --syntax-check -i inventory/staging/
      done      
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

check-production:
  extends: .deploy_base
  stage: validate
  variables:
    SSH_PRIVATE_KEY: $PROD_SSH_KEY
    VAULT_PASSWORD: $VAULT_PASS_PROD
  script:
    - ansible-playbook -i inventory/production/ site.yml
        --check --diff
        --vault-password-file /tmp/.vault_pass
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# Stage: test — parallel Molecule
.molecule_base:
  extends: .ansible_base
  stage: test
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - pip install ansible molecule molecule-plugins[docker] --quiet
    - ansible-galaxy install -r requirements.yml --force

molecule-common:
  extends: .molecule_base
  script: cd roles/common && molecule test

molecule-nginx:
  extends: .molecule_base
  script: cd roles/nginx && molecule test

molecule-postgresql:
  extends: .molecule_base
  script: cd roles/postgresql && molecule test

The check-production job is interesting: it runs the playbook with --check --diff — Ansible simulates execution without actually changing anything, then shows the diff of changes that would occur. This is the most accurate dry-run for Ansible: not just syntax checking, but also verifying all conditions, dependencies, and variables. Combined with lint and syntax-check in the same stage, we have three validation layers before code reaches runtime.

Ansible --check mode isn’t perfect. Tasks needing connections to remote services (e.g. command or shell on managed nodes) will fail because Ansible doesn’t actually connect during check mode. For full verification, run molecule tests in the test stage — that’s the closest to real execution.

Building and Pushing to the GitLab Container Registry #

GitLab has a built-in container registry already integrated with projects. No need to set up Docker Hub or an external registry for most cases:

build-image:
  stage: build
  image: docker:24
  services:
    - docker:dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_NAME:$IMAGE_TAG .
    - docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
    - echo "IMAGE_FULL_TAG=$IMAGE_NAME:$IMAGE_TAG" > build.env
  artifacts:
    reports:
      dotenv: build.env      # Forward variables to the next jobs
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

artifacts.reports.dotenv is GitLab’s official way to share variables between jobs. The build.env file written at the end of the script is parsed and injected as environment variables in subsequent jobs. So IMAGE_FULL_TAG written in the build job is automatically available in deploy jobs without manual passing or artifact downloads. For context: the GitHub Actions way is outputs: at the job level; in GitLab CI it’s a dotenv file in artifacts. Same mindset, different implementation.


Deployment with GitLab Environments #

GitLab Environments is a frequently underestimated feature: every deployment to an environment is tracked automatically, with history, status, and links to the commit/job that performed it. Very useful for audits and rollbacks.

deploy-staging:
  extends: .deploy_base
  stage: deploy-staging
  variables:
    SSH_PRIVATE_KEY: $STAGING_SSH_KEY
    VAULT_PASSWORD: $VAULT_PASS_STAGING
  script:
    - ansible-playbook -i inventory/staging/ playbooks/deploy.yml
        -e "app_version=$IMAGE_TAG"
        --vault-password-file /tmp/.vault_pass
  environment:
    name: staging
    url: https://staging.company.com
    deployment_tier: staging
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

verify-staging:
  stage: verify-staging
  image: curlimages/curl:latest
  script:
    - sleep 15
    - curl -f https://staging.company.com/health
    - |
      VERSION=$(curl -s https://staging.company.com/api/version | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])")
      [ "$VERSION" = "$IMAGE_TAG" ] || (echo "Version mismatch: expected $IMAGE_TAG, got $VERSION" && exit 1)      
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-production:
  extends: .deploy_base
  stage: deploy-production
  variables:
    SSH_PRIVATE_KEY: $PROD_SSH_KEY
    VAULT_PASSWORD: $VAULT_PASS_PROD
  script:
    - ansible-playbook -i inventory/production/ playbooks/deploy.yml
        -e "app_version=$IMAGE_TAG"
        --vault-password-file /tmp/.vault_pass
  environment:
    name: production
    url: https://app.company.com
    deployment_tier: production
  when: manual               # Must be manually triggered — not automatic
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
      allow_failure: false

The environment: block has three roles: (1) labels the deployment so it appears in the GitLab Environments menu, (2) tracks deployment history per environment with timestamps and job refs, (3) enables the “re-deploy” feature from the UI which re-triggers the job with the same commit. when: manual on the production deployment is the most important guard — the pipeline won’t automatically promote to production. Someone must click the “play” button in the GitLab UI for the deploy-production job to run.

verify-staging does two things: a smoke test of the endpoint to ensure the container is alive, and a version check to ensure the running container is truly the newly deployed image. Without the version check, there’s a risk the smoke test passes but the responding container is actually an old instance still running from a previous deployment.


Dynamic Child Pipelines #

For monorepos with many services, hardcoding all jobs in .gitlab-ci.yml quickly becomes unscalable. Dynamic child pipelines let us generate a pipeline based on the services changed in a specific commit.

# .gitlab-ci.yml (parent pipeline)
generate-child-pipeline:
  stage: validate
  image: python:3.11-slim
  script:
    - python3 scripts/generate-pipeline.py > generated-pipeline.yml
  artifacts:
    paths:
      - generated-pipeline.yml

trigger-child:
  stage: deploy-staging
  trigger:
    include:
      - artifact: generated-pipeline.yml
        job: generate-child-pipeline
    strategy: depend
# scripts/generate-pipeline.py
# Generate a pipeline based on the changed services

import subprocess
import yaml
import sys

# Detect the changed services
changed = subprocess.run(
    ['git', 'diff', '--name-only', 'HEAD~1'],
    capture_output=True, text=True
).stdout.split()

services = set()
for path in changed:
    if path.startswith('services/'):
        service = path.split('/')[1]
        services.add(service)

# Generate jobs for each changed service
pipeline = {'stages': ['deploy'], 'jobs': {}}
for service in services:
    pipeline['jobs'][f'deploy-{service}'] = {
        'stage': 'deploy',
        'script': [
            f'ansible-playbook -i inventory/staging/ playbooks/deploy-{service}.yml'
        ]
    }

print(yaml.dump(pipeline))

This pattern is powerful but needs caution: the generate-pipeline.py script is code generating code, and if buggy, it could skip deploying a service that should be deployed. Always validate this script’s output (e.g. if services is empty, fail explicitly; don’t generate an empty pipeline that auto-succeeds).

The strategy: depend in trigger: makes the parent pipeline wait for the child pipeline to finish. Without depend, the parent is considered successful as soon as the child is triggered, and if the child fails, the parent stays green. This is dangerous — we’d get false positives in the pipeline status.


Masked Variables for Secrets #

GitLab has three security features for variables we should use consistently:

# In GitLab Settings → CI/CD → Variables
# Use these options:
# - Masked: the value doesn't appear in pipeline logs
# - Protected: only available on protected branches
# - File: the value is stored as a file, not an environment variable

# Example variables that must be masked:
# VAULT_PASS_PROD     → masked + protected
# PROD_SSH_KEY        → masked + protected + file type
# REGISTRY_PASSWORD   → masked

An important difference here: Masked only hides the value from pipeline UI logs, but the variable is still accessible to jobs as a normal environment variable. For SSH keys or other credential files, the File option is safer — the variable is written as a file on the runner, and we can directly reference its path in commands. This reduces the risk of careless echo accidentally printing a secret to the log.

Protected ensures variables are only available on branches/tags in the protected list (usually main and release tags). Pull requests from forks won’t have access to protected variables, so PRs from external contributors can’t use production credentials for exploits.


GitLab CI vs Jenkins vs GitHub Actions #

Choosing a CI/CD platform is an architectural decision that’s hard to reverse. The table and decision tree below help us see the trade-offs:

AspectGitLab CIJenkinsGitHub Actions
ConfigurationYAML in the repo, versioned with the codeGroovy/UI, versioned separately from the codeYAML in the repo, versioned with the code
RunnerBuilt-in (shared) or self-hostedSelf-hosted only (default)GitHub-hosted or self-hosted
Learning curvemoderate — stages + extends + rulessteep — plugin ecosystem + Jenkinsfile DSLmoderate — jobs + steps + matrix
EcosystemIntegrated with GitLab (issues, MRs, registry)The largest plugin marketplaceLarge marketplace, native GitHub integration
Self-host overheadlow — light GitLab Runnerhigh — Jenkins master + agentsnone needed for hosted, light for self-hosted
Pipeline as codeYes (.gitlab-ci.yml)Yes (Jenkinsfile) but also UI-only possibleYes (.github/workflows/)
Suitable forTeams wanting an all-in-one DevOps platformTeams with very custom plugin needsTeams whose repositories are on GitHub
LicensingCommunity Edition free, self-hostableOpen source, fully self-hostableFree for public, paid for private above quota
flowchart TD
    A{"Where is the repository stored?"} -- GitHub --> B["GitHub Actions"]
    A -- GitLab --> C["GitLab CI"]
    A -- Bitbucket/Other --> D{"Need very custom<br/>plugins?"}
    D -- Yes --> E["Jenkins"]
    D -- No --> F["Evaluate migrating<br/>to GitLab/GitHub"]
    B --> G{"Need full control<br/>over runners?"}
    C --> G
    E --> G
    G -- Yes --> H["Self-hosted runners/agents"]
    G -- No --> I["Hosted runners"]

For Ansible, all three are equally capable. The choice is usually determined by the existing ecosystem: if our repo is on GitHub, GitHub Actions is the path of least resistance. If we already use GitLab for issues, MRs, and the registry, GitLab CI reduces the number of tools we must maintain. Jenkins still makes sense for organizations with very specific requirements (e.g. build server farms with old OSes, or integration with internal tools that only have Jenkins plugins).


Direct Comparison: GitLab CI and GitHub Actions #

Here’s a side-by-side comparison for the features most used in Ansible pipelines. This isn’t to show “who’s better”, but to help translate workflows between platforms:

ConceptGitHub ActionsGitLab CI
Configuration file.github/workflows/*.yml.gitlab-ci.yml
Execution orderjobs.<id>.needs: [other jobs]top-level stages: block
Configuration reuseworkflow_call (reusable workflows)extends: and !reference
Parallel matrixstrategy.matrixparallel: matrix or manual triggers per combination
Passing variables between jobsoutputs: at the job levelartifacts.reports.dotenv
Manual approvalenvironment with required reviewerswhen: manual on the job
Environment trackingenvironment: on the job (limited)environment: with full deployment history
Container registryMust set up ourselves or use ghcr.ioBuilt-in per project ($CI_REGISTRY_IMAGE)
Secret managementRepository/org/environment secretsGroup/project/environment variables + masked + file type
External event triggersrepository_dispatchtrigger: with artifact include
Dynamic pipelinesComposite actions + matrixtrigger: with artifact include (child pipelines)
Self-hosted runnersRegistered in repo/org settingsRegistered in project/group/instance settings

The most striking difference: GitHub Actions uses an event-driven approach (workflow triggers → jobs run in parallel with needs: dependencies), GitLab CI uses a stage-driven approach (stages run sequentially → jobs in each stage run in parallel). If we often think “which job must run first”, GitLab CI fits better. If we more often think “what event triggers this”, GitHub Actions fits better.

Migrating workflows between platforms is real. Many teams move from Jenkins to GitLab CI, from GitLab CI to GitHub Actions, and vice versa. When migrating, don’t get stuck translating patterns 1:1 — take the opportunity to refactor the pipeline based on the new platform’s best practices. What was a “freestyle job” in Jenkins could become a “reusable workflow” in GitHub Actions; what was extends: in GitLab CI could become a “composite action” in GitHub Actions.

Anti-Patterns to Avoid #

The three most common anti-patterns in GitLab CI for Ansible:

1. Secrets Printed in Pipeline Logs #

# ANTI-PATTERN: secrets set as env vars and printed
deploy-production:
  script:
    - export VAULT_PASS="$VAULT_PASS_PROD"
    - export SSH_KEY="$PROD_SSH_KEY"
    - echo "Deploying with vault password: $VAULT_PASS"
    - echo "Using SSH key: $SSH_KEY"
    - ansible-playbook -i inventory/prod/ deploy.yml

  # ANTI-PATTERN: even though GitLab masks "part" of the value in logs,
  # the `echo` steps above will show the full value because
  # GitLab only masks if the exact format matches the pattern
  # already masked. Free printing won't be masked.
# CORRECT: secrets written directly to files, no echo
deploy-production:
  variables:
    VAULT_PASS_FILE: $VAULT_PASS_PROD   # File type variable
  script:
    - ansible-playbook -i inventory/prod/ deploy.yml
        --vault-password-file "$VAULT_PASS_FILE"

  # CORRECT: VAULT_PASS_PROD is set as a File type variable in
  # GitLab Settings → CI/CD → Variables. GitLab automatically writes
  # the value to a file and exposes its path. No env var containing
  # secrets, no echo that could leak.

2. Long-Running Pipelines with Mixed-Up Stages #

# ANTI-PATTERN: validation and deployment stages mixed in the same stage
stages:
  - build
  - deploy

build-and-deploy-staging:
  stage: deploy
  script:
    - ansible-lint
    - ansible-playbook -i inventory/staging/ site.yml
    - ./run-integration-tests.sh
    - ./run-security-scan.sh

  # ANTI-PATTERN: one giant job with 4 different concerns
  # (lint, deploy, test, scan). If the integration test fails,
  # we don't know if the problem is in the deploy, in the test
  # script, or in the environment. And we can't parallelize
  # molecule tests for 10 roles if everything is in one job.
# CORRECT: separate stages for each concern
stages:
  - validate
  - test
  - build
  - deploy

lint:
  stage: validate
  extends: .ansible_base
  script: ansible-lint

molecule-common:
  stage: test
  extends: .molecule_base
  script: cd roles/common && molecule test

molecule-nginx:
  stage: test
  extends: .molecule_base
  script: cd roles/nginx && molecule test

deploy-staging:
  stage: deploy
  extends: .deploy_base
  script: ansible-playbook -i inventory/staging/ deploy.yml

# CORRECT: four separate concerns, parallel within each stage,
# fail-fast between stages. If molecule-nginx fails,
# deploy-staging doesn't run. Completed stages don't
# need to be repeated if the next stage fails.

3. Hardcoded Image Tags in Multiple Jobs #

# ANTI-PATTERN: image tag hardcoded in every deploy job
deploy-staging:
  stage: deploy
  script:
    - ansible-playbook -i inventory/staging/ deploy.yml
        -e "app_version=2.1.5-abc1234"   # Hardcoded!

deploy-production:
  stage: deploy
  script:
    - ansible-playbook -i inventory/production/ deploy.yml
        -e "app_version=2.1.5-abc1234"   # Hardcoded too!

  # ANTI-PATTERN: every deploy job must manually update the tag,
  # or there's a separate script updating all files.
  # Risk: forgetting to update one job, deploying the wrong
  # environment, or tag drift between staging and production.
# CORRECT: the image tag is written once at build, forwarded via dotenv
build-image:
  stage: build
  script:
    - docker build -t $IMAGE_NAME:$IMAGE_TAG .
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - echo "DEPLOY_VERSION=$IMAGE_TAG" > build.env   # IMAGE_TAG = CI_COMMIT_SHORT_SHA
  artifacts:
    reports:
      dotenv: build.env

deploy-staging:
  stage: deploy
  script:
    - ansible-playbook -i inventory/staging/ deploy.yml
        -e "app_version=$DEPLOY_VERSION"   # Taken from build.env

deploy-production:
  stage: deploy
  script:
    - ansible-playbook -i inventory/production/ deploy.yml
        -e "app_version=$DEPLOY_VERSION"   # Always the same as staging

# CORRECT: one source of truth for the image tag. Staging and
# production always get the same version (the one tested in
# staging). No chance of typos or forgotten updates.

Summary #

  • Use .template_name: with extends: to avoid duplicating configuration between jobs — a change in the template immediately applies to all jobs extending it.
  • !reference to reuse before_script from other templates — allows more flexible composition than extends alone.
  • GitLab Environments provides automatic deployment tracking — the history of all deployments to every environment is available in the GitLab UI.
  • artifacts.reports.dotenv to pass variables from one job to the next — the right way to share image tags between build and deploy jobs.
  • when: manual on production deployments — requires a manual click in the GitLab UI, the pipeline doesn’t automatically continue to production.
  • Masked + Protected + File type variables for all secrets — masked prevents values from appearing in logs, protected ensures only protected branches can access them, file type avoids env vars containing secrets.
  • Separate stages for every concern: validate, test, build, deploy — not one multi-purpose stage. This enables parallelism within stages and fail-fast between stages.
  • Dynamic child pipelines for monorepos with many services — generate a pipeline based on the services changed in a commit, deploy only what needs deploying.

← Previous: GitHub Actions Next: Environment Management →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact