GitLab Runner is the agent that picks up CI/CD jobs from GitLab and executes them on your own infrastructure. Running your own runner gives you control over CPU, memory, caching and network access, and avoids the minute limits of shared runners. In this tutorial you will install GitLab Runner from GitLab's official package repository on Ubuntu 24.04, register it with a runner authentication token, run jobs in Docker containers and verify everything with a test pipeline.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 vCPUs and 4 GB of RAM (builds are usually the limiting factor, not the runner itself).
- A non-root user with
sudoprivileges. - A GitLab project on GitLab.com or on a self-managed GitLab instance, with the Maintainer or Owner role so you can create runners.
- Outbound HTTPS access from the server to your GitLab instance. The runner polls GitLab, so no inbound ports are required.
Step 1 - Installing Docker
This guide uses the Docker executor, which runs every job in a fresh container. That keeps builds isolated from each other and from the host, and lets each project choose its own image. Install Docker Engine from the Ubuntu repositories:
sudo apt update
sudo apt install docker.io
Make sure the service is enabled and running:
sudo systemctl enable --now docker
sudo docker run --rm hello-world
The test container prints a greeting that starts like this:
Hello from Docker!
This message shows that your installation appears to be working correctly.
NoteIf you prefer the latest Docker release, install Docker Engine from Docker's official repository instead. The runner works with either.
Step 2 - Installing GitLab Runner
GitLab publishes the runner in its own APT repository. GitLab provides a script that adds the repository and its signing key. Download it first so you can review it before running it with root privileges:
curl -fsSL -o /tmp/gitlab-runner-repo.sh https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
less /tmp/gitlab-runner-repo.sh
When you are satisfied with its contents, run it and install the package:
sudo bash /tmp/gitlab-runner-repo.sh
sudo apt install gitlab-runner
The package creates a gitlab-runner system user, installs the binary to /usr/bin/gitlab-runner and registers a systemd service. Check the version and the service state:
gitlab-runner --version
sudo systemctl status gitlab-runner
The status output should show the service as active (running):
● gitlab-runner.service - GitLab Runner
Loaded: loaded (/etc/systemd/system/gitlab-runner.service; enabled; preset: enabled)
Active: active (running)
At this point the runner is running but has no configuration, so it does not pick up any jobs yet.
Step 3 - Creating the runner in GitLab
Since GitLab 16, runners are created in the GitLab UI first and then registered on the server with a runner authentication token (it starts with glrt-). The old registration tokens are deprecated and disabled by default on recent GitLab versions, so use the new workflow.
In your project, go to Settings > CI/CD, expand Runners and select New project runner. Fill in the form:
- Tags:
docker,linux. Jobs use tags to select a runner. - Run untagged jobs: enable it if you want this runner to also pick up jobs without
tags. - Runner description: a name such as
docker-runner-01. - Maximum job timeout: optional, for example
3600seconds.
Select Create runner. GitLab shows the registration command with the token. Copy the token now; it is only displayed once.
NoteTags, the untagged option, the protected flag and the timeout are stored in GitLab for runners created this way. You change them in the UI, not with command line flags.
Step 4 - Registering the runner with the Docker executor
Register the runner on the server. Replace https://gitlab.com with the URL of your GitLab instance if you are self-hosting, and your_runner_token with the glrt- token you just copied:
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com" \
--token "your_runner_token" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "docker-runner-01"
--docker-image is the default image for jobs whose .gitlab-ci.yml does not set image:. The command writes the configuration to /etc/gitlab-runner/config.toml:
Runtime platform arch=amd64 os=linux ...
Verifying runner... is valid runner=AbCdEfGh
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
Confirm that the runner can authenticate against GitLab:
sudo gitlab-runner verify
Verifying runner... is valid runner=AbCdEfGh
Back in Settings > CI/CD > Runners, the runner now appears with a green dot, meaning it is online.
Step 5 - Reviewing config.toml
Open the configuration file to see what registration created and to adjust concurrency and resource limits:
sudo nano /etc/gitlab-runner/config.toml
A tuned configuration for a 4 vCPU server looks like this. Keep the id, token and token_* values that registration wrote for you:
concurrent = 2
check_interval = 0
shutdown_timeout = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "docker-runner-01"
url = "https://gitlab.com"
id = 12345678
token = "glrt-your_runner_token"
token_obtained_at = 2026-09-25T10:00:00Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false
pull_policy = ["if-not-present"]
memory = "3g"
cpus = "2"
volumes = ["/cache"]
shm_size = 0
The settings that matter most:
| Setting | Meaning |
|---|---|
concurrent | Maximum number of jobs this machine runs at the same time, across all runners in the file. |
executor | How jobs run. docker starts one container per job. |
image | Default job image when the pipeline does not set one. |
privileged | Gives job containers full access to the host. Keep it false unless you need Docker-in-Docker (Step 7). |
pull_policy | if-not-present reuses images already on the host, which speeds up jobs. |
memory, cpus | Hard limits for each job container, so one build cannot starve the others. |
volumes | /cache is a Docker volume used for the job cache between runs. |
GitLab Runner reloads config.toml automatically when it changes. To apply it immediately, restart the service:
sudo systemctl restart gitlab-runner
If the file contains a syntax error, the logs tell you where:
sudo journalctl -u gitlab-runner -n 50 --no-pager
Step 6 - Running a test pipeline
Add a .gitlab-ci.yml file to the root of your repository to confirm that jobs reach your runner. The tags value must match the tags you set in Step 3:
stages:
- test
runner-check:
stage: test
image: alpine:3.20
tags:
- docker
script:
- echo "Running on $CI_RUNNER_DESCRIPTION"
- cat /etc/os-release
- nproc
Commit and push the file. In GitLab, open Build > Pipelines and select the job. The log shows which runner executed it:
Running with gitlab-runner 18.x (...)
on docker-runner-01 AbCdEfGh, system ID: s_0123456789ab
Preparing the "docker" executor
Using Docker executor with image alpine:3.20 ...
$ echo "Running on $CI_RUNNER_DESCRIPTION"
Running on docker-runner-01
...
Job succeeded
On the server you can follow jobs as they are picked up:
sudo journalctl -u gitlab-runner -f
Using the cache
The Docker executor stores caches in the /cache volume on the runner host, so dependencies survive between pipelines without extra setup. For example, for a Node.js project:
build:
image: node:22
tags:
- docker
cache:
key:
files:
- package-lock.json
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths:
- dist/
expire_in: 7 days
The cache key is derived from package-lock.json, so the cache is rebuilt only when dependencies change. artifacts are uploaded to GitLab and passed to later stages; caches stay on the runner.
Step 7 - Building Docker images with Docker-in-Docker (optional)
Jobs that run docker build need access to a Docker daemon. The most common approach is the docker:dind service, which requires privileged containers. Only enable this on a runner dedicated to trusted projects, because a privileged job can take over the host.
Edit /etc/gitlab-runner/config.toml and change the [runners.docker] block:
[runners.docker]
image = "docker:27"
privileged = true
volumes = ["/certs/client", "/cache"]
The /certs/client volume lets the job and the dind service share the TLS certificates that Docker generates. Restart the runner:
sudo systemctl restart gitlab-runner
Then use a job like this, which builds an image and pushes it to the project's GitLab Container Registry:
build-image:
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
tags:
- docker
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
TipIf you do not want privileged containers at all, build images with a daemonless tool such as Buildah or BuildKit in rootless mode instead of Docker-in-Docker.
Step 8 - Managing the runner
List the runners configured on this host:
sudo gitlab-runner list
docker-runner-01 Executor=docker Token=glrt-... URL=https://gitlab.com
Remove a runner from this host and from GitLab:
sudo gitlab-runner unregister --name docker-runner-01
Docker images and stopped containers from past jobs accumulate over time. Clean up unused data regularly with a weekly cron entry or run it manually:
sudo docker system prune --all --force --filter "until=168h"
To back up the runner, save /etc/gitlab-runner/config.toml. It contains the runner token, so store the copy somewhere with restricted access.
Troubleshooting
Jobs stay pending with "This job is stuck because the project doesn't have any runners online assigned to it". The job's tags do not match any online runner. Check the runner's tags in Settings > CI/CD > Runners, or enable Run untagged jobs for jobs without tags.
ERROR: Failed to load config stat /etc/gitlab-runner/config.toml: permission denied. You ran gitlab-runner without sudo. The system service uses /etc/gitlab-runner/config.toml, which only root can read.
Jobs fail with Cannot connect to the Docker daemon at tcp://docker:2375. The job uses the dind service but the runner is not privileged, or DOCKER_TLS_CERTDIR does not match the /certs/client volume. Review Step 7.
Shell executor jobs fail at "Preparing environment". On Ubuntu the gitlab-runner home directory can contain a .bash_logout file that clears the terminal and breaks the shell executor. Remove it with sudo rm /home/gitlab-runner/.bash_logout.
Conclusion
You now have a self-hosted GitLab Runner on Ubuntu 24.04 that executes jobs in isolated Docker containers, with resource limits, a local cache and an optional Docker-in-Docker setup for building images. From here you can register more runners on additional servers to scale out, add a separate protected runner for production deployments, or configure an S3-compatible distributed cache so several runners share the same cache.