GitLab Self-Hosted Runner on Docker

I got tired of watching my GitLab CI/CD minutes drain away. On free tier it's a constant worry, and even on paid plans the quota disappears fast if you have a few active projects. The fix is simple: run your own runner on any machine you have lying around. All pipeline minutes become unlimited.

I run mine inside Docker because it keeps things contained and easy to move between machines.

Note

If you're on GitHub Actions, heads up: starting March 2026, GitHub is introducing a $0.002/min platform charge for self-hosted runners in private repos. Self-hosted minutes will count against your plan's free quota too. The community pushback was loud enough that GitHub walked it back partially, but the direction is clear — free CI minutes are disappearing across the board.

What you need

Getting the token

Go to your project or group:

Text
Settings > CI/CD > Runners > New project runner

Copy the token. That's it.

Note

GitLab 16.0+switched to runner authentication tokens instead of registration tokens. The registration command handles both.

The compose file

I keep a dedicated folder for this:

Bash
mkdir -p gitlab-runner && cd gitlab-runner
docker-compose.yml
services:
  gitlab-runner:
    image: gitlab/gitlab-runner:latest
    container_name: gitlab-runner
    restart: unless-stopped
    volumes:
      - ./config:/etc/gitlab-runner
      - /var/run/docker.sock:/var/run/docker.sock

Mounting the Docker socket lets the runner spin up sibling containers for each job.

Registering

Bash
docker compose up -d
docker compose exec gitlab-runner gitlab-runner register

It asks a few things:

Prompt What I use
GitLab instance URL https://gitlab.com (or your self-hosted URL)
Registration token The token from the UI
Description Something short, like home-docker
Tags docker
Executor docker
Default Docker image alpine:latest

If you prefer a one-liner:

Bash
docker compose exec gitlab-runner gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com" \
  --token "$RUNNER_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "home-docker"

Tweaking the config

Registration creates ./config/config.toml. The full reference is here, but the defaults are fine to start. Here's what I usually change:

config/config.toml
concurrent = 4
check_interval = 3

[[runners]]
  name = "home-docker"
  url = "https://gitlab.com"
  executor = "docker"

  [runners.docker]
    image = "alpine:latest"
    privileged = false
    volumes = ["/cache"]

More parallel jobs — bump concurrent if your machine can handle it:

toml
concurrent = 8

Pull policy — I set this to avoid pulling images every single time:

toml
[runners.docker]
  pull_policy = ["if-not-present"]

Privileged mode — only needed if you're building Docker images inside CI:

toml
[runners.docker]
  privileged = true
Warning

Privileged mode gives containers full host access. I only enable this on machines I fully control.

Using it

The runner should already show up in Settings > CI/CD > Runners. To make sure jobs land on it, use tags in your .gitlab-ci.yml:

.gitlab-ci.yml
build:
  image: node:20-alpine
  tags:
    - docker
  script:
    - npm ci
    - npm run build

No tags in the job = it might still go to a shared runner and eat your quota. Always tag.

Day-to-day commands

Bash
# logs
docker compose logs -f gitlab-runner

# restart
docker compose restart

# update to latest
docker compose pull && docker compose up -d

# unregister everything
docker compose exec gitlab-runner gitlab-runner unregister --all-runners

Docker-in-Docker

When I need to build images in CI, I use the dind service:

.gitlab-ci.yml
build-image:
  image: docker:latest
  services:
    - docker:dind
  tags:
    - docker
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker build -t my-app:$CI_COMMIT_SHORT_SHA .

This needs privileged = true in the runner config.

Common issues

Jobs stuck in pending — usually a tag mismatch. Check that your .gitlab-ci.yml tags match what you registered the runner with.

Permission denied on Docker socket — on Linux, the runner user needs to be in the docker group, or adjust the socket permissions.

TLS errors with self-signed certs — mount your CA cert:

docker-compose.yml
volumes:
  - ./config:/etc/gitlab-runner
  - /var/run/docker.sock:/var/run/docker.sock
  - ./certs/ca.crt:/etc/gitlab-runner/certs/ca.crt:ro