Setting Up Self-Hosted GitLab Runners: Tags, Scaling, and the Config Mistakes That Waste Compute

By KP  |  TZoneLabs  |  DevOps & Cloud Engineering

Registering a GitLab Runner with gitlab-runner register and walking away is how you end up with three untagged runners fighting over the same jobs, a deploy job that occasionally runs on whatever box happens to be free, and a host that falls over the moment four docker jobs land at once. Tag every runner by purpose, cap concurrency to what the machine can actually hold, and keep deploy jobs pinned to a runner that isn’t also building untrusted feature branches.

We’ll walk through installing the runner, why tags matter more than most setups treat them, the concurrency settings almost nobody tunes, and the config mistakes that quietly waste compute or open a security gap.

Why Bother With Self-Hosted Runners

GitLab.com’s shared runners work fine for small projects, but they come with limits: shared compute minutes run out, you can’t control instance size, and every job starts cold since the underlying machine isn’t yours between runs. A self-hosted runner is a box you control. Docker layers can stay warm on disk between jobs, you size the instance for your actual workload, and heavier jobs (image builds, integration tests against real services) don’t queue behind other tenants’ pipelines.

The trade-off is that now you’re responsible for the config, and a self-hosted runner set up carelessly can be slower and less secure than the shared runner it replaced.

Installing the Runner

On a Linux host, install the runner package and register it against your GitLab instance:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner

We use the Docker executor for most jobs, since it isolates each job in its own container instead of running commands directly on the host’s filesystem:

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --registration-token "$RUNNER_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "build-runner-01" \
  --tag-list "docker,build" \
  --run-untagged="false" \
  --locked="false"

--run-untagged="false" is the setting most walkthroughs skip. Without it, this runner will pick up any job that has no tags at all, including ones you never meant it to run.

Why Tags Aren’t Optional

A runner with no tags and “run untagged jobs” enabled becomes a shared dumping ground: build jobs, deploy jobs, and one-off debug jobs all land on it in whatever order they arrive. Tags let you say exactly which runner a job is allowed to use:

build-job:
  stage: build
  tags:
    - docker
    - build
  script:
    - docker build -t myapp:$CI_COMMIT_SHORT_SHA .

GitLab only assigns this job to a runner whose tag list includes both docker and build. If no runner matches, the job sits queued forever with no error, just a pipeline that never moves, which is its own debugging trap the first time it happens.

The Concurrency Setting Nobody Tunes

Every runner install ships with a config file at /etc/gitlab-runner/config.toml. Two settings in it decide how many jobs actually run at once on that host, and both default to values that assume a bigger machine than most people register on:

concurrent = 4

[[runners]]
  name = "build-runner-01"
  url = "https://gitlab.com/"
  executor = "docker"
  limit = 2

  [runners.docker]
    image = "alpine:latest"
    privileged = false
    pull_policy = ["if-not-present"]

concurrent is the global cap across every runner registered on this host. limit caps how many jobs this specific runner entry will accept at once. On a 4 vCPU, 8GB box, leaving the default concurrent = 4 with docker jobs that each need 2GB is how four jobs land simultaneously and the host starts swapping, or the OOM killer starts picking targets for you. Set limit to what the host can genuinely sustain, not what the default happened to ship with.

Separating Build Runners From Deploy Runners

A build runner executes whatever’s in a feature branch, including code from an open merge request nobody’s reviewed yet. A deploy runner typically holds credentials that can reach production. Running both job types on the same runner means untrusted code and production credentials share a host.

Register a second runner, tagged for deploy only, with tighter network access and no exposure to arbitrary feature-branch code:

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --registration-token "$DEPLOY_RUNNER_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "deploy-runner-01" \
  --tag-list "deploy" \
  --run-untagged="false" \
  --locked="true"
deploy-job:
  stage: deploy
  tags:
    - deploy
  script:
    - ./deploy.sh
  only:
    - main

--locked="true" stops this runner from being attached to other projects later by someone who doesn’t know it holds deploy credentials.

Config Mistakes That Waste Compute

A few settings cause most of the slow pipelines and wasted spend we’ve seen on self-hosted runners:

  • Leaving instance (shared) runners enabled alongside self-hosted ones. GitLab picks whichever available runner matches the job’s tags, including shared runners if the job has no tags restricting it. Jobs bounce between your warm self-hosted cache and a cold shared runner at random, and the caching setup you built stops paying off consistently.
  • Setting privileged = true globally instead of per-job. Docker-in-docker jobs need it, most jobs don’t. A privileged runner gives every job root-level access to the host, which is a bigger blast radius than most pipelines need.
  • No pull_policy set. Without it, the runner re-pulls the job’s Docker image from the registry every single run, even when nothing changed. Setting pull_policy = ["if-not-present"] reuses the locally cached image instead.
  • One runner tagged for everything. A runner tagged docker, build, test, deploy defeats the point of tagging, jobs land there because they can, not because it’s the right host for the job.

Verifying Which Runner Picked Up a Job

Don’t assume the tags are routing jobs the way you configured them. Check the job log header:

Running with gitlab-runner 16.9.0 (897a4459)
  on build-runner-01 aB3cD9fG

If a job you expect on deploy-runner-01 shows up running on build-runner-01, the tags in that job’s .gitlab-ci.yml entry don’t match what you think they match. We also print the runner ID inside a job when debugging routing issues:

debug-job:
  script:
    - echo "Running on runner $CI_RUNNER_ID with tags $CI_RUNNER_TAGS"

Key Lessons

  1. A runner with no tags and untagged jobs enabled will pick up anything.
    Set --run-untagged="false" on every runner you register unless you genuinely want it as a catch-all.
  2. concurrent and limit should match the host, not the default.
    Four docker jobs at once on a small box ends in swapping or an OOM kill, not faster pipelines.
  3. Deploy runners need their own tag and their own host.
    Untrusted feature-branch code and production credentials shouldn’t share a runner.
  4. Shared runners left enabled undercut a self-hosted cache.
    Disable instance runners for the project once self-hosted ones are in place, or jobs will land on whichever is free.
  5. Verify routing in the job log, don’t assume the tags did what the YAML says.
    “Running with gitlab-runner … on X” is the one line that confirms it.

Summary

Setting Purpose Use When
tags Restricts a job to specific runners Every job, without exception
concurrent / limit Caps parallel jobs per host / per runner Any host that isn’t oversized for its job mix
privileged Grants root-level host access to a job Docker-in-docker jobs only, never globally
pull_policy Controls whether the job image re-pulls each run if-not-present for images that rarely change

None of this needs a bigger fleet of runners, it needs the ones you already have configured for the jobs they actually run.

Read Next

If you’re running GitLab CI in production, follow along on LinkedIn for more guides like this one as they’re published.


Tags:
#GitLab   #CICD   #DevOps   #Runners  
#SelfHosted   #SRE

Leave a Comment