GitLab CI/CD in Depth: Pipelines, Runners, and Real-World Patterns
If you already know the general idea of CI/CD, GitLab's implementation adds its own specific vocabulary and mechanics on top - runners, stages, DAG pipelines, environments - that don't map one-to-one onto other platforms. This post assumes you know what CI/CD is broadly for and focuses specifically on how GitLab does it.
The pipeline file
Every GitLab pipeline is defined in a single .gitlab-ci.yml file at the root of your repo:
stages:
- build
- test
- deploy
build-job:
stage: build
script:
- npm ci
- npm run build
test-job:
stage: test
script:
- npm test
deploy-job:
stage: deploy
script:
- ./deploy.sh
only:
- main
By default, jobs in the same stage run in parallel, and stages run sequentially - everything in build finishes before anything in test starts. That's the simple model. GitLab also supports a more flexible one, covered below.
Runners: what actually executes your jobs
A runner is the agent that picks up jobs and runs them. GitLab.com provides shared runners for free (with usage limits), but many teams register their own:
- Shared runners - managed by GitLab, used across many projects. Convenient, but you're sharing capacity and can't customize the underlying environment much.
- Specific runners - registered to your own project or group, running on infrastructure you control (a VM, a Kubernetes cluster, even a laptop for testing). Necessary once you need specific hardware, network access to internal systems, or predictable performance.
Runners use an executor to decide how a job actually runs - docker (each job in a fresh container, the most common choice), shell (directly on the runner's host), kubernetes (spins up a pod per job), among others. You target a specific runner using tags:
deploy-job:
stage: deploy
tags:
- internal-network
script:
- ./deploy.sh
Caching vs artifacts - a common point of confusion
cache- speeds up future pipeline runs by reusing things likenode_modulesbetween jobs/pipelines. Not guaranteed to be there (it's best-effort), so nothing in your pipeline should depend on the cache existing.artifacts- files a job explicitly produces and passes forward to later stages in the same pipeline run (a build output, a test report). Guaranteed to be available to downstream jobs that need them.
build-job:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
cache:
paths:
- node_modules/
Mixing these up is a common source of "why did my deploy job not find the build output" bugs - that's what artifacts is for, not cache.
Stages vs DAG pipelines
The stage model above is simple but has a real cost: every job in test waits for every job in build to finish, even if it only actually depends on one of them. GitLab's needs keyword lets you express direct dependencies instead, forming a directed acyclic graph (DAG) where jobs start as soon as their specific dependencies are done, regardless of stage:
test-frontend:
stage: test
needs: [build-frontend]
script:
- npm test
test-backend:
stage: test
needs: [build-backend]
script:
- pytest
If build-backend finishes first, test-backend can start immediately rather than waiting on build-frontend too - for pipelines with many independent components, this can meaningfully cut total pipeline time.
Merge request pipelines and environments
By default, pipelines run on every push to a branch. GitLab can also run merge request pipelines specifically in the context of a merge request (useful for running checks that only make sense pre-merge, or showing pipeline status directly on the MR). Combined with environments, you can track exactly what's deployed where:
deploy-staging:
stage: deploy
script:
- ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
GitLab then shows a deployment history per environment, and can auto-create review apps - a temporary, isolated deployment per merge request - which is genuinely useful for reviewing frontend changes without needing to run the branch locally.
Where GitLab CI/CD fits with GitOps
Everything above is about building and testing code and deciding when to trigger a deploy - GitLab pipelines can absolutely run kubectl apply or helm upgrade directly in a deploy-job. Some teams take a different approach for the deployment step specifically: instead of the pipeline pushing changes directly to a cluster, it just updates a Git repository, and a separate GitOps controller running inside the cluster pulls and applies those changes. That's a meaningfully different model worth understanding on its own - see our GitOps with Flux guide for what that looks like in practice.
Ready to test what you've learned? Try the CI/CD quiz on OpsQuiz.