Intro
GitLab CI/CD is more than a tool that runs scripts after a push. Its advanced capabilities let teams model complex delivery workflows, protect sensitive values, release with confidence, and recover quickly when a pipeline fails. This article walks through the advanced concepts that matter in production: runner architecture, pipeline control with DAGs and rules, environments and deployment safety, artifact and cache management, security scanning, and failure diagnosis. Each section includes concrete commands, example configuration, expected output, and recovery guidance.
This material is written for developers, DevOps consultants, and technical startup teams who already understand basic .gitlab-ci.yml syntax and have run at least one pipeline. The focus is operational safety: observe before changing, limit blast radius, keep secrets out of logs and configuration, verify each outcome, and know how to roll back when something goes wrong.
Version and Environment Inventory
Before changing any advanced setting, know exactly what you are running. Record the GitLab instance version, runner versions, executor types, and relevant infrastructure details. This prevents troubleshooting the wrong component and makes recovery decisions precise.
Check GitLab Instance Version
Run a read-only API call from a workstation with network access to the GitLab instance. Replace <https://gitlab.example.com> with your instance URL and <your-personal-access-token> with a token that has at least read_api scope.
curl --header "PRIVATE-TOKEN: <your-personal-access-token>" "<https://gitlab.example.com/api/v4/version>"
Expected output for GitLab 16.10:
{
"version": "16.10.2-ee",
"revision": "a1b2c3d4e5f"
}
If the API returns 401 Unauthorized, verify the token scope and expiration. Never paste the token into a shared document; use a secret manager or environment variable.
Identify Runners and Executors
List runners registered to a project. Navigate to Settings > CI/CD > Runners in the web UI, or use the API:
curl --header "PRIVATE-TOKEN: <your-personal-access-token>" "<https://gitlab.example.com/api/v4/projects/<project_id>/runners"
Expected response includes runner IDs, descriptions, online status, and executor types (shell, docker, kubernetes, etc.). Record the executor for each runner because advanced features like Docker-in-Docker, Kubernetes pod scheduling, and cache behavior depend on it.
Inventory Dependencies
For a typical production pipeline, record the following in a runbook or wiki page:
- GitLab instance version (e.g., 16.10.2-ee)
- Runner version (
gitlab-runner --versionon each runner host) - Executor type for each runner (e.g.,
docker,kubernetes) - Docker version on runner hosts (
docker --version) - Kubernetes cluster version if using Kubernetes executor (
kubectl version --short) - External services used by jobs (artifact repositories, cloud providers, monitoring endpoints)
Keep this inventory up to date after upgrades. A known version mismatch between GitLab and runners can cause subtle failures, such as unrecognized configuration keywords or cache incompatibility.
Runner Architecture and Executor Selection
GitLab Runner executes jobs. Choosing the right executor affects isolation, performance, and security. The most common executors for advanced setups are Docker, Docker Machine (deprecated but still present), Kubernetes, and Shell.
Docker Executor with Privileged Mode
For jobs that need to build container images, Docker-in-Docker (DinD) is common but requires careful configuration. The runner must be registered with --docker-privileged and the job must set DOCKER_TLS_CERTDIR appropriately.
Example runner registration:
gitlab-runner register \
--non-interactive \
--url "<https://gitlab.example.com>" \
--registration-token "<runner-registration-token>" \
--executor "docker" \
--docker-image "docker:24.0.5" \
--docker-privileged \
--docker-volumes "/certs/client"
In .gitlab-ci.yml:
build-image:
image: docker:24.0.5
services:
- docker:24.0.5-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t registry.example.com/myapp:$CI_COMMIT_SHA .
- docker push registry.example.com/myapp:$CI_COMMIT_SHA
Expected result: job runs inside a docker:24.0.5 container, starts a sibling DinD container, and the docker commands connect to the DinD daemon via TLS certificates mounted at /certs/client.
Security note: DinD with privileged mode gives the build container elevated capabilities on the runner host. Prefer using kaniko or buildah for unprivileged builds in shared runner environments, or use a dedicated runner pool for DinD jobs.
Kubernetes Executor
Kubernetes executor creates a pod for each job. Advanced configuration includes node selectors, resource limits, and service account mapping. Example runner config.toml section:
[[runners]]
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-runners"
poll_timeout = 600
cpu_limit = "1"
memory_limit = "2Gi"
[runners.kubernetes.node_selector]
"node-role.kubernetes.io/ci" = "true"
[runners.kubernetes.volumes]
[[runners.kubernetes.volumes.host_path]]
name = "docker-sock"
mount_path = "/var/run/docker.sock"
host_path = "/var/run/docker.sock"
This configuration schedules jobs only on nodes labeled node-role.kubernetes.io/ci=true, limits each job pod to 1 CPU and 2Gi memory, and mounts the host Docker socket for jobs that need container management. Mounting the Docker socket weakens isolation; avoid it unless strictly necessary and understand the security implications.
To verify a Kubernetes runner works, run a simple job and inspect the pod events:
kubectl get pods -n gitlab-runners -l job-name=<job-name>
kubectl describe pod <pod-name> -n gitlab-runners
Look for successful image pull, container start, and no resource-related events like FailedScheduling or OOMKilled.
Shell Executor for Specialized Workloads
Shell executor runs jobs directly on the runner host. It is suitable when jobs need access to host-specific tools, hardware, or file systems. Register with:
gitlab-runner register --executor shell --url "<https://gitlab.example.com>" --registration-token "<token>"
In .gitlab-ci.yml, a job using shell executor might run a local script:
deploy-local:
script:
- /usr/local/bin/deploy.sh --env staging
tags:
- shell-runner
Because shell jobs inherit the runner host environment, they can accidentally leave artifacts, expose secrets, or impact other processes. Prefer Docker or Kubernetes executors for isolation unless there is a strong requirement for host access.
Pipeline Control: DAG, Needs, and Rules
Advanced pipelines use Directed Acyclic Graphs (DAG) to run jobs in parallel when safe, and rules to decide when a job should run. This reduces pipeline duration and prevents unnecessary work.
Using needs to Create a DAG
By default, jobs run in stages sequentially. The needs keyword allows a job to start as soon as its specified dependencies finish, ignoring stage order.
stages:
- build
- test
- deploy
build-app:
stage: build
script: make build
test-unit:
stage: test
needs: ["build-app"]
script: make test-unit
test-integration:
stage: test
needs: ["build-app"]
script: make test-integration
deploy-staging:
stage: deploy
needs: ["test-unit", "test-integration"]
script: make deploy-staging
In this example, test-unit and test-integration both depend only on build-app and can run in parallel. deploy-staging waits for both tests to succeed, not for the entire test stage. The resulting DAG reduces total pipeline time when tests are independent.
Expected behavior: if test-unit fails, test-integration continues because it does not need test-unit. Only deploy-staging is blocked. This is a key difference from stage-based execution, where a failure in one job would block the entire stage.
Conditional Execution with rules
The rules keyword is the modern replacement for only and except. It evaluates conditions in order and stops at the first match.
deploy-production:
stage: deploy
script: make deploy-production
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
when: manual
allow_failure: false
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
when: auto
- when: never
Interpretation:
- Pushes to
maincreate a manual deployment job that must be clicked in the web UI. - Tags matching semantic versioning (e.g.,
v1.2.3) trigger automatic deployment. - All other cases skip the job.
Common mistake: forgetting the final when: never. Without it, unmatched conditions cause the job to be included with default behavior, often leading to unexpected runs.
Dynamic Child Pipelines
For monorepos or complex workflows, generate child pipelines dynamically from a configuration file produced by a job.
generate-config:
stage: build
script:
- ./generate-pipeline-config.sh > generated-config.yml
artifacts:
paths:
- generated-config.yml
child-pipeline:
stage: test
trigger:
include:
- artifact: generated-config.yml
job: generate-config
The generate-config job writes a valid .gitlab-ci.yml fragment to generated-config.yml. The child-pipeline job consumes that artifact and triggers a child pipeline. This allows per-directory or per-service pipeline definitions without maintaining a monolithic file.
To debug the generated configuration, download the artifact from the pipeline page and run gitlab-ci-lint if available locally, or use the CI Lint API endpoint:
curl --header "PRIVATE-TOKEN: <token>" --header "Content-Type: application/json" --data '{"content": "<pasted yaml>"}' "<https://gitlab.example.com/api/v4/ci/lint>"
Environments, Deployments, and Release Safety
GitLab environments represent where code is deployed. Advanced use includes protected environments, deployment approval, and rollback via re-running older pipelines.
Define an Environment
deploy-staging:
stage: deploy
environment:
name: staging
url: <https://staging.example.com>
script:
- kubectl apply -f staging.yaml
After the job runs, the environment staging appears in Operate > Environments with a link to the URL. GitLab tracks deployments, including who deployed, what commit, and when.
Protected Environments and Approvals
Protect a production environment by going to Settings > CI/CD > Protected environments. Select the environment and choose which users or roles can deploy. In .gitlab-ci.yml, such jobs become deployable only by authorized users even if the pipeline runs automatically.
Example: require manual action by a maintainer for production deployments.
deploy-production:
stage: deploy
environment:
name: production
url: <https://prod.example.com>
script:
- ./deploy-production.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: false
With the environment protected and constrained to maintainers, a developer cannot click the manual job; only a maintainer can. This enforces separation of duties.
Rollback Strategy
GitLab does not automatically roll back, but you can re-run a previous deployment job from the environment page. Alternatively, keep a dedicated rollback job:
rollback-production:
stage: deploy
environment:
name: production
action: stop
script:
- ./rollback-production.sh --to $PREVIOUS_VERSION
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
Set PREVIOUS_VERSION as a CI/CD variable before triggering the rollback job. For Kubernetes deployments, kubectl rollout undo deployment/myapp is a quick rollback command that can be wrapped in a job.
Artifacts, Caching, and Dependencies
Artifacts are files generated by a job that are passed to subsequent jobs, while caches are for dependencies that should be reused across pipelines for speed. Misusing them leads to bloated storage or incorrect builds.
Artifact Management
Define artifacts with expiration to prevent unlimited storage growth.
build-assets:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
reports:
junit: test-results.xml
The dist/ directory is passed to jobs that declare dependencies: [build-assets] or to the default next stage. The JUnit report appears in merge request widgets for test visualization. Expiration of one week keeps storage manageable.
To clean up old artifacts manually, use the API:
curl --request DELETE --header "PRIVATE-TOKEN: <token>" "<https://gitlab.example.com/api/v4/projects/<project_id>/artifacts"
This deletes all artifacts for the project. Use with caution; there is no per-artifact granularity in this endpoint.
Cache Best Practices
Cache dependencies that are expensive to fetch, like npm or Maven packages. Use a key that changes when dependencies change.
build-node:
stage: build
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci
The cache key changes whenever package-lock.json changes, ensuring correct cache invalidation. Do not cache build outputs that are meant to be artifacts; use artifacts for those.
Distributed runners require a shared cache. Configure a cache server for Docker and Kubernetes executors, or use a cloud storage like S3. In config.toml:
[runners.cache]
Type = "s3"
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
AccessKey = "<access-key>"
SecretKey = "<secret-key>"
BucketName = "gitlab-runner-cache"
Store the access key and secret in environment variables or a secrets manager, not in plain config.toml. GitLab Runner supports AccessKey and SecretKey as environment variables.
Security: Secrets, Scanning, and Best Practices
Advanced CI/CD must handle secrets safely and incorporate security scanning without slowing development excessively.
Secrets Management
Never put secrets in .gitlab-ci.yml or commit them to the repository. Use CI/CD variables with protection and masking.
Create a variable in Settings > CI/CD > Variables. Mark it as protected if it should only be available on protected branches and tags, and masked if it should be hidden from job logs.
In .gitlab-ci.yml:
deploy:
script:
- echo "Deploying with token ${DEPLOY_TOKEN}"
If DEPLOY_TOKEN is masked, the output shows [MASKED] when the value would appear. Avoid using secrets in command arguments where the process list might expose them (e.g., ps aux). Prefer environment variables and file-based secrets.
For more advanced secret management, integrate with HashiCorp Vault using the vault keyword (GitLab 15.0+). Example:
job:
id_tokens:
ID_TOKEN:
aud: <https://gitlab.example.com>
secrets:
DATABASE_PASSWORD:
vault: secret/data/production/db/password
file: false
This requires configuring a Vault server and the GitLab instance to trust it. The secret is injected into the job environment, not stored in GitLab.
Security Scanning Templates
GitLab provides SAST, dependency scanning, secret detection, container scanning, and DAST. Include them using include and template.
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
stages:
- test
sast:
stage: test
variables:
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp"
Run these jobs in merge request pipelines to catch vulnerabilities before merge. The results appear in the merge request widget and the security dashboard.
Review the findings promptly. False positives are common; configure exclusions and severity thresholds according to your team's risk appetite.
Failure Modes and Recovery
Even with advanced configuration, pipelines fail. Knowing how to diagnose and recover is essential.
Common Failure: Job Stuck Pending
If a job remains pending, check that a runner with matching tags is online. In the job log, the first lines show the runner selection process.
Diagnostic commands:
gitlab-runner list
systemctl status gitlab-runner
If the runner is offline, restart it:
sudo systemctl restart gitlab-runner
If the runner is online but not picking up jobs, verify that the job's tags match the runner's tags. A job with tag docker will not be picked by a runner tagged shell.
Recovery: either change the job tag to match an existing runner, or register a new runner with the required tag.
Common Failure: Docker Build Fails with Cannot connect to the Docker daemon
This occurs when the DinD service is not available or DOCKER_TLS_CERTDIR is misconfigured.
Check the job log for service startup lines. In the job script, add a connectivity test:
docker info
Expected output includes server version and storage driver. If it fails, ensure the runner has --docker-privileged and the job includes services: - docker:dind and variables: DOCKER_TLS_CERTDIR: "/certs".
For unprivileged alternatives, replace DinD with kaniko:
build:
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination registry.example.com/myapp:$CI_COMMIT_SHA
This eliminates the need for privileged mode.
Common Failure: Artifact Upload Fails or Expired
If a downstream job reports missing artifacts, check the upstream job's artifact paths and expiration. Wrong paths or symlinks can cause empty artifact archives.
In the upstream job log, look for "Uploading artifacts..." and verify the listed files. Use ls -la in the script to confirm files exist.
If artifacts expired, increase expire_in or re-run the pipeline. To permanently store artifacts, use GitLab's package registry or an external artifact repository instead of CI artifacts.
Recovery Strategy: Pipeline Rollback
For deployment pipelines, GitLab environments allow re-running previous deployment jobs. On the environment page, select the desired deployment and click "Rollback" (if available). This re-runs the job with the same variables as the original deployment. Ensure rollback scripts are idempotent.
Operations Checklist
Before modifying an advanced GitLab CI/CD configuration, complete the following checklist:
- Record the GitLab instance version and runner versions (
gitlab-runner --version). - Inventory executors and their capabilities (privileged, Kubernetes, shell).
- Confirm that all secrets are stored in protected and masked CI/CD variables, not in the repository.
- Review the existing
.gitlab-ci.ymlforrules,needs, andenvironmentdefinitions. - Ensure artifacts have appropriate
expire_invalues and caches have correct keys. - Check runner tags and availability for each job.
- Validate the pipeline configuration using the CI Lint API before pushing changes.
- Define failure signals: what logs or metrics indicate a failed deployment?
- Prepare a rollback procedure and test it in a staging environment first.
- Limit the change to one component at a time with a clear verification step.
Example pre-change validation using CI Lint API:
curl --header "PRIVATE-TOKEN: <token>" --header "Content-Type: application/json" --data '{"content": "<pasted yaml>"}' "<https://gitlab.example.com/api/v4/ci/lint>"
Expected output: {"valid":true,"errors":[]} with optional warnings.
After applying the change, monitor the first pipeline run. Compare duration, job statuses, and artifact sizes with previous runs. If something breaks, revert the change using version control, not by patching in the UI.
Conclusion
Advanced GitLab CI/CD features give teams the power to build sophisticated delivery pipelines, but that power demands operational discipline. Runners and executors must be selected for isolation and capability; pipeline DAGs and rules must be designed for speed and safety; environments must be protected and reversible; artifacts and caches must be managed for efficiency and correctness; and security scanning must be integrated without creating noise.
Start with one low-risk improvement. For example, introduce needs to parallelize independent test jobs in a staging pipeline. Record the before and after pipeline duration, verify that artifacts and dependencies remain correct, and then gradually adopt more advanced patterns.
A reliable GitLab CI/CD workflow makes failures visible, protects sensitive values, limits changes to the intended scope, and defines recovery steps before an incident occurs. Use this article as a reference to audit your current configuration and plan incremental improvements with confidence.