Runner Executors
Shell Executor
Docker Executor
Docker Machine
Kubernetes Executor
A complete guide to GitLab Runner executors covering shell, docker, docker-machine, kubernetes, and custom executors with detailed explanations and comparisons.
Shell
Docker
Kubernetes
Docker Machine
What are Runner Executors?
An executor is the mechanism that determines how a GitLab Runner actually runs the jobs assigned to it. When you register a runner, you choose an executor — and that choice shapes everything about how jobs are executed: whether they run directly on the host or in an isolated container, whether they can use Docker-in-Docker, whether they can scale automatically, and what resources they consume.
Understanding executors is crucial because the executor is the biggest determinant of your runner's security, performance, and operational characteristics. The shell executor runs jobs directly on the host — simple but with no isolation. The Docker executor runs each job in a fresh container — isolated but requires Docker. The Kubernetes executor runs jobs as pods — auto-scaling and cloud-native but requires a cluster.
There's no single "best" executor. The right choice depends on your infrastructure, security requirements, and workload. This guide compares all the major executors to help you choose.
Key Concept: The executor is specified during runner registration and stored in config.toml. Each runner uses exactly one executor, but you can register multiple runners on the same host with different executors.
Executor Comparison
| Executor |
Isolation |
Setup |
Scaling |
Best For |
| Shell |
None |
Easiest |
Manual |
Simple builds, trusted environments |
| Docker |
High |
Easy |
Manual |
Most CI/CD use cases |
| Docker Machine |
High |
Medium |
Auto |
Cloud-based auto-scaling |
| Kubernetes |
High |
Medium |
Auto |
Kubernetes-native environments |
| VirtualBox |
Very High |
Hard |
Manual |
Testing across OSes |
| Parallels |
Very High |
Hard |
Manual |
macOS/iOS builds |
| SSH |
None |
Easy |
Manual |
External build servers |
| Custom |
Varies |
Hard |
Varies |
Specialized requirements |
Shell Executor
The shell executor is the simplest of all executors. It runs job scripts directly on the host machine using the local shell, with no containerization or virtualization. When a job runs, GitLab Runner checks out the repository into a local directory, runs the before_script, script, and after_script commands using the host's shell, and reports the result. There's no image to pull, no container to start, no isolation to configure.
This simplicity is the shell executor's greatest strength and its greatest weakness. It's the fastest executor to start (no container startup overhead) and the easiest to set up (just install the runner and register it). But it provides zero isolation: jobs from different projects share the same host, can see each other's files, and can potentially interfere with each other or with the host's operating system. A malicious build script in a project could, in theory, compromise the runner host.
The shell executor is a reasonable choice for simple, trusted environments — like a personal build server or an internal tool where all code is trusted. It's also useful for debugging (you can run the same commands manually) and for jobs that need direct host access (like deploying to the host or managing local resources). But for anything involving untrusted code, containers are strongly preferred.
Shell Executor
Direct host execution
Fastest startup (no container)
No Docker dependency
Direct host access
No isolation between jobs
Untrusted code can affect host
Dependencies persist between jobs
Trusted builds, host-level tasks
# Register a shell executor
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "shell" \
--description "Shell Runner" \
--tag-list "shell,linux"
# config.toml for shell executor
[[runners]]
name = "Shell Runner"
url = "https://gitlab.com/"
token = "RUNNER_TOKEN"
executor = "shell"
# .gitlab-ci.yml with shell executor
build:
stage: build
script:
# Commands run directly on the host
- echo "Running on $(hostname)"
- whoami
- pwd
- npm install
- npm run build
# The runner clones into a temp directory
# under the build directory and runs the script
# Shell executor build directory:
# /home/gitlab-runner/builds/
//
# Caching works but lives on the host filesystem
# Limitations:
# - No isolation between concurrent jobs
# - Dependencies installed in one job persist for others
# - Two jobs could conflict on ports, files, or system state
# - A malicious job could modify the host
# Best practices for shell executor:
# - Only use for trusted code
# - Run in a dedicated VM or container
# - Use a non-root user
# - Avoid running multiple projects on the same host
# - Consider Docker/Kubernetes executor instead
Shell Executor Security:
- Never use the shell executor for untrusted code
- Jobs share the host's filesystem and processes
- Dependencies from one job can break another
- Use a dedicated VM, never a shared production host
- Prefer Docker or Kubernetes executor whenever possible
Docker Executor
The Docker executor is the most widely used executor and for good reason. It runs each job in a fresh Docker container based on the image specified by the job. Every job starts from a clean slate — no leftover dependencies, no conflicting ports, no shared state with other jobs. When the job finishes, the container is discarded. This isolation is exactly what modern CI/CD needs.
The Docker executor is also highly flexible. The job specifies its image, so you can choose the right environment for each job: node:18 for frontend tests, python:3.11 for backend tests, golang:1.21 for a Go microservice. Services (like databases or caches) can be started alongside the job using the services keyword, giving each job a self-contained environment.
The main caveat with the Docker executor is that it requires Docker on the host, and some tasks (like building Docker images inside a job) require either mounting the host's Docker socket or running in privileged mode with Docker-in-Docker (DinD). Both approaches have trade-offs, which we'll cover below.
Docker Executor
Container-based job execution
Strong isolation per job
Fresh environment every time
Reproducible builds
Easy to use with CI
Requires Docker on host
Docker-in-Docker needs privileged
Most CI/CD workloads
# Register a Docker executor
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "Docker Runner" \
--tag-list "docker,linux"
# config.toml for Docker executor
[[runners]]
name = "Docker Runner"
url = "https://gitlab.com/"
token = "RUNNER_TOKEN"
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
network_mode = "bridge"
pull_policy = "if-not-present"
# .gitlab-ci.yml with Docker executor
build:
stage: build
image: node:18 # Runs in this container
script:
- npm ci
- npm run build
test:
stage: test
image: python:3.11 # Different image for this job
script:
- pip install -r requirements.txt
- pytest
# Using services (databases, caches)
integration_test:
image: node:18
services:
- name: postgres:15
alias: db
- name: redis:7
alias: cache
variables:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
script:
- npm run test:integration
# Docker-in-Docker (DinD) for building images
build_image:
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t myapp:$CI_COMMIT_SHORT_SHA .
# Docker socket binding (alternative to DinD)
build_image_socket:
image: docker:24
script:
- docker build -t myapp:$CI_COMMIT_SHORT_SHA .
# Requires /var/run/docker.sock mounted in config.toml
# DinD vs Socket Binding:
#
# DinD (privileged):
# - Isolated Docker daemon per job
# - Requires privileged = true
# - Slower (starts a new daemon)
# - Better isolation
#
# Socket binding:
# - Shares host's Docker daemon
# - Faster
# - Less isolation (can see all host containers)
# - Simpler configuration
# Docker executor pull policies:
# - always: Pull image every time
# - if-not-present: Pull only if not cached
# - never: Never pull (fail if missing)
# Docker executor cache:
[runners.cache]
type = "s3"
[runners.cache.s3]
bucket_name = "my-cache-bucket"
Docker Executor Best Practices:
- Use specific image tags (not
latest) for reproducibility
- Keep images small (alpine variants) for faster startup
- Use
services for databases and caches instead of installing them
- Prefer DinD over socket binding for better isolation
- Configure cache storage (S3, GCS) for faster builds
- Don't use privileged mode unless DinD requires it
Docker Machine Executor
The Docker Machine executor is an auto-scaling variant of the Docker executor. Instead of using a single Docker host, it uses Docker Machine to provision ephemeral VMs on demand — each job gets its own fresh VM with Docker, which is destroyed after the job completes. This provides maximum isolation and automatic scaling, at the cost of higher complexity and slower job startup (provisioning a VM takes longer than starting a container).
This executor was very popular in the era before Kubernetes was ubiquitous, and it's still useful for teams that don't have a Kubernetes cluster but want auto-scaling runners. It supports many cloud providers (AWS, GCP, Azure, DigitalOcean, OpenStack, etc.) via Docker Machine drivers. When jobs arrive, Docker Machine spins up VMs; when jobs finish, the VMs are torn down. This means you pay only for the compute you actually use.
The main trade-off is startup time. Provisioning a VM can take 30 seconds to several minutes, so Docker Machine is best for jobs that take a while (10+ minutes). For fast jobs, the VM startup overhead dominates, and the standard Docker executor is a better choice.
Docker Machine
Auto-scaling Docker hosts
Auto-scaling with jobs
Fresh VM per job
Pay only for what you use
Slow VM provisioning
Complex setup
Cloud provider credentials needed
Cloud-based auto-scaling
# Register a Docker Machine executor
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "docker+machine" \
--docker-image "alpine:latest" \
--description "Docker Machine Runner" \
--tag-list "aws,autoscale" \
--machine-driver "amazonec2" \
--machine-name "%s" \
--machine-options "--amazonec2-instance-type=t3.medium"
# config.toml for Docker Machine
concurrent = 10
[[runners]]
name = "Docker Machine Runner"
executor = "docker+machine"
limit = 10
[runners.docker]
image = "alpine:latest"
privileged = false
[runners.machine]
IdleCount = 1
IdleScaleFactor = 0.0
IdleCountMin = 0
MaxBuilds = 100
MachineDriver = "amazonec2"
MachineName = "runner-%s"
MachineOptions = [
"amazonec2-instance-type=t3.medium",
"amazonec2-region=us-east-1",
"amazonec2-zone=a",
"amazonec2-ami=ami-0123456789abcdef0",
"amazonec2-security-group=runner-sg"
]
[runners.machine.autoscaling]
Periods = ["* * 9-17 * * mon-fri *"] # Work hours
IdleCount = 2
IdleTime = 3600
# Configuration keys:
# IdleCount: Number of idle VMs to keep warm
# MaxBuilds: Max builds per VM before destroying
# MachineDriver: Cloud provider (amazonec2, google, azure, etc.)
# MachineOptions: Driver-specific options
# Docker Machine drivers:
# - amazonec2: AWS EC2
# - google: Google Compute Engine
# - azure: Microsoft Azure
# - digitalocean: DigitalOcean
# - openstack: OpenStack
# - virtualbox: VirtualBox
# - vmwarevsphere: VMware vSphere
# When to use Docker Machine:
# - You want auto-scaling without Kubernetes # - Jobs take longer than 5 minutes
# - You're on a cloud provider
# - You need fresh VMs per job for maximum isolation
#
# When NOT to use Docker Machine:
# - You already have Kubernetes (use Kubernetes executor)
# - Jobs are very short (VM startup dominates)
# - You don't have cloud credentials
Docker Machine Status: Docker Machine is deprecated upstream, and the Docker Machine executor is being superseded by the Kubernetes executor for most use cases. However, it remains a valid option for teams that don't have Kubernetes. New deployments should consider the Kubernetes executor instead.
Kubernetes Executor
The Kubernetes executor is the modern, cloud-native choice. It runs each job as a pod in a Kubernetes cluster, which gives you the same isolation as the Docker executor, plus Kubernetes' native auto-scaling and resource management. When a job arrives, the runner creates a pod with the job's image and any services it needs; when the job finishes, the pod is deleted. All of this happens automatically, using the cluster's existing scheduling and autoscaling capabilities.
What makes the Kubernetes executor especially appealing is that it inherits Kubernetes' operational benefits: auto-scaling (via Cluster Autoscaler or Karpenter), resource quotas, node affinity, taints and tolerations, and integrated observability. You define job requirements in terms of Kubernetes resources (CPU, memory, node selectors), and Kubernetes handles the rest. This makes it ideal for teams already operating Kubernetes, who can reuse existing cluster capacity and tooling for CI/CD.
The main requirement is, of course, a Kubernetes cluster — and the runner needs permissions to create pods in a namespace. The runner configuration is more involved than the Docker executor (you specify namespace, service account, resource limits, etc.), but for teams comfortable with Kubernetes, this is familiar territory.
Kubernetes Executor
Pods as job environments
Auto-scaling via K8s
Resource limits per job
Cloud-native
Reuses cluster capacity
Requires K8s cluster
More complex configuration
Kubernetes environments
# Register a Kubernetes executor
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "kubernetes" \
--kubernetes-namespace "gitlab-runner" \
--description "K8s Runner" \
--tag-list "kubernetes,autoscale"
# config.toml for Kubernetes executor
[[runners]]
name = "K8s Runner"
url = "https://gitlab.com/"
token = "RUNNER_TOKEN"
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-runner"
image = "alpine:latest"
privileged = false
allow_privilege_escalation = false
cpu_limit = "1"
memory_limit = "2Gi"
service_cpu_limit = "1"
service_memory_limit = "1Gi"
helper_cpu_limit = "500m"
helper_memory_limit = "512Mi"
poll_timeout = 180
[runners.kubernetes.node_selector]
"kubernetes.io/os" = "linux"
[runners.kubernetes.pod_labels]
"app" = "gitlab-runner"
[runners.kubernetes.pod_security_context]
run_as_non_root = true
run_as_user = 1000
fs_group = 1000
# .gitlab-ci.yml with Kubernetes executor
build:
stage: build
image: node:18
script:
- npm ci
- npm run build
# Override Kubernetes settings per job
build_large:
stage: build
image: node:18
tags:
- kubernetes
variables:
KUBERNETES_CPU_REQUEST: "2"
KUBERNETES_MEMORY_REQUEST: "4Gi"
KUBERNETES_CPU_LIMIT: "4"
KUBERNETES_MEMORY_LIMIT: "8Gi"
KUBERNETES_NODE_SELECTOR_OPERATOR: "In"
KUBERNETES_NODE_SELECTOR: "disktype=ssd"
script:
- npm run build:large
# Using services (K8s services)
integration_test:
image: node:18
services:
- name: postgres:15
alias: db
variables:
POSTGRES_DB: test
script:
- npm run test:integration
# Kubernetes-specific variables:
# KUBERNETES_CPU_REQUEST
# KUBERNETES_CPU_LIMIT
# KUBERNETES_MEMORY_REQUEST
# KUBERNETES_MEMORY_LIMIT
# KUBERNETES_CPU_REQUEST
# KUBERNETES_HELPER_CPU_REQUEST
# KUBERNETES_SERVICE_CPU_REQUEST
# KUBERNETES_NODE_SELECTOR
# KUBERNETES_NODE_TOLERATIONS
# KUBERNETES_POD_ANNOTATIONS
# Helm chart for GitLab Runner
helm repo add gitlab https://charts.gitlab.io
helm repo update
helm install gitlab-runner gitlab/gitlab-runner \
--namespace gitlab-runner \
--create-namespace \
--set gitlabUrl=https://gitlab.com/ \
--set runnerRegistrationToken="YOUR_TOKEN" \
--set runners.privileged=false \
--set runners.config="[runners.kubernetes]
namespace = \"gitlab-runner\"
cpu_limit = \"1\"
memory_limit = \"2Gi\""
# Kubernetes executor advantages:
# - Auto-scaling handled by Kubernetes
# - Resource limits and requests
# - Node affinity and taints
# - Integrates with cluster monitoring
# - Reuses existing cluster capacity
Kubernetes Executor Best Practices:
- Run the runner in its own namespace
- Use a dedicated service account with minimal permissions
- Set CPU and memory limits for jobs and services
- Use
allow_privilege_escalation = false unless required
- Enable Cluster Autoscaler for automatic scaling
- Use node selectors to place jobs on appropriate nodes
- Configure pod security contexts for defense in depth
Other Executors
VirtualBox Executor
Run jobs in VirtualBox VMs
Each job runs in a fresh VirtualBox VM. Full OS isolation, but slow startup and high resource usage.
Full OS isolation
Test across different OSes
Slow VM startup
High resource usage
Cross-platform testing
Parallels Executor
Run jobs in Parallels VMs
Similar to VirtualBox but for macOS. Used for building iOS and macOS applications.
macOS builds
iOS app testing
Requires macOS host
Commercial license
Apple platform builds
SSH Executor
Run jobs on a remote host
Connects to a remote host over SSH and runs the job there. Useful for specialized build servers.
Use specialized hardware
Remote execution
No isolation
Requires SSH keys
Specialized build servers
Custom Executor
Define your own executor
Write your own executor in Go if you need behavior not supported by built-in executors. Requires compiling a custom runner.
Full control
Specialized behavior
Complex to build
Maintenance burden
Specialized requirements
# SSH Executor configuration
[[runners]]
name = "SSH Runner"
executor = "ssh"
[runners.ssh]
host = "build-server.example.com"
port = "22"
user = "builder"
password = "password"
identity_file = "/home/gitlab-runner/.ssh/id_rsa"
# VirtualBox Executor configuration
[[runners]]
name = "VirtualBox Runner"
executor = "virtualbox"
[runners.virtualbox]
base_name = "gitlab-runner"
base_snapshot = "clean"
disable_snapshots = false
# Parallels Executor configuration
[[runners]]
name = "Parallels Runner"
executor = "parallels"
[runners.parallels]
base_name = "gitlab-runner"
template_name = "macOS"
disable_snapshots = false
Choosing the Right Executor
Start with Docker
If you're unsure, start with the Docker executor. It's the best balance of isolation, ease of setup, and flexibility for most workloads.
Kubernetes for K8s Shops
If you already have a Kubernetes cluster, use the Kubernetes executor. You can reuse cluster capacity and benefit from auto-scaling.
Shell Only for Trusted Code
Use the shell executor only for trusted code where isolation isn't a concern. Avoid it for public repositories or untrusted contributors.
Docker Machine for Cloud Autoscaling
Use Docker Machine if you want auto-scaling without Kubernetes. Best for long-running jobs where VM startup time is acceptable.
# Decision guide
# Are you on Kubernetes?
# ├─ Yes → Use Kubernetes executor
# └─ No → Continue
# Do you have Docker?
# ├─ Yes → Use Docker executor (recommended)
# └─ No → Continue
# Do you need auto-scaling?
# ├─ Yes → Use Docker Machine executor
# └─ No → Continue
# Is all code trusted?
# ├─ Yes → Use Shell executor
# └─ No → Set up Docker (strongly recommended)
# Do you need specific OS or hardware?
# ├─ Yes → Use VirtualBox, Parallels, or SSH executor
# └─ No → Use Docker or Kubernetes executor
# Common executor combinations:
# - Docker executor + project runner for general CI
# - Kubernetes executor + autoscaler for scalable CI
# - Shell executor on dedicated host for deployment
# - Docker Machine + cloud credentials for on-demand build farms
# Migration path:
# Shell → Docker → Kubernetes
# (as your needs grow from simple to sophisticated)
Frequently Asked Questions
What's the difference between an executor and a runner?
A runner is the agent that picks up jobs from GitLab. The executor is the mechanism the runner uses to run those jobs (shell, docker, kubernetes). A runner is configured with exactly one executor.
Can I change the executor after registering a runner?
Not directly. You need to unregister the runner and register it again with the new executor. The old runner's configuration is lost.
Which executor is the fastest?
The shell executor has the fastest startup (no container overhead). Docker is slightly slower (container start), Kubernetes has pod scheduling overhead, and Docker Machine is the slowest (VM provisioning).
What's the difference between docker and docker+machine?
The docker executor runs jobs on the runner host's Docker daemon. The docker+machine executor provisions ephemeral VMs and runs Docker on them, giving auto-scaling.
Is Docker-in-Docker required for building images?
No, you can also mount the host's Docker socket. DinD is more isolated but requires privileged mode; socket binding is simpler but less isolated.
Why is my Kubernetes job not scheduling?
Common causes: insufficient cluster resources, node selectors that match no nodes, taints without tolerations, or a namespace quota. Check pod events with kubectl describe pod.
Can I run multiple executors on the same host?
Yes, you can register multiple runners on the same host with different executors. Each runner has its own configuration in config.toml.
What's the safest executor?
Kubernetes and Docker executors provide strong isolation. The shell executor provides none. For untrusted code, always use Docker or Kubernetes.
The executor you choose shapes your CI/CD security, performance, and operations. Choose wisely, and revisit your choice as your needs evolve.