Runner Security
Protected Runners
Runner Tags
Isolation
Best Practices
A complete guide to GitLab Runner security covering protected runners, tags, isolation, secrets, ephemeral runners, and best practices for securing CI/CD pipelines.
Protected Runners
Isolation
Best Practices
Tags
Why Runner Security Matters
Runners are the most sensitive component of a GitLab CI/CD setup. They execute arbitrary code from your repositories, they have access to the secrets you've configured, and they often have privileged access to your infrastructure — cloud credentials, deployment keys, and network access to internal systems. A compromised runner is a serious security incident.
What makes runner security especially challenging is that runners execute code that changes constantly. Every push to a repository might introduce new code that runs on a runner. If a contributor's account is compromised, or if a malicious contributor is added to a project, they could push code that attacks the runner or exfiltrates its secrets. A secure runner architecture anticipates this — it assumes that job code is untrusted and designs accordingly.
The good news is that GitLab and Kubernetes provide excellent tools for securing runners. The bad news is that securing them requires deliberate effort. This guide covers the controls you need: protected runners, tags, isolation, ephemeral runners, secrets management, and network policies. Together these form a defense-in-depth strategy that keeps your CI/CD infrastructure safe even when job code is malicious.
Critical Insight: A runner that executes untrusted code is a gateway to your infrastructure. Assume any job could be malicious and design your runner architecture to limit what a compromised job can do.
Threat Model: What Are We Protecting Against?
Before diving into controls, it's worth being explicit about what we're protecting against. A runner faces several classes of threats:
Malicious Job Code
A contributor pushes code that runs in a job and attacks the runner, exfiltrates secrets, or attacks other infrastructure.
Secret Exfiltration
A job captures CI/CD variables (API tokens, credentials) and sends them to an attacker-controlled endpoint.
Container Escape
A privileged job breaks out of its container and gains access to the host or other containers.
Lateral Movement
A compromised job attacks other workloads, internal services, or cloud resources reachable from the runner.
Cross-Project Attacks
A job in one project attacks another project's jobs running on the same shared runner.
Supply Chain Attacks
A malicious dependency or action compromises the job and, through it, the runner.
Defense in Depth: No single control defends against all these threats. Combine isolation, least privilege, network policies, and monitoring to build a layered defense.
Protected Runners
A protected runner is a runner that only accepts jobs from protected branches and tags. This is one of the most important security controls you can put in place, because it establishes a clear boundary: only code that has been reviewed and merged can run on trusted infrastructure.
Why does this matter? Feature branches are where developers experiment. They may include work-in-progress code, unreviewed contributions from external collaborators, or code from a compromised developer account. If a feature branch can access a production deploy runner, an attacker with push access to that branch can run arbitrary code on the runner — potentially with production credentials. By marking the runner as protected, you ensure that only code on main (or other protected branches) can run there.
Protected runners are configured during registration with the --access-level ref_protected flag, or can be modified later in the runner's configuration. When a non-protected branch pushes a job that needs the runner's tags, GitLab will not assign that job to the protected runner — the job stays pending (or is picked up by another non-protected runner with matching tags, if one exists).
# Register a protected runner
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "Protected Production Runner" \
--tag-list "production,deploy" \
--access-level "ref_protected"
# In the GitLab UI:
# Project → Settings → CI/CD → Runners
# The runner shows "Protected" badge
# Using protected runners in .gitlab-ci.yml
deploy_production:
stage: deploy
tags:
- production
- deploy
script:
- deploy.sh production
environment:
name: production
only:
- main # Only on protected branches
# Behavior:
# - Job on main → Protected runner accepts → Runs
# - Job on feature branch → Protected runner rejects → Stays pending
# (or is picked up by a non-protected runner, if available)
# Configure protected branches:
# Project → Settings → Repository → Protected Branches
# - main (protected by default)
# - release/* (custom pattern)
# - production (custom branch)
# Runner access levels:
# - not_protected: Accepts any job
# - ref_protected: Only accepts jobs on protected branches/tags
# Best practice:
# - Mark all production runners as ref_protected
# - Use protected branches for main, release, production
# - Combine with protected variables for maximum security
# - Never let unprotected code run on production runners
# Combine with protected variables:
# Project → Settings → CI/CD → Variables
# - Set DB_PASSWORD: Protected ✓, Masked ✓
# - The variable is only available on protected branches
# - Even if a feature branch runs on a shared runner,
# the production secrets are not available
Protected Runner Best Practices:
- Mark all production runners as
ref_protected
- Protect the main branch and release branches
- Use protected variables for production secrets
- Combine with protected environments for deployment approval
- Audit which runners are protected periodically
Runner Tags for Security
Tags are not just for routing jobs — they're also a security mechanism. By tagging runners according to their trust level (e.g., trusted, production, untrusted), you can ensure that jobs only run on runners with the right trust profile. A job that builds untrusted code should be tagged untrusted so it can't accidentally end up on a production runner.
The most common pattern is to have separate runners for different environments. A "staging" runner might accept jobs from feature branches, while a "production" runner only accepts jobs from main. This is enforced by combining tags with ref_protected: the production runner has the production tag and ref_protected access level, so only protected branches with the production tag can use it.
A related control is to disable run_untagged on production runners. If a runner has run_untagged = true, it will accept jobs that have no tags at all — which means a job in an untrusted project could accidentally end up on your production runner. Setting run_untagged = false forces every job that wants to use the runner to explicitly declare the runner's tags, which makes routing intentions explicit.
# Tag runners by trust level
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "TOKEN" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "Trusted Production Runner" \
--tag-list "production,trusted" \
--access-level "ref_protected" \
--run-untagged="false"
# A job that explicitly requires the production tag
deploy_prod:
stage: deploy
tags:
- production
- trusted
script:
- deploy.sh production
only:
- main
# A job that runs on untrusted runners
test_feature:
stage: test
tags:
- untrusted
script:
- npm test
# Separate runners for separate trust levels:
# - untrusted-runner: run_untagged=true, access_level=not_protected
# → Builds and tests feature branches
# - staging-runner: tags=staging, access_level=ref_protected
# → Deploys to staging from main
# - production-runner: tags=production, access_level=ref_protected
# → Deploys to production from main (manual)
# Runner tags as security boundary:
# - A job with tag "untrusted" cannot run on the production runner
# - A job with tag "production" cannot run on the untrusted runner
# - Routing is enforced by GitLab based on tags
# Best practices:
# 1. Tag runners by trust level (untrusted, staging, production)
# 2. Set run_untagged=false on trusted runners
# 3. Combine tags with ref_protected
# 4. Never share a runner between different trust levels
# 5. Document which tags mean what
# Checking tags:
# Project → Settings → CI/CD → Runners
# Shows each runner's tags and protected status
Tags vs Scope:
- Scope: Which projects a runner serves (shared/group/project)
- Tags: Which jobs a runner accepts within those projects
- Access Level: Which branches a runner accepts (not_protected/ref_protected)
- All three must match for a runner to accept a job
Isolation: The Foundation of Runner Security
Isolation is the single most important security property of a runner. When a job is isolated, any damage it does is confined to its own environment — it can't see other jobs' data, can't affect the host, and can't attack other infrastructure. Without isolation, one compromised job can bring down everything.
The shell executor provides no isolation at all. Jobs run directly on the host with the runner user's permissions. They can read each other's files, interfere with running processes, and potentially compromise the host. This is why the shell executor should only be used for trusted code.
The Docker executor provides strong isolation. Each job runs in its own container with its own filesystem, process namespace, and network namespace. A job can't see other jobs' files, can't kill other processes, and generally can't break out of its container (barring a container escape vulnerability, which are rare but not impossible).
The Kubernetes executor provides even stronger isolation when configured correctly. Each job runs in its own pod, which can have its own security context, network policy, and runtime class. With gVisor or Kata Containers, jobs run in a sandboxed kernel, providing isolation even against kernel exploits.
# Isolation comparison
#
# Shell Executor:
# - NO isolation
# - Jobs share the host filesystem
# - Jobs share the host's process list
# - Jobs can see each other's files
# - A job can modify the host's OS
#
# Docker Executor:
# - Strong isolation
# - Each job in its own container
# - Filesystem, process, and network namespaces
# - Containers are discarded after the job
# - Runs share the same Docker daemon
#
# Kubernetes Executor:
# - Strong isolation
# - Each job in its own pod
# - Kubernetes namespaces, network policies
# - Can use gVisor/Kata for stronger isolation
# - Pods are discarded after the job
#
# Docker executor with security options
[[runners]]
name = "Secure Docker Runner"
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false # No privileged containers
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache"] # Only necessary volumes
shm_size = 0
network_mode = "bridge" # Isolated network
pull_policy = "always"
# Drop all capabilities
[runners.docker.security_opt]
"no-new-privileges:true" = "true"
# Kubernetes executor with pod security
[[runners]]
name = "Secure K8s Runner"
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-runner"
image = "alpine:latest"
privileged = false
allow_privilege_escalation = false
cpu_limit = "1"
memory_limit = "2Gi"
[runners.kubernetes.pod_security_context]
run_as_non_root = true
run_as_user = 1000
run_as_group = 1000
fs_group = 1000
seccomp_profile_type = "RuntimeDefault"
[runners.kubernetes.container_security_context]
allow_privilege_escalation = false
read_only_root_filesystem = false
capabilities:
drop = ["ALL"]
# Kubernetes NetworkPolicy for isolation
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: gitlab-runner-isolation
namespace: gitlab-runner
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
# Allow DNS
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
# Allow GitLab
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
# gVisor runtime class for stronger isolation
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
# Job using gVisor
sandboxed_job:
script:
- echo "Running in gVisor sandbox"
variables:
KUBERNETES_RUNTIME_CLASS: "gvisor"
Isolation Best Practices:
- Use Docker or Kubernetes executor for isolation
- Never use the shell executor for untrusted code
- Disable privileged mode unless necessary (DinD)
- Use pod security contexts (non-root, drop capabilities)
- Apply network policies to isolate job pods
- Consider gVisor for untrusted code
Secrets Management
Secrets are the most attractive target for an attacker who compromises a job. If a job can read your cloud credentials, it can use them to exfiltrate data, spin up expensive resources, or pivot to other systems. Managing secrets safely is therefore a critical part of runner security.
The first principle is to limit which jobs can access which secrets. GitLab provides this through protected variables, which are only available on protected branches. By marking production credentials as protected, you ensure that only code on main can access them — feature branches and untrusted contributions never see the production secrets.
The second principle is to limit the damage a leaked secret can cause. Use secrets with the minimum necessary permissions. Use short-lived credentials where possible (e.g., temporary AWS STS tokens instead of long-lived access keys). Rotate secrets regularly. Use masking to prevent accidental disclosure in logs.
The third principle is to use file variables for secrets that are naturally files (SSH keys, TLS certificates, service account JSON). File variables don't leak into logs the way environment variables can, and they work naturally with tools that expect file paths.
# Protected variables for production secrets
# Project → Settings → CI/CD → Variables
#
# Key: PROD_DB_PASSWORD
# Value: production-secret-password
# Type: Variable
# Protected: ✓
# Masked: ✓
#
# Behavior:
# - Only available on protected branches (main, release/*)
# - Value masked in job logs
#
# Key: KUBECONFIG
# Value:
# Type: File
# Protected: ✓
#
# Behavior:
# - Written to a temp file on the runner
# - $KUBECONFIG contains the file path
# - File is deleted after the job
# Using protected variables in .gitlab-ci.yml
deploy_production:
stage: deploy
script:
- echo "Deploying with password: $PROD_DB_PASSWORD" # [MASKED]
- kubectl --kubeconfig=$KUBECONFIG apply -f k8s/
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- when: never
# Avoid printing secrets
# BAD:
script:
- echo "Password: $DB_PASSWORD" # Leaks in logs
- env # Prints all env vars
# GOOD:
script:
- mysql -u $DB_USER -p$DB_PASSWORD # Password not printed
- echo "Connecting to DB" # No secret in output
# Use secrets with minimal permissions
# BAD: Long-lived, admin AWS credentials
# GOOD: Short-lived STS token with limited scope
# Rotate secrets regularly
# - Rotate API tokens quarterly
# - Rotate database passwords quarterly
# - Rotate SSH keys annually
# Use ephemeral credentials where possible
# - AWS STS temporary credentials
# - GCP Workload Identity Federation
# - Azure Managed Identity
# Store secrets in a secret manager (Vault, AWS Secrets Manager)
# and fetch them at job runtime:
deploy_with_vault:
script:
- export DB_PASSWORD=$(vault kv get -field=password secret/db)
- deploy.sh
# Never commit secrets to the repository
# - Use .gitignore
# - Use pre-commit hooks with secret scanners
# - Enable GitLab's secret detection
# Check for leaked secrets:
# - GitLab Secret Detection
# - gitleaks
# - truffleHog
Anti-Pattern: Secrets in .gitlab-ci.yml
Never put secrets directly in .gitlab-ci.yml. The file is in the repository, so secrets there are visible to anyone with repository access (including anyone who can read the git history, forever). Use CI/CD variables or a secret manager instead.
Ephemeral Runners
An ephemeral runner is a runner that runs exactly one job and then shuts down. This is the strongest isolation model: since the runner doesn't persist between jobs, a compromised job can't affect subsequent jobs, and there's no state for an attacker to persist.
There are several ways to achieve ephemeral runners. GitLab's Kubernetes executor can be configured to run in "ephemeral mode" where each job gets a fresh runner pod. The GitLab Runner Operator can create ephemeral runner pods on demand. And the Docker Machine executor creates a fresh VM per job — one of the original ephemeral patterns.
Ephemeral runners are especially valuable for untrusted code and for CI/CD environments where job isolation is critical. The trade-off is startup cost — spinning up a fresh runner per job is slower than reusing a long-lived one. But for security-sensitive workloads, this cost is worth paying.
# Ephemeral runners with GitLab Runner Operator
apiVersion: apps.gitlab.com/v1beta1
kind: Runner
metadata:
name: ephemeral-runner
namespace: gitlab-runner
spec:
gitlabUrl: https://gitlab.com/
token:
config: |
[[runners]]
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-runner"
image = "alpine:latest"
# Ephemeral runner pods in Kubernetes
# Each job creates a fresh runner pod that is deleted after the job
# Configure with runners.kubernetes.namespace and
# runners.kubernetes.pod_annotations
# Docker Machine executor (fresh VM per job)
[[runners]]
executor = "docker+machine"
limit = 10
[runners.machine]
IdleCount = 0 # No idle machines
MaxBuilds = 1 # One build per machine
MachineDriver = "amazonec2"
MachineOptions = [
"amazonec2-instance-type=t3.medium",
"amazonec2-region=us-east-1"
]
# Autoscaling runner with ephemeral instances
# GitLab Runner Autoscaler (new feature) creates
# ephemeral VMs for each job
# Benefits of ephemeral runners:
# - No state persistence between jobs
# - A compromised job can't affect subsequent jobs
# - No long-lived runner to attack
# - Clean environment every time
#
# Trade-offs:
# - Slower job startup (runner creation)
# - Higher resource usage (no reuse)
# - More complex setup
#
# When to use ephemeral runners:
# - Untrusted code (public repos, external contributors)
# - High-security environments
# - Compliance requirements
# - Jobs with unpredictable resource needs
# GitLab Runner Autoscaler (modern approach)
# https://docs.gitlab.com/runner/runner_autoscaler/
# Creates ephemeral VMs for each job
# Supports AWS, GCP, Azure
Ephemeral Runners vs Persistent Runners:
- Persistent: Fast startup, lower cost, weaker isolation
- Ephemeral: Slower startup, higher cost, stronger isolation
- Use persistent for trusted code and fast feedback loops
- Use ephemeral for untrusted code and high security
- You can mix both — different runners for different trust levels
Network Security
Runners often need network access to GitLab, to container registries, and to the infrastructure they deploy to. But they don't need access to everything. Network segmentation limits what a compromised runner can reach, and network policies enforce that segmentation.
The key controls are: use NetworkPolicy to restrict egress from runner pods; don't run runners in the same network as production; use VPC peering or private links instead of exposing services publicly; firewall rules to restrict what runners can reach; and egress proxies to mediate and log outbound traffic.
For cloud environments, the runner's network access is often controlled by security groups or firewall rules at the network layer. This is even stronger than Kubernetes NetworkPolicy because it can't be bypassed by pod configuration changes.
# Kubernetes NetworkPolicy to restrict runner egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: gitlab-runner-egress
namespace: gitlab-runner
spec:
podSelector:
matchLabels:
app: gitlab-runner
policyTypes:
- Egress
egress:
# Allow DNS resolution
- to:
- namespaceSelector:
matchLabels:
name: kube-system
ports:
- port: 53
protocol: UDP
# Allow HTTPS to GitLab
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
# Allow HTTPS to container registry
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
# Allow specific internal services
- to:
- namespaceSelector:
matchLabels:
name: gitlab-runner
ports:
- port: 8080
protocol: TCP
# AWS Security Group rules for runners
# Allow only necessary egress:
# - HTTPS to GitLab (443)
# - HTTPS to container registry (443)
# - HTTPS to cloud APIs (443)
# - DNS (53)
# Deny everything else
# Network segmentation best practices:
# 1. Put runners in a dedicated VPC/subnet
# 2. Use network policies or security groups for egress control
# 3. Never run runners in the same network as production databases
# 4. Use VPC peering or private links for cross-VPC access
# 5. Consider egress proxies for visibility and control
# 6. Monitor outbound connections for anomalies
# 7. Firewall the runner from external access
# Egress proxy configuration (for logging/control)
# Set HTTP_PROXY and HTTPS_PROXY in the runner's environment
# This makes all outbound traffic go through a proxy
# The proxy can log, filter, and analyze traffic
Network Security Anti-Patterns:
- Running runners in the same network as production databases
- Allowing unrestricted egress (0.0.0.0/0 on all ports)
- Exposing runner pods to the public internet
- Not using network policies (pods can talk to everything)
- Sharing a network namespace with production workloads
Runner Token Security
Runner tokens are the credentials that allow a runner to authenticate to GitLab and pick up jobs. They're sensitive: anyone with a runner token can register a runner, and anyone with a runner's authentication token can impersonate that runner. Treating tokens as secrets is essential.
There are two types of tokens: registration tokens (used once to register a runner) and authentication tokens (used by the runner to authenticate with GitLab). The newer GitLab versions favor "runner authentication tokens" (created via UI/API) which don't require a registration token to be present at all. This is more secure because there's no long-lived registration token to leak.
Best practices: rotate tokens periodically; store tokens as Kubernetes Secrets (not ConfigMaps); never commit tokens to a repository; use protected runners for production; and audit registered runners regularly to detect unauthorized registrations.
# Store runner token as a Kubernetes Secret
apiVersion: v1
kind: Secret
metadata:
name: gitlab-runner-token
namespace: gitlab-runner
type: Opaque
stringData:
runner-token: "glrt-xxxxxxxxxxxxxxxxxxxx"
# Reference in the runner deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitlab-runner
namespace: gitlab-runner
spec:
template:
spec:
containers:
- name: gitlab-runner
env:
- name: RUNNER_TOKEN
valueFrom:
secretKeyRef:
name: gitlab-runner-token
key: runner-token
# Helm values (token via Kubernetes Secret)
# values.yaml
runnerToken: glrt-xxxxxxxxxxxxxxxxxxxx
# Or reference an existing secret
runners:
secret: gitlab-runner-secret
# Rotate tokens:
# 1. Create a new runner with a new token
# 2. Update the runner deployment to use the new token
# 3. Delete the old runner in GitLab UI
# 4. Delete the old token
# Audit registered runners:
# Project → Settings → CI/CD → Runners
# - Check for unexpected runners
# - Check for runners with unexpected tags
# - Check the last contact time
# Tokens are sensitive:
# - Registration tokens: use once, then rotate
# - Authentication tokens: treat as passwords
# - Store in secret managers, not in code
# - Never log tokens
# - Rotate if exposed
Runner Security Checklist
| Area |
Control |
Priority |
| Isolation |
Use Docker or Kubernetes executor |
Critical |
| Isolation |
Disable privileged mode unless required |
Critical |
| Isolation |
Use pod security contexts (non-root) |
High |
| Access |
Use protected runners (ref_protected) |
Critical |
| Access |
Tag runners by trust level |
High |
| Access |
Disable run_untagged on trusted runners |
High |
| Secrets |
Never commit secrets to the repository |
Critical |
| Secrets |
Use protected variables for production |
Critical |
| Secrets |
Mask all sensitive variables |
High |
| Secrets |
Use file variables for keys/certs |
Medium |
| Network |
Apply network policies to job pods |
High |
| Network |
Isolate runners from production networks |
High |
| Tokens |
Store tokens in secret managers |
Critical |
| Tokens |
Rotate tokens regularly |
High |
| Ephemeral |
Use ephemeral runners for untrusted code |
High |
| Audit |
Monitor runner activity |
Medium |
| Updates |
Keep runner updated |
High |
| Updates |
Scan job images for vulnerabilities |
Medium |
Security Is a Process:
- Start with the critical controls (isolation, protected runners, secrets)
- Add network policies and token management next
- Implement auditing and monitoring last
- Review security periodically, not just once
- Stay informed about new vulnerabilities
Frequently Asked Questions
What is a protected runner?
A protected runner only accepts jobs from protected branches and tags. It's a key security control that ensures only reviewed code can run on trusted infrastructure. Configure with --access-level ref_protected.
Which executor is the most secure?
The Kubernetes executor with gVisor or Kata provides the strongest isolation. The Docker executor is also strong for most use cases. The shell executor provides no isolation and should only be used for trusted code.
How do I prevent secrets from leaking in logs?
Use masked variables — GitLab replaces the value with [MASKED] in logs. Also avoid echoing secrets, avoid printing env vars, and use file variables for keys and certificates.
What's an ephemeral runner?
An ephemeral runner runs exactly one job and then shuts down. This provides the strongest isolation because there's no state between jobs. Used for untrusted code and high-security environments.
How do I restrict a runner's network access?
Use Kubernetes NetworkPolicy, cloud security groups, or egress proxies to restrict what runner pods can reach. Allow only necessary destinations (GitLab, registries, target infrastructure).
Should I use the shell executor?
Generally no. The shell executor provides no isolation — jobs share the host. Use it only for trusted code in dedicated environments. For anything else, use Docker or Kubernetes.
How do I rotate runner tokens?
Create a new runner with a new token, update your runner deployment to use the new token, then delete the old runner and token. Plan for downtime or run both runners temporarily.
What's the biggest runner security mistake?
Running untrusted code on a trusted runner. This can happen via the shell executor, unprotected runners, or mixing trust levels. Always separate runners by trust level and use protected runners for production.
Runner security is a layered discipline. Isolate jobs, restrict access, protect secrets, and monitor activity — and your CI/CD infrastructure will be safe even when job code is malicious.