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.
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
- Docker and Docker Compose on whatever machine will run this (a spare laptop, a VPS, anything)
- A runner registration token from your GitLab project (Settings > CI/CD > Runners)
Getting the token
Go to your project or group:
Settings > CI/CD > Runners > New project runnerCopy the token. That's it.
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:
mkdir -p gitlab-runner && cd gitlab-runnerservices:
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.sockMounting the Docker socket lets the runner spin up sibling containers for each job.
Registering
docker compose up -d
docker compose exec gitlab-runner gitlab-runner registerIt 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:
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:
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:
concurrent = 8Pull policy — I set this to avoid pulling images every single time:
[runners.docker]
pull_policy = ["if-not-present"]Privileged mode — only needed if you're building Docker images inside CI:
[runners.docker]
privileged = truePrivileged 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:
build:
image: node:20-alpine
tags:
- docker
script:
- npm ci
- npm run buildNo tags in the job = it might still go to a shared runner and eat your quota. Always tag.
Day-to-day commands
# 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-runnersDocker-in-Docker
When I need to build images in CI, I use the dind service:
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:
volumes:
- ./config:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock
- ./certs/ca.crt:/etc/gitlab-runner/certs/ca.crt:ro