Introduction
Running GitLab CI/CD pipelines requires compute resources. Many teams run dedicated GitLab Runners 24/7, paying for idle time when no pipelines are active. This tutorial shows you how to set up an orchestrator that automatically creates Hetzner cloud servers when pipelines need to run and deletes them when they're done — paying only for actual usage.
The GitLab Runner Orchestrator:
- Automatically creates servers when pipelines are pending or running
- Deletes servers cost-optimized — 5 minutes before the next billing cycle to maximize utilization
- Monitors all your projects — one orchestrator for your entire GitLab instance
Who is this for?
This solution is ideal for small companies and individuals with intermittent CI/CD workloads. If your pipelines only run a few hours per day, you'll see significant cost savings compared to a 24/7 runner.
However, this approach has limitations:
- Single runner: The orchestrator manages one powerful server for all your projects. If you need multiple separate runners working in parallel, this solution may not fit your use case. However, a single runner can execute multiple jobs concurrently by setting
concurrentin your runner configuration (e.g.,concurrent = 4for 4 parallel jobs). - No job tagging: Since there's only one runner, configure it to also run untagged jobs (set
run_untagged = truein your runner configuration). - Cost benefit requires idle time: If your CI/CD pipelines run constantly throughout the day, a dedicated runner becomes more cost-effective. The savings come from not paying for idle hours.
Example cost savings:
| Scenario | Traditional Runner (24/7) | Orchestrator |
|---|---|---|
| 2 hours CI/day | ~720 hours/month | ~60 hours/month |
| Monthly cost (CX23) | ~5.35€ | ~0.52€ |
Prerequisites
- A Hetzner account with an API token
- A GitLab instance (self-hosted or gitlab.com)
- A server to run the orchestrator (can be a small CX23 or even a Raspberry Pi)
- Docker and Docker Compose installed on the orchestrator server
Step 1 - Create GitLab Personal Access Token
First, create a GitLab Personal Access Token that the orchestrator will use to monitor your pipelines.
-
Log in to your GitLab instance
-
Go to User Settings → Access Tokens
-
Create a new token with the following settings:
- Name:
runner-orchestrator - Expiration: Set according to your security policy
- Scopes: Select
read_api
- Name:
-
Click Create personal access token and save the token securely
The
read_apiscope is sufficient — the orchestrator only reads project and pipeline information.
Note: The runner will appear as "unresponsive" in GitLab when no server is running. This is intended behavior — the runner only exists when pipelines are active.
Step 2 - Create Hetzner Cloud API Token
Create a Hetzner Console API token to allow the orchestrator to create and delete servers.
- Log in to the Hetzner Console
- Select your project (or create a new one)
- Go to Security → API Tokens
- Click Generate API Token
- Description:
gitlab-runner-orchestrator - Permissions: Select Read & Write
- Description:
- Click Generate API Token and save the token securely
Step 3 - Register GitLab Runner
Before starting the orchestrator, you need to create and register a GitLab Runner.
Create the runner in GitLab:
- Go to your GitLab instance → Admin Area → CI/CD → Runners
- Click Create instance runner
- Configure the runner:
- Tags: Leave empty or set as needed
- Run untagged jobs: Enable this checkbox (important!)
- Click Create runner
- GitLab will show you a registration token and instructions — keep this page open
Register the runner:
Follow the instructions shown in GitLab. If you don't have gitlab-runner installed locally, you can use Docker to register:
# Create a temporary directory for registration
mkdir -p /tmp/gitlab-runner-register
# Register the runner using Docker (no local installation required)
docker run --rm -it \
-v /tmp/gitlab-runner-register:/etc/gitlab-runner \
gitlab/gitlab-runner:latest register \
--url https://gitlab.example.com \
--token YOUR_GITLAB_RUNNER_TOKENFollow the prompts:
- Executor: Choose
docker - Default Docker image: Choose your preferred image (e.g.,
alpine:latest)
After registration, you'll find a config.toml file in /tmp/gitlab-runner-register/.
Important: Edit the config.toml and add the pull_policy setting under [runners.docker]:
[runners.docker]
# ... other settings ...
pull_policy = ["if-not-present"]Warning: Without this setting, the runner pulls Docker images on every CI/CD step, even if the latest version is already available locally. This causes unnecessary network traffic and can result in your IP being rate-limited by Docker Hub.
Step 4 - Prepare Configuration Directory
Create the directory structure on your orchestrator server:
# Create the configuration directory
sudo mkdir -p /srv/hcloud_orc
# Set ownership to the container user (UID 42069)
sudo chown -R 42069:42069 /srv/hcloud_orcStep 5 - Create Orchestrator Configuration
Create the main configuration file:
sudo nano /srv/hcloud_orc/config.tomlAdd the following content:
Replace
gitlab.example.com, the Personal Access Token, and the Hetzner API Token with your own information.
[gitlab]
# URL of your GitLab instance
url = "https://gitlab.example.com"
# Personal Access Token with read_api scope
token = "glpat-xxxxxxxxxxxxxxxxxxxx"
[hetzner]
# Hetzner API Token
token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# SSH key
ssh_key_name = "YOUR_SSH_KEY_NAME"
# Server type for the runner (see: https://www.hetzner.com/cloud)
server_type = "ccx23"
# Datacenter location (nbg1, fsn1, hel1, ash, hil)
location = "fsn1"
# OS Image
image = "ubuntu-24.04"
[runner]
# Name of the Hetzner cloud server
name = "gitlab-runner"
# Minimum runtime in minutes before the server can be deleted
min_lifetime_minutes = 20
# How often to check for pipelines (in seconds)
poll_interval_seconds = 30Configuration options explained:
| Option | Description |
|---|---|
server_type |
Hetzner server type. ccx23 (AMD dedicated) is great for CI. See Hetzner pricing |
location |
Datacenter location. Choose one close to your GitLab instance |
min_lifetime_minutes |
Minimum time a server runs. Prevents rapid create/delete cycles |
poll_interval_seconds |
How often the orchestrator checks for active pipelines |
Step 6 - Copy Runner Configuration
Copy the config.toml from Step 4 to the orchestrator:
# Copy the runner configuration (rename to runner.toml)
sudo cp /tmp/gitlab-runner-register/config.toml /srv/hcloud_orc/runner.toml
# Ensure correct ownership
sudo chown 42069:42069 /srv/hcloud_orc/runner.tomlYour directory should now contain:
/srv/hcloud_orc/
├── config.toml # Orchestrator configuration
└── runner.toml # GitLab Runner configurationStep 7 - Create Docker Compose File
Create a docker-compose.yml file for the orchestrator:
sudo nano /srv/hcloud_orc/docker-compose.ymlAdd the following content:
services:
hcloud:
image: ghcr.io/devmaxde/gitlab_hcloud:latest
container_name: gitlab-runner-orchestrator
restart: unless-stopped
volumes:
- /srv/hcloud_orc:/app/configStep 8 - Start the Orchestrator
Start the orchestrator:
cd /srv/hcloud_orc
sudo docker compose up -dCheck the logs to verify it's running:
sudo docker compose logs -fYou should see output similar to:
=== GitLab Runner Orchestrator ===
2026-01-16T14:12:25.304304Z INFO === GitLab Runner Orchestrator ===
2026-01-16T14:12:25.304316Z INFO Logs are written to: logs/orchestrator.log
2026-01-16T14:12:25.304318Z INFO Starting...
2026-01-16T14:12:25.304324Z INFO Loading configuration from: config/config.toml
2026-01-16T14:12:25.304363Z INFO Configuration loaded successfully
2026-01-16T14:12:25.304368Z INFO GitLab URL: https://git.example.com
2026-01-16T14:12:25.304370Z INFO Hetzner server type: ccx23
2026-01-16T14:12:25.304373Z INFO Runner name: flexi-runner
2026-01-16T14:12:25.304375Z INFO Loading runner configuration from: config/runner.toml
2026-01-16T14:12:25.304429Z INFO CSV logger initialized: logs/runner_usage.csv
2026-01-16T14:12:25.310477Z INFO GitLab client initialized for: https://git.flexi-servers.com
2026-01-16T14:12:25.315389Z INFO Hetzner client initialized
2026-01-16T14:12:25.315392Z INFO Server type: ccx23
2026-01-16T14:12:25.315394Z INFO Location: nbg1
2026-01-16T14:12:25.315395Z INFO Image: ubuntu-24.04
2026-01-16T14:12:25.315397Z INFO Generating cloud-init configuration
2026-01-16T14:12:25.315401Z INFO Cloud-init configuration generated (1722 bytes)
2026-01-16T14:12:25.315416Z INFO Verifying state with Hetzner API...
2026-01-16T14:12:25.364778Z INFO No server active - state is consistent
2026-01-16T14:12:25.364793Z INFO Starting polling loop (interval: 30s)
2026-01-16T14:12:25.364798Z INFO Minimum server runtime: 20 minutes
2026-01-16T14:12:25.418056Z INFO Total 3 projects loaded
2026-01-16T14:12:25.559466Z INFO No active pipelines found
2026-01-16T14:12:25.559476Z INFO No active pipelines, no server active - waiting...Step 9 - Verify the Setup
Trigger a pipeline in one of your GitLab projects. A simple pipeline example:
# .gitlab-ci.yml
stages:
- test
simple_job:
stage: test
script: echo "Hello"The orchestrator should:
- Detect the pending pipeline
- Create a new Hetzner server
- The server bootstraps with Docker and the GitLab Runner
- Your pipeline runs
- After completion (and minimum runtime), the server is deleted
You can monitor this in the logs:
sudo docker compose logs -f hcloudStep 10 - View Usage Logs (Optional)
The orchestrator creates CSV logs documenting all server usage:
# View the usage log
sudo cat /srv/hcloud_orc/logs/runner_usage.csvExample output:
timestamp,event,server_id,project,pipeline_id,reason,duration_minutes
2026-01-16T14:15:51.848054250+00:00,START,117645040,flexi-servers/Flexi-OS,1696,pipeline_pending,This is useful for:
- Tracking CI costs
- Auditing runner usage
Conclusion
You have successfully set up a GitLab Runner Orchestrator that automatically provisions Hetzner Cloud servers on demand. Your CI/CD pipelines now run on fresh, powerful servers while you only pay for actual usage.