A GitLab server on its own just stores code. Push a .gitlab-ci.yml and nothing happens. The pipeline sits there on “pending” because nobody is around to run it. That’s the runner’s job.
GitLab Runner is a small agent you install on a separate machine. It asks your GitLab server for work, runs the job, and sends the log back. This guide installs it, registers it with the new glrt- token flow, sets it up with the Docker executor, and proves it works with a real pipeline.
My setup for this post: the runner lives on Oracle Linux 9.8 in a home lab (linux2, on a private 192.168.100.x network) and connects to the GitLab CE 19.5.3 server I built in How to Install GitLab CE on Ubuntu and RHEL. Runner version 19.5.3, Docker 29.9.3. All the RHEL-family output below comes from that VM. The Ubuntu commands come from GitLab’s and Docker’s documentation, since I didn’t build an Ubuntu runner for this one.
How the runner talks to GitLab
This part confuses people, so let’s clear it up first. The runner always connects out to GitLab. GitLab never connects in to the runner. So your runner can sit behind NAT, on a laptop or in a home lab with no public IP, and it works fine. You don’t open any ports on the runner machine.
The one thing it does need is a working connection to your GitLab URL. Check that first, from the runner machine:
curl -I https://gitlab.linuxpathfinder.com/
HTTP/2 302
server: nginx
location: https://gitlab.linuxpathfinder.com/users/sign_in
A 302 to the sign-in page is exactly what you want. That’s GitLab redirecting a visitor who isn’t logged in. A timeout or a certificate error means fix the network or DNS before going further.
Pick an executor before you install
The executor decides where your job actually runs. Two cover nearly everyone:
| Executor | Where jobs run | Good for |
|---|---|---|
docker |
A fresh container per job | Most pipelines. Clean every time, any image you like |
shell |
Directly on the host, as the gitlab-runner user |
Quick tests, or jobs that must touch the host itself |
I’d go with Docker. A job that installs packages or leaves junk behind can’t break the runner machine, and the next job starts clean. That’s what this guide uses.
Step 1: Install Docker
Skip this if you’re going with the shell executor.
RHEL, Oracle Linux, Rocky and AlmaLinux. These distros ship Podman, which clashes with Docker’s packages, so remove it first:
sudo dnf remove -y podman buildah runc
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io
sudo systemctl enable --now docker
Ubuntu:
sudo apt update && sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release; echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io
Quick sanity check: sudo docker run --rm hello-world should print “Hello from Docker!”. On my VM:
rpm -q docker-ce
docker-ce-29.9.3-1.el9.x86_64
Step 2: Install GitLab Runner
GitLab has its own package repo for the runner, separate from the one for GitLab itself.
RHEL family:
curl -fsSL https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | sudo bash
sudo dnf install -y gitlab-runner
Ubuntu:
curl -fsSL https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install -y gitlab-runner
Check what landed:
gitlab-runner --version
Version: 19.5.3
Git revision: 33227fd4
Git branch: 19-5-stable
GO version: go1.26.10
Built: 2026-11-05T14:38:04Z
OS/Arch: linux/amd64
rpm -q gitlab-runner gitlab-runner-helper-images
gitlab-runner-19.5.3-1.x86_64
gitlab-runner-helper-images-19.5.3-1.noarch
The package also creates a gitlab-runner system user and a systemd service, both of which come up in a minute. Try to keep the runner on the same version as your GitLab server, or close to it. Mine are both 19.5.3.
Step 3: Create the runner in GitLab and get a token
Here’s where older tutorials send you wrong. They tell you to copy a “registration token” from the settings page. That method is deprecated and scheduled for removal in GitLab 20.0. The current way is to create the runner in the UI first, and GitLab hands you an authentication token starting with glrt-.
For a runner every project can use, sign in as an admin and go to:
Admin → CI/CD → Runners → Create instance runner
Careful. That’s the CI/CD entry near the top of the Admin sidebar, not the CI/CD page under Settings. The Settings one only holds runner options and has no create button, which is the exact spot I got stuck on first. You can also jump straight to https://your-gitlab/admin/runners/new.
- Add tags. I used
oracleanddocker. - Tick Run untagged jobs, so jobs without a
tags:line still get picked up. - Click Create runner and copy the token.
Copy it straight away. GitLab only shows it once. And treat it like a password. Anyone holding it can attach their own machine to your GitLab and receive your jobs, secrets included.
Want a runner for one project only? Same idea, from the project’s Settings → CI/CD → Runners → New project runner.
Step 4: Register the runner
Back on the runner machine. Run this with sudo. Without it, the config goes into your home folder and the systemd service never sees it.
sudo gitlab-runner register --non-interactive \
--url "https://gitlab.example.com" \
--token "glrt-YOUR_TOKEN_HERE" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "linux2-oracle-runner"
The --docker-image is just the default, used when a job doesn’t say which image it wants. Here’s how mine went:
Runtime platform arch=amd64 os=linux pid=194981 revision=33227fd4 version=19.5.3
Running in system-mode.
Verifying runner... is valid runner=r-6T6k9qM runner_name=linux2-oracle-runner
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml"
Two lines to look for. Running in system-mode means the config went to /etc/gitlab-runner/, which is right. is valid means GitLab accepted the token.
Prefer answering prompts? Leave out --non-interactive and the other flags, and it’ll ask for the URL, a name, the executor and the default image one at a time. Same result. Just don’t run both versions, or you’ll register the runner twice.
Step 5: Look at config.toml
Everything the runner knows lives in one file. Here’s mine, trimmed a little and with the token hidden:
sudo cat /etc/gitlab-runner/config.toml
concurrent = 1
check_interval = 0
connection_max_age = "15m0s"
shutdown_timeout = 0
[[runners]]
name = "linux2-oracle-runner"
url = "https://gitlab.linuxpathfinder.com"
id = 1
token = "****"
executor = "docker"
[runners.docker]
tls_verify = false
image = "alpine:latest"
privileged = false
volumes = ["/cache"]
shm_size = 0
Two settings worth knowing about. concurrent = 1 means one job at a time across the whole runner. With a few cores spare, raise it to 2 or 4 and pipelines with parallel jobs finish a lot sooner. And privileged = false is the safe default. You’d only flip it if you build Docker images inside jobs with Docker-in-Docker, and then the runner should only serve projects you trust.
The runner reloads this file by itself when it changes. No restart needed.
Step 6: Run a test pipeline
Create a project in GitLab, add a .gitlab-ci.yml file to it, and commit:
test-job:
script:
- echo "Hello from GitLab Runner"
- cat /etc/os-release | head -2
Then open Build → Pipelines. That’s it. The second command is a neat check: it prints the OS inside the job, which tells you whether the Docker executor really kicked in.
If the job sits on pending forever, the tags don’t match. A job with tags: [oracle] only goes to a runner tagged oracle, and an untagged job only goes to a runner allowed to run untagged jobs.
Setup confirmation
Here’s the whole thing checked end to end. First, the service on the runner VM:
sudo systemctl status gitlab-runner --no-pager
● gitlab-runner.service - GitLab Runner
Loaded: loaded (/etc/systemd/system/gitlab-runner.service; enabled; preset: disabled)
Active: active (running) since Wed 2026-11-11 00:47:48 PKT; 27min ago
Main PID: 193293 (gitlab-runner)
Memory: 76.5M (peak: 78.8M)
sudo gitlab-runner list
Listing configured runners ConfigFile=/etc/gitlab-runner/config.toml
linux2-oracle-runner Executor=docker Token=glrt-**** URL=https://gitlab.linuxpathfinder.com
sudo gitlab-runner verify
Running in system-mode.
Verifying runner... is valid runner=r-6T6k9qM runner_name=linux2-oracle-runner
Running, enabled at boot, and using less than 80 MB of RAM while it waits. GitLab sees it too. Under Admin → CI/CD → Runners it shows up as Online with both tags:

And the real test, the pipeline. It passed in 20 seconds after waiting 1 second in the queue. Here’s the important part of the job log:
Running with gitlab-runner 19.5.3 (33227fd4)
on linux2-oracle-runner r-6T6k9qM, system ID: s_1ecb389843f3
Preparing the "docker" executor
Using Docker executor with image alpine:latest ...
Pulling docker image alpine:latest ...
Preparing environment
Running on runner-r-6t6k9qm-project-2-concurrent-0 via linux2...
Getting source from Git repository
Fetching changes with git depth set to 20...
Checking out 1def8f72 as detached HEAD (ref is main)...
Executing "step_script" stage of the job script
echo "Hello from GitLab Runner"
Hello from GitLab Runner
cat /etc/os-release | head -2
NAME="Alpine Linux"
ID=alpine
Job succeeded
See the last two lines of output? The runner host is Oracle Linux, but the job reported Alpine Linux. So the job really did run inside a fresh container. via linux2 confirms which machine picked it up.

The first job is the slowest one. Preparing the Docker executor, mostly pulling the Alpine image, took 13 of those 20 seconds. Later jobs reuse what’s already on disk.
Frequently asked questions
Does the runner need a public IP or open ports?
No. It only makes outbound HTTPS connections to GitLab. Mine runs on a private home network behind NAT with nothing forwarded.
Can I install the runner on the same server as GitLab?
You can, but I wouldn’t. A heavy build will fight GitLab for CPU and memory, and a misbehaving job sits right next to your repositories. A separate VM, even a small one, is safer.
What’s the difference between a registration token and a glrt token?
The old registration token came from a settings page and could register any number of runners. It’s deprecated. A glrt- authentication token belongs to one runner you created in the UI, and that’s what gitlab-runner register --token expects now.
My job is stuck on pending. Why?
Almost always tags. Check the job’s tags: against the runner’s tags, or allow the runner to run untagged jobs. Also check the runner shows as Online. If it doesn’t, look at sudo journalctl -u gitlab-runner -n 50.
How do I remove a runner properly?
Delete it in GitLab, then run sudo gitlab-runner verify --delete on the runner machine. That drops any runner GitLab no longer recognises from config.toml.
Where to go next
The runner works, so now it’s time for pipelines that do something useful: build stages, test stages, a deploy job. Before you go there, it’s worth knowing your containers. Working effectively with Docker containers and Docker images cover the basics your jobs will lean on. Coming from GitHub? Setting up a self-hosted GitHub Actions runner shows how the same idea works over there. And if the bigger picture is still fuzzy, What is DevOps walks through a real pipeline from start to finish.