GitLab CI/CD
GitLab CI/CD explained: how .gitlab-ci.yml pipelines, runners, stages and artifacts build, test and deploy code and data workflows, with a worked example.
Overview
GitLab CI/CD is the continuous integration and continuous delivery system built into GitLab. You describe a pipeline in a YAML file stored in your repository, usually named .gitlab-ci.yml. Every time you push a commit or open a merge request, GitLab reads that file, creates a pipeline, and hands the jobs to machines called runners. The jobs build code, run tests, scan for problems, package results and deploy them.
For teams that work with tabular data, GitLab CI/CD is the place where data code gets the same discipline as application code. A pipeline can lint SQL, validate schemas, run data quality tests, check a data contract, train or evaluate a model on a small sample, and then release the result. Because the pipeline lives in the repository, it is reviewed, versioned and reproducible like any other file.
What it is
GitLab CI/CD is a pipeline engine. A pipeline is an ordered set of jobs grouped into stages. A job is a script that runs in a clean environment, often a container. If a job fails, the pipeline reports it on the commit or merge request, and later stages can be stopped.
It has four main building blocks.
- The pipeline file. A YAML file that lists stages, jobs, rules and variables.
- Runners. Agents that pick up jobs and execute them. They can be shared, group level or project level.
- Artifacts and caches. Ways to pass files between jobs and to speed up repeated work.
- Environments and deployments. Named targets such as staging and production, with history and rollbacks.
Continuous integration versus continuous delivery
Continuous integration means every change is built and tested automatically and often. Continuous delivery means that a change that passes can be released with a single, repeatable action. Continuous deployment goes one step further and releases automatically. GitLab supports all three, and you choose how much to automate with rules and manual gates.
An everyday analogy
Think of a restaurant kitchen with a strict pass. Every plate that leaves the kitchen goes through the same checks: right ingredients, right temperature, right presentation. The pipeline is the pass. The cooks (developers) can work fast because the pass catches mistakes before a customer sees them. Runners are the line cooks who actually do each step, and the recipe card is the YAML file.
Where it came from
GitLab began as an open source code hosting project in the early 2010s. A separate continuous integration tool grew alongside it, and the two were later merged so that CI became part of the same product and the same interface. That choice shaped its character: the pipeline is configured in the repository, and the results appear next to merge requests, issues and code review.
The wider idea of continuous integration is older. It grew from agile and extreme programming practices in the late 1990s and early 2000s, where teams learned that merging often and testing automatically reduced painful integrations. Earlier server based tools such as Jenkins popularised the idea. Over time the field moved from clicking through a web interface to "pipeline as code", where the definition is a text file kept with the source. GitLab adopted this approach early. See Jenkins and GitHub Actions for comparable systems.
In the 2020s, CI/CD also expanded into machine learning and data engineering. Teams began to treat data validation, feature checks and model evaluation as pipeline stages, a practice often called MLOps or DataOps.
How it works
Here is the flow from a commit to a finished pipeline.
Step 1: A change is pushed
You push a commit or open a merge request. GitLab detects the change and looks for the pipeline file in the repository.
Step 2: The pipeline is created
GitLab parses the YAML, resolves includes and templates, evaluates rules and variables, and builds a graph of jobs. Jobs that do not match the rules are left out.
Step 3: Jobs wait for a runner
Each job is queued. A runner that matches the job's tags asks GitLab for work, receives it, and starts. The runner uses an executor, such as a Docker executor that starts a fresh container from the image named in the job, or a Kubernetes executor that starts a pod. See Docker and Kubernetes.
Step 4: Jobs run in stages
Jobs in the same stage run in parallel. A stage starts only when the previous stage has succeeded, unless you use the needs keyword to build a directed graph that lets jobs start as soon as their dependencies finish. That is like a kitchen where the sauce can start as soon as the stock is ready, without waiting for the dessert station.
Step 5: Results are collected
Each job writes logs, exit codes, test reports and artifacts. GitLab shows them in the merge request. Test report files in JUnit format are parsed so failed tests appear inline.
Step 6: Deployment and environments
Deploy jobs target an environment. GitLab tracks which commit is live, who deployed it, and offers rollback to an earlier deployment. You can require manual approval for production.
A minimal pipeline file
stages:
- lint
- test
- build
- deploy
default:
image: python:3.12-slim
cache:
key: pip-cache
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
lint:
stage: lint
script:
- pip install ruff
- ruff check .
unit-tests:
stage: test
script:
- pip install -r requirements.txt pytest
- pytest --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
deploy-staging:
stage: deploy
script:
- ./scripts/deploy.sh staging
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
Key concepts and terms
| Term | Meaning |
|---|---|
| Pipeline | The full run triggered by a commit, merge request, schedule or API call |
| Stage | A group of jobs that run in parallel; stages run in order |
| Job | One unit of work with a script, an image and settings |
| Runner | An agent that executes jobs |
| Executor | How a runner runs a job: shell, Docker, Kubernetes and others |
| Artifact | Files a job saves and passes to later jobs or to download |
| Cache | Files reused between runs to save time, such as downloaded packages |
| Rules | Conditions that decide whether a job is created |
| Needs | A keyword that builds a job graph instead of strict stages |
| Environment | A named deployment target such as staging or production |
| Variables | Key value settings, some masked or protected for secrets |
| Include | A way to reuse pipeline fragments from other files or projects |
| Merge request pipeline | A pipeline that runs for the proposed result of a merge |
| Child pipeline | A pipeline triggered from another one, useful in large repositories |
| Protected branch | A branch with restricted pushes and access to protected variables |
A worked example
Suppose a team keeps SQL transformations and Python feature code for a customer table. They want every merge request to check the SQL style, validate a sample against the table schema, run unit tests, and only then allow a deploy.
The repository layout
repo/
.gitlab-ci.yml
sql/
customers.sql
contracts/
customers.yaml
tests/
test_features.py
scripts/
validate_sample.py
The pipeline
stages: [static, data-checks, test, release]
sql-lint:
stage: static
image: python:3.12-slim
script:
- pip install sqlfluff
- sqlfluff lint sql/ --dialect ansi
contract-check:
stage: data-checks
image: python:3.12-slim
script:
- pip install pandas pyyaml pyarrow
- python scripts/validate_sample.py contracts/customers.yaml data/sample.parquet
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
pytest:
stage: test
image: python:3.12-slim
script:
- pip install -r requirements.txt pytest
- pytest -q
coverage: '/TOTAL.*\s+(\d+%)$/'
publish-package:
stage: release
script:
- ./scripts/publish.sh
environment: production
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
The validation script
import sys
import pandas as pd
import yaml
contract_path, data_path = sys.argv[1], sys.argv[2]
contract = yaml.safe_load(open(contract_path))
df = pd.read_parquet(data_path)
errors = []
for col in contract["columns"]:
name = col["name"]
if name not in df.columns:
errors.append(f"missing column {name}")
continue
if col.get("required") and df[name].isna().any():
errors.append(f"nulls in required column {name}")
extra = set(df.columns) - {c["name"] for c in contract["columns"]}
if extra:
errors.append(f"unexpected columns: {sorted(extra)}")
if errors:
print("\n".join(errors))
sys.exit(1)
print("contract ok")
What happens
A developer renames a column in the SQL. The sql-lint job passes. The contract-check job fails with missing column customer_id. The merge request shows the red job, the reviewer sees the message, and the rename is fixed before it reaches production. This is the core value: a cheap, automatic gate at the boundary. See Data contract validation and Schema-drift detection.
Strengths and limitations
Strengths
- Everything is in one product: source, review, pipelines, registry and environments.
- Pipeline as code means changes are reviewed and versioned.
- Flexible runners, from a laptop to autoscaling clusters.
- Good support for reuse through includes, templates and components.
- Merge request pipelines give early feedback before code lands.
- Environments and deployment history make releases traceable.
- Built in features for container registries and package registries.
Limitations
- Complex pipelines can grow into long YAML files that are hard to read.
- Debugging often means pushing a commit and waiting, unless you test locally.
- Shared runners can queue at busy times, and self hosted runners need care.
- Cache and artifact behaviour surprises beginners.
- Long running jobs such as full model training do not fit well; use CI to trigger and track them instead.
- Secrets handling needs discipline: masked variables help but are not a full secret manager. See HashiCorp Vault.
- It is not a workflow orchestrator for data. Scheduling and retrying data tasks is the job of tools like Apache Airflow.
How it compares with alternatives
| Tool | Where it fits | Compared with GitLab CI/CD |
|---|---|---|
| GitHub Actions | CI/CD tied to GitHub repositories | Very similar model; different syntax and marketplace of reusable actions |
| Jenkins | Self hosted automation server | More plugins and freedom, more maintenance, pipelines less tied to the repository host |
| Argo CD | GitOps deployment to Kubernetes | Focuses on delivery and sync, not on building and testing |
| Apache Airflow | Data workflow orchestration | Runs scheduled data tasks with dependencies; not meant for build and test of code |
| dbt | SQL transformation and tests | A tool that GitLab CI/CD can run and test on each change |
| Terraform | Infrastructure as code | A tool that pipelines commonly run to plan and apply infrastructure |
The main decision is usually practical. If your code lives in GitLab, its built in CI/CD is the natural choice. If it lives elsewhere, use the native option there.
Role in tabular foundation models
A tabular foundation model learns from raw tables. It must cope with rows and columns, mixed numeric and text types, missing values and sometimes no useful column names. Such a model is pre-trained on many tables, often synthetic ones generated from random causal structures plus real collections, and at use time it can learn from a handful of example rows through in-context learning. GitLab CI/CD does not train that kind of model, but it supports the engineering around it in concrete ways.
Guarding the table boundary
Every model that reads raw tables depends on the table keeping its shape. A pipeline job can check column count, types, missing value rates and row counts before any model sees the data. This catches a silently changed column that would otherwise shift meaning inside the model's input. Read more in Schema-drift detection.
Reproducible evaluation
A merge request can run a small, fixed evaluation set against a model or a preprocessing change. For a tabular model this might include a classification set, a regression set, an imputation test where known values are masked and then recovered, and an anomaly detection check on rows with planted outliers. The numbers from each run are stored as artifacts so that a regression is visible in review. See Imputation networks and Anomaly detection for the task types. The wider results context is on /model/benchmarks.
Continual learning and safe releases
When a model is updated over time, each update should be tested for forgetting: did accuracy on earlier table types drop? A pipeline can run the same old and new test suites on every candidate and block the release if scores fall. Environments and manual approval give a clear human gate before a new model version is served. See MLflow for tracking versions.
Testing semantic and sector classification
Models that label a table's columns or its whole domain (for example, finance versus healthcare) can be tested with fixed example tables in CI. If a code change alters how headers are tokenised or how missing column names are handled, the pipeline shows whether predicted labels moved.
In-context learning inputs
In-context learning works by feeding labelled example rows plus query rows into the model. The code that builds those inputs, such as sampling rows, encoding types and padding, is ordinary software. Unit tests in CI keep it correct. A small bug here can quietly degrade predictions without any error.
Building and shipping serving images
Pipelines build container images that hold the model server and push them to a registry, then deploy to a cluster. See Docker and Kubernetes. More on architecture is at /model/architecture and research context at /model/research.
Honest limits
Pre-training a large model is a long job on specialised hardware. It should run on a dedicated cluster, not inside a CI job. CI should trigger, record and validate it, not host it.
Practical guidance and best practices
Keep pipelines fast
- Run cheap checks first: linting, formatting, schema checks.
- Use
needsso independent jobs start early. - Cache package downloads, not build outputs you can rebuild cheaply.
- Use small base images.
- Use
rules:changesto skip jobs when unrelated files changed.
Keep pipelines readable
- Split large files with
include. - Use hidden jobs (names starting with a dot) and
extendsfor shared settings. - Give jobs clear names and keep each one focused on a single concern.
- Document non obvious variables in comments.
Handle secrets carefully
- Store secrets as masked and protected variables, or fetch them from a secret manager at run time.
- Never print secrets in logs.
- Limit which branches can use production variables.
- Use short lived tokens where possible.
Make data jobs deterministic
- Use small, versioned sample files for tests, not live production extracts.
- Pin library versions in a lockfile.
- Set random seeds in anything stochastic.
- Record the data version used for each evaluation.
Use environments with intent
- Deploy to staging automatically from the default branch.
- Require manual approval for production.
- Keep the same deploy script for each environment; change only variables.
- Make rollback a documented, tested action.
Use merge request pipelines
Run the full set of checks on the merge request, so the default branch stays green. Protect the default branch and require passing pipelines before merge.
Test the pipeline itself
Use a linter for the YAML, keep a sandbox project for experiments, and make changes in small steps. Pipeline files are code, and they deserve review.
Add scheduled pipelines for drift
A nightly scheduled pipeline can sample the latest source data and run schema and quality checks even when no code changed. That finds upstream changes quickly. See Data contract validation.
Reusing pipeline logic
Large organisations rarely write each pipeline from scratch. They publish shared templates and components, and projects include them.
include:
- project: platform/ci-templates
ref: v2
file: /python-test.yml
- local: /ci/data-checks.yml
variables:
DATA_SAMPLE: "data/sample.parquet"
Pin the reference to a tag, not a moving branch. A change to the shared template then reaches your project only when you choose to upgrade.
Parallel matrices
A matrix runs the same job with different values. This is useful when a data transformation must work on several library versions or several sample tables.
pytest-matrix:
stage: test
image: python:$PY
parallel:
matrix:
- PY: ["3.10", "3.11", "3.12"]
script:
- pip install -r requirements.txt pytest
- pytest -q
Reading a failed pipeline
When a pipeline fails, work through it in the same order each time.
- Open the first failed job, not the last one. Later failures are often side effects.
- Read the last thirty lines of the log, then scroll up for the first error.
- Check whether the failure is new. Compare with the previous green pipeline on the same branch.
- Reproduce locally using the same image and the same commands.
- If the failure is flaky, record it and fix the cause. Do not just retry forever.
Measuring pipeline health
Teams that depend on CI track a few simple numbers.
| Measure | Why it matters |
|---|---|
| Median pipeline duration | Slow feedback makes people batch changes |
| Failure rate on the default branch | A red default branch blocks everyone |
| Flaky test count | Flaky tests erode trust |
| Queue time for runners | Shows whether capacity is enough |
| Time from merge to production | A simple view of delivery speed |
A second example: model evaluation gate
This example shows a gate that compares a candidate model's score file with a stored baseline. The score files are placeholders produced by an earlier job.
evaluate:
stage: test
script:
- python eval/run.py --out scores.json
- python eval/compare.py --baseline baseline.json --candidate scores.json --max-drop 0.01
artifacts:
paths:
- scores.json
expire_in: 30 days
import json, sys
args = dict(a.split("=") for a in sys.argv[1:] if "=" in a)
base = json.load(open("baseline.json"))
cand = json.load(open("scores.json"))
for task, b in base.items():
c = cand.get(task)
if c is None:
print(f"missing task {task}")
sys.exit(1)
if b - c > 0.01:
print(f"regression on {task}: {b:.3f} -> {c:.3f}")
sys.exit(1)
print("no regression")
The job fails if any task drops by more than the allowed margin. The reviewer sees which task moved. The same idea works for fairness checks, latency budgets and memory limits.
Common mistakes
- Putting everything in one giant job. It is hard to debug and cannot run in parallel.
- Using the latest image tag. Builds change without any commit. Pin versions.
- Testing against live production data. Slow, risky and not repeatable.
- Ignoring failed optional jobs. Allowed failures that nobody reads become noise.
- Leaking secrets in logs. Debug output can reveal tokens.
- Skipping caches, or caching the wrong thing. Either slow builds or stale results.
- Long manual deploy steps hidden in a wiki. Put them in the pipeline.
- No ownership of runners. Unpatched or overloaded runners cause flaky builds.
- Treating CI as a scheduler for heavy data jobs. Use a proper orchestrator.
- Never testing rollback. The first time you need it should not be an incident.
- Letting flaky tests stay flaky. People stop trusting the pipeline.
- Letting model evaluation depend on unpinned data. Scores change for reasons unrelated to the code.
Frequently asked questions
What is GitLab CI/CD in simple terms?
GitLab CI/CD is a feature of GitLab that runs automated jobs whenever your code changes. You describe the jobs in a YAML file, and GitLab uses runners to build, test and deploy your project, then shows the results next to your commits and merge requests.
What is the .gitlab-ci.yml file?
It is the configuration file at the root of a repository that defines the pipeline. It lists stages, jobs, scripts, images, rules and variables. Because it lives with the code, changes to the pipeline are reviewed and versioned like any other change.
What is a GitLab runner?
A runner is a program that picks up jobs from GitLab and runs them on a machine. It can run commands directly, inside a container, or in a Kubernetes pod. You can use shared runners provided by an instance or register your own for more control and private network access.
What is the difference between a stage and a job?
A job is one unit of work with a script to run. A stage is a group of jobs that run in parallel, and stages run one after another. With the needs keyword you can let jobs start as soon as their specific dependencies finish rather than waiting for the whole stage.
Can GitLab CI/CD be used for data pipelines?
Yes, for the engineering around them. It is well suited to linting SQL, running data tests on samples, validating schemas and contracts, and deploying transformation code. It is not designed to schedule and retry production data workflows, which is better handled by an orchestrator such as Apache Airflow.
How do I keep secrets safe in a pipeline?
Store them as masked and protected CI variables or fetch them at run time from a secret manager. Restrict production secrets to protected branches, avoid printing them, and prefer short lived credentials. Review job logs and shared templates for accidental exposure.
How do I speed up slow pipelines?
Run cheap checks first, cache dependency downloads, use small images, run independent jobs in parallel with needs, and skip jobs when the changed files do not affect them. Measure job times in the pipeline view to find the real bottleneck before tuning.
How does GitLab CI/CD relate to machine learning models?
It automates the testing and release steps around a model. A pipeline can validate input data, run a fixed evaluation set, compare scores to a baseline, build a serving image and deploy after approval. Training large models usually happens elsewhere, with the pipeline recording and checking the outcome.
What is the difference between continuous delivery and continuous deployment?
Continuous delivery keeps every passing change ready to release, with a human choosing when to ship. Continuous deployment releases every passing change automatically. GitLab supports both through manual jobs and rules on deploy stages.
Is GitLab CI/CD better than GitHub Actions or Jenkins?
Neither is better in general. The models are similar, and the best choice follows where your code is hosted and what your team already knows. Jenkins offers more freedom with more maintenance, while the repository integrated options offer simpler setup.
Glossary
- Artifact: A file or folder saved by a job for later jobs or download.
- Cache: Files kept between pipeline runs to avoid repeated downloads.
- CI/CD: Continuous integration and continuous delivery or deployment.
- Child pipeline: A pipeline started by another pipeline.
- DAG: A directed acyclic graph of jobs created with the needs keyword.
- Environment: A named deployment target with history.
- Executor: The method a runner uses to run a job.
- Image: The container image in which a job runs.
- Include: A keyword that merges other YAML files into the pipeline.
- Job: A script with settings that runs on a runner.
- Merge request: A proposal to merge a branch, with review and checks.
- MLOps: Practices that bring DevOps ideas to machine learning.
- Pipeline: The set of jobs created by one trigger.
- Protected variable: A variable available only on protected branches or tags.
- Rules: Conditions that decide whether a job runs.
- Runner: The agent that executes jobs.
- Stage: An ordered group of jobs that run in parallel.
- Tag: A label that matches jobs to runners.
Summary
GitLab CI/CD turns a YAML file in your repository into an automatic path from commit to release. Runners execute jobs in stages, artifacts and caches carry files, and environments track what is deployed. For tabular data work it is the safety net: it lints SQL, validates schemas and contracts, runs fixed evaluations and gates releases. For tabular foundation models it protects the table boundary, keeps evaluation reproducible, supports safe model updates and ships serving images, while heavy training stays on dedicated hardware. Keep pipelines fast, readable and deterministic, guard secrets, and test the pipeline as carefully as the code it protects.
Related
It belongs to DataOps in my stack, under Data engineering and lakehouse. Others in the same group: