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 --> D2Three 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--checkmode isn’t perfect. Tasks needing connections to remote services (e.g.commandorshellon managed nodes) will fail because Ansible doesn’t actually connect during check mode. For full verification, run molecule tests in theteststage — 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:
| Aspect | GitLab CI | Jenkins | GitHub Actions |
|---|---|---|---|
| Configuration | YAML in the repo, versioned with the code | Groovy/UI, versioned separately from the code | YAML in the repo, versioned with the code |
| Runner | Built-in (shared) or self-hosted | Self-hosted only (default) | GitHub-hosted or self-hosted |
| Learning curve | moderate — stages + extends + rules | steep — plugin ecosystem + Jenkinsfile DSL | moderate — jobs + steps + matrix |
| Ecosystem | Integrated with GitLab (issues, MRs, registry) | The largest plugin marketplace | Large marketplace, native GitHub integration |
| Self-host overhead | low — light GitLab Runner | high — Jenkins master + agents | none needed for hosted, light for self-hosted |
| Pipeline as code | Yes (.gitlab-ci.yml) | Yes (Jenkinsfile) but also UI-only possible | Yes (.github/workflows/) |
| Suitable for | Teams wanting an all-in-one DevOps platform | Teams with very custom plugin needs | Teams whose repositories are on GitHub |
| Licensing | Community Edition free, self-hostable | Open source, fully self-hostable | Free 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:
| Concept | GitHub Actions | GitLab CI |
|---|---|---|
| Configuration file | .github/workflows/*.yml | .gitlab-ci.yml |
| Execution order | jobs.<id>.needs: [other jobs] | top-level stages: block |
| Configuration reuse | workflow_call (reusable workflows) | extends: and !reference |
| Parallel matrix | strategy.matrix | parallel: matrix or manual triggers per combination |
| Passing variables between jobs | outputs: at the job level | artifacts.reports.dotenv |
| Manual approval | environment with required reviewers | when: manual on the job |
| Environment tracking | environment: on the job (limited) | environment: with full deployment history |
| Container registry | Must set up ourselves or use ghcr.io | Built-in per project ($CI_REGISTRY_IMAGE) |
| Secret management | Repository/org/environment secrets | Group/project/environment variables + masked + file type |
| External event triggers | repository_dispatch | trigger: with artifact include |
| Dynamic pipelines | Composite actions + matrix | trigger: with artifact include (child pipelines) |
| Self-hosted runners | Registered in repo/org settings | Registered 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:withextends:to avoid duplicating configuration between jobs — a change in the template immediately applies to all jobs extending it.!referenceto reusebefore_scriptfrom other templates — allows more flexible composition thanextendsalone.- GitLab Environments provides automatic deployment tracking — the history of all deployments to every environment is available in the GitLab UI.
artifacts.reports.dotenvto pass variables from one job to the next — the right way to share image tags between build and deploy jobs.when: manualon 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.