Understanding GitLab CI/CD and GitLab Runner
GitLab CI/CD is a built-in continuous integration and delivery system that automates the testing, building, and deployment of your code. At its core sits GitLab Runner — a lightweight agent that executes the jobs defined in your pipeline configuration. Think of GitLab as the orchestrator that tells Runner what to do, and Runner as the worker that actually runs the scripts, builds Docker images, or deploys to production.
When you push code to GitLab, the platform looks for a .gitlab-ci.yml file in your repository root. This file defines a pipeline — a sequence of stages and jobs. GitLab then dispatches these jobs to available Runners. A Runner can run on your local machine, a dedicated server, inside a Docker container, or on a Kubernetes cluster. This separation of concerns means your CI infrastructure can scale independently from your GitLab instance.
Key Terminology
- Pipeline: The top-level construct that groups stages and jobs triggered by commits, tags, or schedules
- Stage: A logical grouping of jobs that run sequentially (e.g., build → test → deploy)
- Job: A single unit of work that runs in isolation, with its own environment and artifacts
- Executor: The mechanism Runner uses to execute jobs — shell, Docker, Kubernetes, VirtualBox, or custom
- Artifacts: Files produced by a job that can be passed to downstream jobs or downloaded manually
- Cache: Dependencies stored between pipeline runs to speed up execution
Why Setting Up Your Own Runner Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →GitLab.com provides shared Runners for all users, but they come with limitations: limited monthly minutes on free tiers, no control over the execution environment, and potential queue delays during peak usage. By deploying your own GitLab Runner, you gain:
- Unlimited builds: No minute restrictions on self-managed Runners
- Environment control: Install exactly the dependencies, SDK versions, and OS packages your project needs
- Network access: Reach internal services, databases, and private registries behind your firewall
- Performance: Dedicated compute resources with predictable build times
- Security: Keep sensitive credentials and source code within your own infrastructure
- Custom hardware: Access GPUs, specialized ARM devices, or iOS build machines
For teams shipping production software, a dedicated Runner is not optional — it's the foundation of a reliable delivery pipeline.
Architecture Overview
The GitLab Runner architecture follows a simple, elegant pattern. The Runner agent registers with a GitLab instance (self-hosted or GitLab.com) using a registration token. Once registered, it continuously polls for pending jobs. When a job is available, the Runner spawns an isolated environment using its configured executor, runs the job scripts, and reports results back to GitLab.
The Runner itself is stateless — all project-specific configuration lives in the repository's .gitlab-ci.yml file. This means you can reuse the same Runner across hundreds of projects, each with their own pipeline definitions.
Installing GitLab Runner
Option 1: Native Package Installation (Linux)
This is the most common setup for production environments. The Runner runs as a systemd service directly on the host OS.
# Add the official GitLab Runner repository
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
# Install the package
sudo apt-get install gitlab-runner
# Verify installation
gitlab-runner --version
gitlab-runner status
For RHEL/CentOS systems, use the equivalent RPM repository script:
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo yum install gitlab-runner
Option 2: Docker Deployment
Running the GitLab Runner itself inside a Docker container gives you portability and simplifies upgrades. This is ideal when you want the Runner to use the Docker executor for jobs (Docker-in-Docker pattern).
# Create a persistent configuration volume
docker volume create gitlab-runner-config
# Start the Runner container
docker run -d --name gitlab-runner --restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v gitlab-runner-config:/etc/gitlab-runner \
gitlab/gitlab-runner:latest
The mounted Docker socket allows the Runner container to spawn sibling containers for each job — this is significantly faster and more reliable than true Docker-in-Docker (DinD).
Option 3: Kubernetes Deployment
For teams operating at scale, deploying Runner on Kubernetes provides elastic capacity. Each pipeline job runs in its own ephemeral pod, and you can leverage cluster auto-scaling to handle build spikes.
# Using Helm (recommended approach)
helm repo add gitlab https://charts.gitlab.io
helm repo update
# Install the GitLab Runner chart
helm install gitlab-runner gitlab/gitlab-runner \
--set gitlabUrl=https://gitlab.example.com \
--set runnerRegistrationToken="YOUR_REGISTRATION_TOKEN" \
--set rbac.create=true \
--set runners.privileged=true \
--namespace gitlab-runner \
--create-namespace
The Kubernetes executor dynamically creates pods with build and helper containers. The helper container handles cloning the repository, downloading artifacts, and uploading results, while the build container runs your actual job scripts.
Registering a Runner
Registration links a Runner instance to your GitLab project or group. You'll need a registration token, which you can find in your project's Settings → CI/CD → Runners section, or at the group/admin level for shared Runners.
# Interactive registration (asks for GitLab URL, token, description, tags, executor)
sudo gitlab-runner register
# Non-interactive registration with all parameters
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.example.com" \
--registration-token "PROJECT_OR_GROUP_TOKEN" \
--description "production-docker-runner" \
--tag-list "docker,linux,production" \
--executor "docker" \
--docker-image "alpine:latest" \
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock" \
--run-untagged=true \
--locked=false
The --tag-list parameter is critical — jobs in your .gitlab-ci.yml specify tags to route themselves to appropriate Runners. For example, a job tagged docker will only run on Runners that have docker in their tag list.
Configuring the Runner (config.toml)
After registration, GitLab Runner stores its configuration in /etc/gitlab-runner/config.toml. Understanding this file lets you fine-tune Runner behavior beyond what registration offers.
concurrent = 4
check_interval = 0
log_level = "info"
[[runners]]
name = "production-docker-runner"
url = "https://gitlab.example.com"
id = 42
token = "encrypted_runner_token"
executor = "docker"
limit = 2
output_limit = 4096
[runners.docker]
image = "docker:20.10.16"
privileged = true
volumes = [
"/var/run/docker.sock:/var/run/docker.sock",
"/cache:/cache"
]
shm_size = 256000000
extra_hosts = ["internal.registry.local:192.168.1.100"]
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
AccessKey = "AKIA..."
SecretKey = "..."
BucketName = "gitlab-runner-cache"
BucketLocation = "us-east-1"
Key configuration options:
- concurrent: Maximum number of jobs the Runner will process simultaneously across all registered executors
- limit: Per-executor concurrency cap (overrides the global concurrent setting for this specific runner entry)
- output_limit: Maximum job log size in kilobytes before truncation
- shm_size: Shared memory size for Docker containers (critical for browser testing tools like Chrome/Puppeteer)
- extra_hosts: Custom host-to-IP mappings injected into job containers — useful for reaching internal services
Writing Your First .gitlab-ci.yml Pipeline
Now that your Runner is installed and registered, let's create a pipeline configuration in your repository. Create a file named .gitlab-ci.yml at the project root.
Basic Three-Stage Pipeline
stages:
- build
- test
- deploy
variables:
DOCKER_IMAGE: registry.example.com/myapp
DOCKER_TAG: ${CI_COMMIT_SHORT_SHA}
build-job:
stage: build
tags:
- docker
script:
- echo "Building Docker image..."
- docker build -t ${DOCKER_IMAGE}:${DOCKER_TAG} .
- docker push ${DOCKER_IMAGE}:${DOCKER_TAG}
artifacts:
paths:
- dist/
expire_in: 1 hour
unit-test:
stage: test
tags:
- docker
script:
- docker pull ${DOCKER_IMAGE}:${DOCKER_TAG}
- docker run --rm ${DOCKER_IMAGE}:${DOCKER_TAG} npm test
dependencies:
- build-job
lint:
stage: test
tags:
- docker
script:
- npm install
- npm run lint
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
deploy-staging:
stage: deploy
tags:
- docker
script:
- echo "Deploying to staging..."
- kubectl apply -f k8s/staging/
environment:
name: staging
url: https://staging.example.com
only:
- develop
deploy-production:
stage: deploy
tags:
- docker
script:
- echo "Deploying to production..."
- kubectl apply -f k8s/production/
environment:
name: production
url: https://app.example.com
when: manual
only:
- main
This pipeline demonstrates several essential patterns. The tags directive ensures jobs run on your dedicated Docker Runner. The artifacts block passes build output between stages. The cache block persists node_modules across pipeline runs using a branch-specific key. The environment block integrates with GitLab's Environments dashboard for tracking deployments.
Using CI/CD Variables and Secrets
Never hardcode credentials in your pipeline file. Instead, set variables in GitLab's UI (Settings → CI/CD → Variables) and reference them securely.
deploy-production:
stage: deploy
tags:
- docker
script:
# These variables are injected from GitLab's protected variables
- aws configure set aws_access_key_id ${AWS_ACCESS_KEY_ID}
- aws configure set aws_secret_access_key ${AWS_SECRET_ACCESS_KEY}
- aws s3 sync dist/ s3://my-production-bucket/ --delete
only:
- main
# Protected variables are only available on protected branches/tags
variables:
AWS_ACCESS_KEY_ID:
description: "AWS access key for production S3"
AWS_SECRET_ACCESS_KEY:
description: "AWS secret key (masked in logs)"
Matrix Builds for Multi-Platform Testing
GitLab supports parallel matrix jobs — perfect for testing against multiple language versions or operating systems.
test-matrix:
stage: test
tags:
- docker
parallel:
matrix:
- NODE_VERSION: ["16", "18", "20"]
DB: ["postgres", "mysql"]
image: node:${NODE_VERSION}-alpine
services:
- ${DB}:latest
script:
- npm install
- npm test
# Exclude combinations that don't make sense
exclude:
- NODE_VERSION: "16"
DB: "mysql"
This generates 6 parallel jobs (3 Node versions × 2 databases, minus the excluded combination). Each runs in its own container with the appropriate service linked.
Advanced Runner Configuration Patterns
Auto-Scaling Docker Runners with Docker Machine
For environments with variable build loads, auto-scaling Runners spin up cloud instances on demand. This uses the Docker Machine executor (legacy but still widely used) or the newer Docker Autoscaler executor.
# config.toml entry for an auto-scaling AWS runner
[[runners]]
name = "autoscale-runner"
url = "https://gitlab.example.com"
token = "runner_token"
executor = "docker+machine"
limit = 10
[runners.docker]
image = "docker:20.10.16"
privileged = true
[runners.machine]
IdleCount = 2
IdleTime = 1800
MaxGrowthRate = 10
MachineDriver = "amazonec2"
MachineName = "gitlab-runner-%s"
MachineOptions = [
"amazonec2-region=us-east-1",
"amazonec2-instance-type=m5.large",
"amazonec2-root-size=50",
"amazonec2-iam-instance-profile=gitlab-runner",
"amazonec2-private-address=true",
"amazonec2-tag=gitlab-runner,managed"
]
OffPeakPeriods = ["* * 0-6,22-23 * * mon-fri", "* * * * * sat,sun"]
OffPeakIdleCount = 0
OffPeakIdleTime = 600
This configuration maintains 2 warm instances during business hours, scales to 0 overnight and on weekends, and caps growth at 10 new instances per cycle to prevent runaway costs.
Distributed Caching with S3
When multiple Runners share a project, a distributed cache ensures all Runners benefit from previously downloaded dependencies.
# In your .gitlab-ci.yml
cache:
key: ${CI_COMMIT_REF_SLUG}-${CI_JOB_NAME}
paths:
- .gradle/wrapper
- .gradle/caches
- node_modules/
- vendor/bundle
policy: pull-push
Combine this with the S3 cache configuration in config.toml (shown earlier). The pull-push policy ensures the job both downloads the latest cache before execution and uploads updated cache after completion.
Debugging and Troubleshooting Runners
Common Issues and Solutions
- "Job is stuck pending with no Runner assigned": Verify your job tags match the Runner's tags exactly. Tags are case-sensitive. Check the Runner is alive with
gitlab-runner verify. - "Cannot connect to the Docker daemon": Ensure the Docker socket is mounted correctly and the Runner has
privileged = truein its config.toml if running Docker-in-Docker. - "No space left on device": Docker images accumulate over time. Add a cleanup job or cron:
docker system prune -af --volumes. - "Permission denied" when cloning: Check that the Runner user has SSH keys configured or that the project uses HTTPS with deploy tokens.
- "Cache not found": Cache keys are branch-specific by default. Use a global key like
key: global-cacheif you want cross-branch sharing.
Useful Debugging Commands
# List all registered runners and their status
sudo gitlab-runner list
# Verify a specific runner's connectivity
sudo gitlab-runner verify --name "production-docker-runner"
# Run a job locally for debugging (single job from .gitlab-ci.yml)
gitlab-runner exec docker build-job
# View runner logs in real-time
sudo gitlab-runner run --working-dir /home/gitlab-runner --config /etc/gitlab-runner/config.toml
# Unregister and re-register a problematic runner
sudo gitlab-runner unregister --name "production-docker-runner"
sudo gitlab-runner register --non-interactive --url "..." --registration-token "..." --executor "docker"
The gitlab-runner exec command is invaluable for debugging pipeline failures without pushing to GitLab repeatedly. It runs a single job locally using the same executor and environment the Runner would use in production.
Security Best Practices
- Use Protected Variables: Mark variables containing secrets as "protected" in GitLab — they'll only be available on protected branches (like main or release tags), preventing exposure in feature branch pipelines
- Mask All Secrets: Enable the "Mask variable" option so values are redacted in job logs — this prevents accidental exposure through copy-pasted log output
- Rotate Registration Tokens: Runner registration tokens should be rotated periodically, especially if a Runner is decommissioned or compromised
- Lock Runners to Specific Projects: When registering, use
--locked=trueso the Runner only serves its assigned project, reducing the blast radius of a compromised Runner - Isolate Sensitive Runners: Deploy separate Runners for production deployments with stricter access controls and network segmentation
- Audit Runner Logs: Centralize Runner logs and monitor for unusual patterns — failed authentication attempts, unexpected job durations, or access to unexpected resources
- Use Image Pull Secrets: When pulling from private registries, configure Kubernetes secrets or Docker config.json rather than embedding credentials in pipeline variables
Performance Optimization
- Pre-warm Docker Images: Schedule a cron job on Runner hosts to periodically pull commonly used images (like
node:20-alpineorpython:3.12) so jobs start instantly without waiting for image downloads - Cache Strategically: Cache only what's expensive to regenerate —
node_modules,.gradle,vendor/bundle. Don't cache build artifacts (use artifacts for those instead) - Parallelize Tests: Split test suites across parallel jobs using GitLab's
paralleldirective with CI node index variables - Optimize Docker Layer Caching: Structure Dockerfiles so frequently-changing layers come last — use multi-stage builds to minimize final image size
- Set Resource Limits: In Kubernetes executor setups, define resource requests and limits per job to prevent a single pipeline from starving other jobs
# Example: parallel test splitting in .gitlab-ci.yml
test-parallel:
stage: test
tags:
- docker
parallel: 4
script:
# CI_NODE_INDEX ranges from 1 to parallel count
- npm install
- npx jest --ci --shard=${CI_NODE_INDEX}/${CI_NODE_TOTAL}
artifacts:
reports:
junit: junit.xml
paths:
- coverage/
Monitoring and Maintenance
A healthy CI/CD infrastructure requires ongoing attention. Set up monitoring for your Runners:
# Prometheus metrics endpoint (built into GitLab Runner)
# Access at: http://runner-host:9252/metrics
# Key metrics to monitor:
# - gitlab_runner_jobs_completed_total
# - gitlab_runner_jobs_failed_total
# - gitlab_runner_jobs_duration_seconds
# - gitlab_runner_concurrent_jobs
# - gitlab_runner_errors_total
Regular maintenance tasks include pruning Docker images weekly (docker system prune -f), updating the Runner binary, reviewing job queue times in GitLab's CI/CD analytics, and capacity planning based on pipeline frequency trends.
Conclusion
GitLab Runner is the engine that transforms your .gitlab-ci.yml definitions into actual executed pipelines. By deploying dedicated Runners — whether on bare metal, Docker hosts, or Kubernetes clusters — you gain complete control over your CI/CD environment. The setup process is straightforward: install the Runner package, register it with a token, configure your executor, and start writing pipeline jobs.
The patterns covered here — from basic three-stage pipelines to matrix builds, distributed caching, auto-scaling infrastructure, and security hardening — form a solid foundation for any team's delivery workflow. Remember that CI/CD is not a "set it and forget it" system; it requires monitoring, maintenance, and continuous refinement. But the investment pays dividends in faster feedback loops, fewer production incidents, and a developer experience that encourages frequent, confident deployments. Start with a single dedicated Runner for your team's most critical project, iterate on the pipeline configuration, and expand your Runner fleet as your needs grow.