Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Setup Cost-Optimized GitLab Runner on Hetzner Cloud

profile picture
Author
Maximilian Kutschka
Published
2026-02-24
Time to read
9 minutes reading time

About the author- DevOps enthusiast building tools to make infrastructure more efficient.

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 concurrent in your runner configuration (e.g., concurrent = 4 for 4 parallel jobs).
  • No job tagging: Since there's only one runner, configure it to also run untagged jobs (set run_untagged = true in 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.

  1. Log in to your GitLab instance

  2. Go to User SettingsAccess Tokens

  3. Create a new token with the following settings:

    • Name: runner-orchestrator
    • Expiration: Set according to your security policy
    • Scopes: Select read_api
  4. Click Create personal access token and save the token securely

The read_api scope 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.

  1. Log in to the Hetzner Console
  2. Select your project (or create a new one)
  3. Go to SecurityAPI Tokens
  4. Click Generate API Token
    • Description: gitlab-runner-orchestrator
    • Permissions: Select Read & Write
  5. 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:

  1. Go to your GitLab instance → Admin AreaCI/CDRunners
  2. Click Create instance runner
  3. Configure the runner:
    • Tags: Leave empty or set as needed
    • Run untagged jobs: Enable this checkbox (important!)
  4. Click Create runner
  5. 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_TOKEN

Follow 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_orc

Step 5 - Create Orchestrator Configuration

Create the main configuration file:

sudo nano /srv/hcloud_orc/config.toml

Add 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 = 30

Configuration 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.toml

Your directory should now contain:

/srv/hcloud_orc/
├── config.toml      # Orchestrator configuration
└── runner.toml      # GitLab Runner configuration

Step 7 - Create Docker Compose File

Create a docker-compose.yml file for the orchestrator:

sudo nano /srv/hcloud_orc/docker-compose.yml

Add 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/config

Step 8 - Start the Orchestrator

Start the orchestrator:

cd /srv/hcloud_orc
sudo docker compose up -d

Check the logs to verify it's running:

sudo docker compose logs -f

You 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:

  1. Detect the pending pipeline
  2. Create a new Hetzner server
  3. The server bootstraps with Docker and the GitLab Runner
  4. Your pipeline runs
  5. After completion (and minimum runtime), the server is deleted

You can monitor this in the logs:

sudo docker compose logs -f hcloud

Step 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.csv

Example 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.

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get €20/$20 free credit!

Valid until: 31 December 2026 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more