Intro
GitLab Runner executes CI/CD jobs, which means it holds credentials, touches source code, and often has broad access to your infrastructure. A misconfigured runner can leak secrets, allow unauthorized code execution, or become a pivot point for attackers. This guide gives you concrete, testable hardening steps for GitLab Runner, from initial version checks to access control, secret handling, and recovery procedures.
We focus on self-managed runners on Linux, though many principles apply to other platforms. You will learn how to inventory your runner, lock down its configuration, restrict who can use it, manage secrets safely, verify your changes, and recover when something goes wrong. Each section includes commands, expected outputs, and rollback paths.
Before you begin, ensure you have:
- A running GitLab instance (self-managed or GitLab.com) and at least one runner registered.
- Shell access to the machine hosting the runner.
sudoprivileges on that machine for configuration changes.- A test project in GitLab to validate runner behavior after changes.
Security disclaimer: Never use real credentials, tokens, or private keys in examples or documentation. Replace them with clearly marked placeholders like YOUR_RUNNER_TOKEN. Always test changes in a staging environment first.
Version and Environment Inventory
Knowing exactly what version of GitLab Runner you are running, how it is deployed, and what permissions it has is the foundation of any hardening effort. Different versions have different security features and bug fixes.
Check runner version
Run this on the runner host:
gitlab-runner --version
Expected output:
Version: 16.11.0
Git revision: 4b8e4c1f
Git branch: 16-11-stable
GO version: go1.21.9
Built: 2024-04-18T14:32:11Z
OS/Arch: linux/amd64
Note the version number. GitLab Runner follows semantic versioning, and security fixes are released regularly. Check the official GitLab Runner releases page to see if you are on the latest stable version. If not, plan an upgrade after you complete your inventory.
Determine deployment topology
GitLab Runner can be installed as a binary on a host, run in a Docker container, or deployed in Kubernetes. The topology affects how you apply security controls.
Binary install: Check the service status:
sudo systemctl status gitlab-runner
Docker install: List containers:
docker ps --filter "name=gitlab-runner"
Kubernetes install: List pods in the runner namespace:
kubectl get pods -n gitlab-runner
Knowing the topology helps you decide where to apply file permissions, network policies, and update procedures.
List registered runners and their configurations
The runner's configuration file (usually /etc/gitlab-runner/config.toml) contains sensitive information like registration tokens and executor settings. View it with caution:
sudo cat /etc/gitlab-runner/config.toml
This file may include tokens. Never share it or commit it to version control. For a quick summary without exposing secrets, you can use:
sudo gitlab-runner list
Example output:
Runtime platform arch=amd64 os=linux pid=1234 revision=4b8e4c1f version=16.11.0
Listing configured runners ConfigFile=/etc/gitlab-runner/config.toml
MyRunner Executor=docker Token=xxxxxx URL=https://gitlab.example.com
Note the executor types (shell, docker, kubernetes, etc.). The executor determines the attack surface. For example, the shell executor runs jobs directly on the host with the gitlab-runner user's privileges, which is riskier than the Docker executor that provides some isolation.
Check runner user and permissions
Identify the user under which the runner service runs:
ps aux | grep gitlab-runner
The output might show gitlab-runner as the user. Check what groups this user belongs to:
groups gitlab-runner
If the runner user is in the docker group, it effectively has root-level access to the host because Docker can mount the host filesystem. That is a common misconfiguration. We will address it in the access control section.
Record baseline
Before making changes, create a baseline snapshot:
sudo cp /etc/gitlab-runner/config.toml /etc/gitlab-runner/config.toml.bak.$(date +%Y%m%d)
sudo gitlab-runner --version > runner_version_baseline.txt
sudo systemctl status gitlab-runner > runner_service_baseline.txt
This allows you to compare and roll back if needed.
Safe Configuration Path
Hardening GitLab Runner involves editing its config.toml and possibly its service unit. The goal is to minimize privileges, disable unused features, and enforce secure defaults.
Back up and edit configuration
Always back up before editing:
sudo cp /etc/gitlab-runner/config.toml /etc/gitlab-runner/config.toml.backup
Edit with your preferred editor:
sudo nano /etc/gitlab-runner/config.toml
Harden the global configuration
In the [[runners]] section, consider these settings:
concurrent: limits the number of jobs a runner can run simultaneously. Lowering it reduces resource exhaustion risk. Example:concurrent = 2check_interval: how often the runner checks for new jobs. A shorter interval (e.g., 3 seconds) reduces job latency but increases load.log_level: set toinfoorwarnfor normal operation; avoiddebugin production as it may log sensitive data.sentry_dsn: if you use Sentry for error tracking, ensure the DSN does not contain a secret in the config file. Use environment variables instead.
Example snippet:
concurrent = 2
check_interval = 3
log_level = "info"
Disable unused executors
If you use only the Docker executor, ensure the shell executor is not configured. Remove or comment out any additional [[runners]] entries you don't need. Each runner entry increases the attack surface.
Restrict Docker executor capabilities
For Docker executor, you can limit container capabilities and resources. In the [runners.docker] section:
[runners.docker]
image = "alpine:latest"
privileged = false
disable_cache = false
volumes = ["/cache"]
shm_size = 0
cap_drop = ["ALL"]
cap_add = ["NET_BIND_SERVICE"]
devices = []
allowed_images = ["alpine:*", "debian:*"]
allowed_services = []
Key settings:
privileged = false: never run privileged containers unless absolutely necessary. Privileged containers have all capabilities and can access host devices.cap_drop = ["ALL"]andcap_addonly what is needed: this limits Linux capabilities inside containers.allowed_imagesrestricts which Docker images jobs can use. This prevents pulling arbitrary images that might contain malicious code.disallowed_imagescan be added with an array of image names (e.g.,["ubuntu:latest"]) to block specific ones.
Restrict network access for Docker
You can disable network access for jobs that don't need it:
[runners.docker]
network_mode = "none"
If jobs need network, use a user-defined Docker network with limited egress.
Harden the runner service unit
If the runner is installed as a system service, you can add security directives. Edit the service file with:
sudo systemctl edit gitlab-runner
Add directives like:
[Service]
# Prevent the service from gaining new privileges
NoNewPrivileges=true
# Restrict filesystem access
ProtectSystem=strict
ProtectHome=true
# Private /tmp
PrivateTmp=true
# Limit capabilities
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
After editing, reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart gitlab-runner
Verify the service is running:
sudo systemctl status gitlab-runner
Expected output includes Active: active (running).
Verify configuration after changes
Run the runner's built-in verification:
sudo gitlab-runner verify
Expected output:
Runtime platform arch=amd64 os=linux pid=5678 revision=4b8e4c1f version=16.11.0
Running in system-mode.
Verifying runner... is alive runner=xxxxxx
If you see errors like ERROR: Verifying runner... failed, check the config file syntax and network connectivity.
Access Control and Permissions
Controlling who can use the runner and what the runner can access is crucial. GitLab provides several layers of access control.
Runner registration and project access
By default, anyone with access to a project can register a runner for that project if they have the Manage project runners permission. To reduce risk, limit runner registration to administrators.
In GitLab, go to Admin Area > Settings > CI/CD > Runners and uncheck "Allow anyone to register runners" (for self-managed) or set appropriate permissions on GitLab.com.
Use protected runners
Protected runners only run jobs on protected branches or tags. This restricts execution to trusted code. To protect a runner:
- Go to your project's Settings > CI/CD > Runners.
- Find the runner and click the pencil icon.
- Check "Protected" and optionally "Run untagged jobs".
Alternatively, in config.toml, you can set:
[[runners]]
name = "ProtectedRunner"
url = "https://gitlab.example.com"
token = "YOUR_RUNNER_TOKEN"
executor = "docker"
protected = true
Note: The protected flag in config.toml is deprecated in favor of GitLab-side settings. Use the UI or API to manage protection.
Restrict runner to specific projects or groups
You can scope a runner to specific projects or groups. In config.toml, use limit to restrict the number of projects, but the actual assignment is done in GitLab. In the GitLab UI, you can assign a runner to a project or group. Group runners are shared across all projects in a group, which may be too broad. Prefer project-specific runners for high-security projects.
Operating system level permissions
Ensure the runner user (gitlab-runner) does not have unnecessary privileges:
- Remove from
dockergroup if using Docker executor with the Docker daemon socket. - Do not add to
sudoers. - Set a strong shell:
sudo chsh -s /usr/sbin/nologin gitlab-runnerif the runner does not need shell access.
Check sudo access:
sudo -l -U gitlab-runner
Expected output should show no sudo permissions (or only limited ones).
Network access control
Use firewall rules to restrict outbound traffic from the runner host to only necessary endpoints (GitLab server, Docker registry, etc.). For example, with ufw:
sudo ufw allow out to <GITLAB_SERVER_IP> port 443 proto tcp
sudo ufw allow out to <DOCKER_REGISTRY_IP> port 443 proto tcp
sudo ufw default deny outgoing
sudo ufw enable
Replace placeholders with actual IPs. Verify with:
sudo ufw status verbose
Secrets Management in GitLab Runner
Secrets are often needed in CI/CD jobs (API keys, deployment credentials, etc.). GitLab Runner must handle these securely.
Use GitLab CI/CD variables
Store secrets as protected and masked CI/CD variables in GitLab, never hardcode them in .gitlab-ci.yml or config.toml. To add a variable:
- Go to Settings > CI/CD > Variables.
- Add a variable (e.g.,
DEPLOY_TOKEN) and set it as "Masked" and optionally "Protected".
Masking prevents the value from appearing in job logs. Protected variables are only passed to jobs on protected branches or tags.
Avoid secrets in config.toml
The runner registration token and authentication token are in config.toml. Ensure file permissions are restrictive:
sudo chmod 600 /etc/gitlab-runner/config.toml
sudo chown root:root /etc/gitlab-runner/config.toml
If you need to store other secrets for the runner (e.g., Docker registry credentials), use environment variables or Docker config files with restricted permissions.
Use external secret managers
For advanced needs, integrate with Vault or cloud secret managers. GitLab supports Vault integration for CI/CD jobs. The runner fetches secrets at job runtime, so they never appear in the runner config.
Example Vault configuration in .gitlab-ci.yml:
job:
secrets:
DATABASE_PASSWORD:
vault: secret/data/production/db/password
This requires setting up Vault and configuring the runner to authenticate.
Key rotation
Regularly rotate runner registration tokens and any stored secrets. To reset a runner's authentication token:
- In GitLab, go to the runner's settings.
- Click "Reset authentication token".
- Update the
tokeninconfig.tomlwith the new value.
This breaks any leaked tokens.
Verification and Diagnostics
After hardening, you need to verify that the runner still works and that security controls are effective.
Run a test pipeline
Create a simple .gitlab-ci.yml in a test project:
test_job:
script:
- echo "Runner is working"
- whoami
- env | sort
Push to the project and watch the job. The whoami output tells you the user inside the container (e.g., root for Docker containers). If you expected non-root, adjust the Docker image or runner configuration.
The env output may reveal secrets. Ensure masked variables appear as [MASKED].
Check runner logs
Runner logs can show errors or security issues. View logs:
sudo journalctl -u gitlab-runner -f
Look for warnings about permissions, network failures, or configuration errors.
Validate Docker container security
If using Docker executor, inspect a running job container to confirm settings:
docker inspect <container_id> | jq '.[0].HostConfig.Privileged, .[0].HostConfig.CapDrop, .[0].HostConfig.CapAdd'
Expected output should show false for Privileged and appropriate capabilities.
Run security scanners
Use tools like trivy to scan the runner host and Docker images for vulnerabilities:
trivy fs /
trivy image alpine:latest
Address any critical findings.
Test access control
Try to trigger a job from an unauthorized branch if using protected runners. The job should not run. Also, try to register a new runner with a non-admin account; it should be denied if registration is restricted.
Failure Modes and Recovery
Even with careful planning, things can break. Be prepared to diagnose and recover.
Configuration syntax error
If config.toml has a syntax error, the runner won't start. Symptom: systemctl status shows failed, and logs show TOML parse errors.
Recovery:
sudo gitlab-runner verify
This may show the error. Fix the syntax or restore backup:
sudo cp /etc/gitlab-runner/config.toml.backup /etc/gitlab-runner/config.toml
sudo systemctl restart gitlab-runner
Runner cannot connect to GitLab
If network restrictions block access, jobs fail with messages like ERROR: Registering runner... failed or Job failed: couldn't execute command.
Check connectivity:
curl -v https://gitlab.example.com/api/v4/runners
If blocked, adjust firewall rules.
Docker daemon issues
If the runner cannot pull images or start containers, check Docker status:
sudo systemctl status docker
docker ps
Ensure the runner user can access Docker if using socket. If you removed the runner from the docker group, but the Docker executor still needs socket access, you may need to use a separate Docker context or run the runner as a user with appropriate permissions.
Privilege escalation attempt
If a job tries to use privileged mode and fails, that's a good sign. If it succeeds unexpectedly, check your config.toml for privileged = true and change to false.
Secrets leakage
If a secret appears in job logs, immediately:
- Revoke the secret from all systems.
- Rotate a new value.
- Update the GitLab variable (ensure "Masked" is checked).
- Investigate why it was leaked (e.g., debug logging,
echo $SECRET). - Consider using
set -xonly in safe contexts.
Recovery from failed service
If the runner service fails after system hardening (e.g., ProtectSystem=strict), relax the service settings gradually. For example, you may need to allow writes to certain directories:
[Service]
ReadWritePaths=/var/lib/gitlab-runner
Then reload and restart.
Common Pitfalls and How to Avoid Them
Many security issues stem from misunderstandings or shortcuts. Here are the most common mistakes.
Running privileged Docker containers
Why it happens: Developers often enable privileged mode to solve permission issues or to allow Docker-in-Docker.
Why it's dangerous: Privileged containers can access host devices and escape container isolation.
How to avoid: Use privileged = false and add only needed capabilities. For Docker-in-Docker, consider using the docker service with network_mode = "host" and a Docker daemon socket mount, or use Kaniko for building images without a daemon.
Using the shell executor unnecessarily
Why it happens: Simplest to set up, but it runs jobs directly on the host with high privileges.
Why it's dangerous: A malicious job can compromise the host.
How to avoid: Prefer Docker or Kubernetes executors. If you must use shell, run the runner as a dedicated user with minimal permissions and use a clean environment.
Leaving default registration open
Why it happens: Default settings may allow anyone with project access to register a runner.
Why it's dangerous: An attacker with low-level access can register a runner that runs jobs on their infrastructure, potentially stealing code or secrets.
How to avoid: Restrict runner registration to administrators.
Hardcoding secrets in .gitlab-ci.yml or config.toml
Why it happens: Quick and easy for developers.
Why it's dangerous: Secrets end up in version control or config files that may be exposed.
How to avoid: Always use GitLab CI/CD variables, masked and protected.
Ignoring runner updates
Why it happens: Fear of breaking things or lack of time.
Why it's dangerous: Outdated runners have known vulnerabilities.
How to avoid: Subscribe to GitLab security announcements and plan regular updates. Test updates in staging.
Overly permissive file permissions
Why it happens: Chmod 777 to solve access issues.
Why it's dangerous: Anyone on the system can read tokens.
How to avoid: Set minimal permissions (600 for config, 700 for directories).
Not monitoring runner activity
Why it happens: Set and forget.
Why it's dangerous: Anomalies go unnoticed.
How to avoid: Enable logging, integrate with monitoring tools, and set alerts for job failures or unusual activity.
Operations Checklist
This checklist summarizes the key hardening steps and provides a repeatable process for maintaining security. Assign an owner to each item and review periodically (e.g., monthly or quarterly).
| Item | Action | Verification | Owner | Review Frequency |
|---|---|---|---|---|
| Runner version | Check gitlab-runner --version | Version is latest stable | DevOps Engineer (Priya Shah) | Monthly |
| Config file permissions | sudo chmod 600 /etc/gitlab-runner/config.toml | ls -l shows -rw------- | System Administrator (Alex Chen) | Monthly |
| Privileged mode | Ensure privileged = false in config.toml | grep privileged /etc/gitlab-runner/config.toml | DevOps Engineer | Monthly |
| Runner registration | Restrict to admins in GitLab settings | No non-admin can register | GitLab Admin (Jordan Lee) | Quarterly |
| Secrets | All secrets in GitLab Variables, masked/protected | No secrets in code or logs | Security Lead (Sam Rivera) | Monthly |
| Runner user privileges | Check groups and sudo access | groups gitlab-runner and sudo -l -U gitlab-runner show limited access | System Administrator | Monthly |
| Network restrictions | Firewall rules limit outbound traffic | ufw status shows only allowed destinations | Network Admin (Casey Morgan) | Quarterly |
| Backup and recovery | Config backup exists and restore tested | Run a restore drill in staging | DevOps Engineer | Semi-annual |
| Monitoring | Logs and metrics monitored | Alerts firing on test events | SRE (Taylor Kim) | Monthly |
Accountable owners: Each item has a single named owner (e.g., Priya Shah, DevOps Engineer). Owners are responsible for executing the action and reporting status in the monthly security review meeting. The review meeting ensures items are not forgotten and adapts to new threats.
Conclusion
Hardening GitLab Runner is an ongoing process, not a one-time task. By following the steps in this guide, you reduce the risk of credential leakage, unauthorized code execution, and infrastructure compromise. Start with a version inventory, lock down configurations, enforce access controls, manage secrets properly, and verify everything with concrete tests. When failures occur, have a recovery plan ready.
As a next step, choose one low-risk item from the checklist, implement it, and observe the results. For example, set privileged = false on a test runner and run a pipeline that previously required privileges. Adjust accordingly. Then gradually work through the rest. Review your security posture monthly and stay informed about new GitLab Runner features and vulnerabilities.
A reliable security workflow makes failures visible, protects sensitive values, and defines recovery verification before an incident forces the decision.