GitLab Runner on Ubuntu 24.04 on Azure User Guide
Overview
GitLab CI/CD splits into two halves. Your GitLab instance stores the code, the pipeline definitions and the run history. A runner is the other half: the component that claims a queued job, runs it, streams the log back, and cleans up afterwards. Without a runner, pipelines queue and nothing happens.
GitLab Runner is the official runner, published by GitLab. It executes each job inside its own container on the VM it runs on, and connects outward to your instance, so nothing has to be opened inbound.
This image delivers GitLab Runner 19.4.0 on Ubuntu 24.04 with Docker from the Ubuntu archive, ready to connect to your own GitLab instance or to GitLab.com. Backed by 24/7 cloudimg support.
What is included:
- GitLab Runner 19.4.0 from the project's own apt repository, pinned by version and checksum, with the repository signing key scoped to that repository alone
- Docker Engine from the Ubuntu noble archive, so the container engine receives Ubuntu security updates along with everything else on the system
- The runner's own package source wired into unattended upgrades, so the runner is patched automatically too rather than being the one component that quietly goes stale
alpine:3.22and the runner's own helper image, both already present, so a first pipeline job runs without reaching a container registry- A unique runner name generated on your VM's first boot, so two runners connected to one instance never collide
- A job sandbox that is set deliberately rather than left at its defaults: jobs get no access to the Docker socket, cannot bind-mount host directories, cannot run privileged, and cannot reach the Azure instance metadata service
- An egress filter installed and enabled as a systemd unit, reapplied whenever Docker restarts
- On-VM self tests you can run at any time, each of which is itself tested against a known bad input
This image ships connected to nothing. It contains no runner registration, no authentication token and no remembered instance address, and the runner service will not start until you have told it where to connect. That is deliberate: a runner image carrying a registration would hand every deployment the same identity, claiming jobs from — and reporting build logs to — somebody else's instance.
GitLab is a trademark of GitLab Inc. All product and company names are trademarks or registered trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them. cloudimg is not affiliated with, endorsed by or sponsored by GitLab Inc. This image packages the unmodified open source software, which is distributed under the MIT License.

Prerequisites
- An Azure subscription
- A GitLab instance (self-managed 16.0 or later, or GitLab.com) reachable from this VM
- Permission on that instance to create a runner: instance administrator, group owner, or project maintainer
- An SSH key pair for the
azureuseraccount
Step 1: Deploy from the Azure Marketplace
Search the Azure Marketplace for GitLab Runner on Ubuntu 24.04 LTS by cloudimg, choose Create, and complete the VM wizard. Standard_B2s (2 vCPU, 4 GiB) is the recommended size and is enough for one job at a time; choose a larger size if your builds are heavy.
Allow SSH (22) inbound and nothing else. A runner needs no inbound ports at all — it dials out to your GitLab instance — so port 22 is only there for you to administer it.
Step 2: Deploy from the Azure CLI
Accept the Marketplace image terms once per subscription, then create the VM. Both commands run on your own workstation, not on the VM.
az vm image terms accept --publisher cloudimg --offer gitlab-runner-ubuntu-24-04 --plan default
az group create --name gitlab-runner-rg --location eastus
az vm create \
--resource-group gitlab-runner-rg \
--name gitlab-runner-vm \
--image cloudimg:gitlab-runner-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Step 3: Connect to your VM
ssh azureuser@<vm-public-ip>
Step 4: Confirm the runner is installed and idle
The runner is installed, enabled, and deliberately not running, because it has not yet been told where to connect.
gitlab-runner --version | sed -n '1,2p'
docker version --format '{{.Server.Version}}'
systemctl is-active docker gitlab-runner-egress-filter
systemctl is-enabled gitlab-runner.service
systemctl is-active gitlab-runner.service || echo "(inactive is the correct state until the runner is connected)"
gitlab-runner.service reporting inactive while is-enabled reports enabled is the correct state for a runner that has not been connected yet, not a fault. The unit carries a condition that systemd evaluates before every start; while no runner is configured, it skips the unit cleanly.
sudo /usr/local/sbin/gitlab-runner-registered-check.sh --explain || echo "(exit 1 means: not connected yet)"
Two container images are present, so a pipeline job can run immediately even on a VM with no outbound registry access:
docker image ls
Step 5: Check what the network can reach
sudo ss -Hltn | awk '{print $4}' | sort -u
Off the VM, only SSH is listening. Everything else is bound to the loopback interface, and that stays true after you connect the runner — GitLab Runner's metrics and session-server listeners are both optional and neither is enabled here. The bundled checker asserts that set by equality rather than by looking for a few unwanted ports:
sudo /usr/local/sbin/gitlab-runner-port-check.sh
And this confirms the image is connected to nothing — no runner section, no authentication token, no remembered instance address:
sudo /usr/local/sbin/gitlab-runner-registration-assert.sh

Step 6: Your VM's runner identity
Every VM from this image generates its own runner name on first boot. That name is what tells two of these runners apart in your GitLab runner list, so it is generated here and on no other VM.
sudo grep -vE '^#' /root/gitlab-runner-credentials.txt
That file is 0600 root:root and holds no secret — this appliance has no credential of its own until you connect it to your instance.
NAME="$(sudo grep '^GITLAB_RUNNER_NAME=' /root/gitlab-runner-credentials.txt | cut -d= -f2-)"
echo "This VM will register as: ${NAME}"
echo "Recorded for this VM: <GITLAB_RUNNER_NAME> at <GITLAB_RUNNER_HOST>"
You can also see, at any time, exactly what a job on this runner is allowed to do:
sudo /usr/local/sbin/gitlab-runner-posture-check.sh

Step 7: Create a runner in GitLab and copy its token
Since GitLab 16.0, you create the runner in GitLab first, and GitLab hands you a runner authentication token that begins glrt-. The older short-lived registration token was removed in GitLab 18.0 and is not used by this image.
| Scope | Where to create it |
|---|---|
| Every project on the instance | Admin area → CI/CD → Runners → New instance runner |
| One group | Group → Settings → CI/CD → Runners → New group runner |
| One project | Project → Settings → CI/CD → Runners → New project runner |
While creating it, set the tags you want this runner to serve (or tick run untagged jobs), and give it a description. Those belong to the runner object in GitLab, not to this VM, so you change them in GitLab afterwards without touching the runner.
GitLab shows the token once. Copy it, and note the root URL of your instance — https://gitlab.example.com/ or https://gitlab.com/, not the URL of a project.
Step 8: Connect the runner to your instance
Put the two values in /etc/gitlab-runner/registration.env and start the registration service:
sudo tee /etc/gitlab-runner/registration.env >/dev/null <<'EOF'
GITLAB_INSTANCE_URL=https://<your-domain>/
GITLAB_RUNNER_TOKEN=<your-token>
EOF
sudo chmod 0600 /etc/gitlab-runner/registration.env
sudo systemctl start gitlab-runner-register
The token is passed to GitLab through an environment variable rather than on a command line, because a command line is readable by every account on the machine for as long as the process lives. Registration writes the runner into /etc/gitlab-runner/config.toml, removes the now-duplicated token from registration.env, verifies that the job sandbox it produced is the one this image permits, and starts the runner. Confirm it connected:
systemctl is-active gitlab-runner.service || echo "(not connected yet — complete the block above first)"
sudo journalctl -u gitlab-runner.service -n 20 --no-pager
A line reading Starting multi-runner followed by Checking for jobs... means the runner is polling your instance. It should now appear as online under Runners in your GitLab settings, with the name from Step 6.
You can also supply the same two keys as custom data when you create the VM, in the same KEY=VALUE form, to have the runner connect on its very first boot. Note that custom data is readable by anything on the VM that can query the Azure instance metadata service, so if that matters to you, leave it out at launch and write the token in afterwards instead.
Step 9: Run your first pipeline job
In any project the runner serves, commit .gitlab-ci.yml:
hello:
image: alpine:3.22
script:
- echo "Hello from $(hostname)"
- cat /etc/os-release | sed -n '1,2p'
Push it. The pipeline appears under the project's Build → Pipelines, and the runner claims the job within a few seconds.
image: alpine:3.22 selects an image that already ships with this VM, so this first job needs no registry access. Any other image works too — it is pulled on first use and then cached.
If your runner was created with tags, add a matching tags: list to the job, or GitLab will leave it queued.
Step 10: Prove a job cannot escape its container
A runner executes code from anyone who can push to the projects it serves, so it is worth knowing exactly what a job can and cannot do. This runs a real pipeline job, through the real runner daemon, against a throwaway server on loopback that speaks GitLab's runner protocol — so it works before you have connected anything, and it reads the job's own output back the way your GitLab would:
sudo /usr/local/sbin/gitlab-runner-job-probe.sh
It asserts four things by running them rather than by reading configuration: the job produced its own output, as received by the server; the job container had no Docker socket, so a job cannot create containers of its own or take over the host; the job could not reach the Azure instance metadata service, which is where a VM's managed identity credentials are handed out; and the job used the locally shipped image rather than reaching a registry.
It is safe to run on a VM that is already connected to your GitLab: it uses its own temporary configuration and never touches the registration in /etc/gitlab-runner/config.toml.
The metadata boundary is enforced by firewall rules applied to container traffic only:
sudo iptables -S DOCKER-USER | grep REJECT
sudo /usr/local/sbin/gitlab-runner-egress-filter.sh check
The rules refuse 169.254.169.254 outright and refuse the Azure agent's HTTP ports on 168.63.129.16. DNS to that address stays open on purpose — on Azure the platform DNS resolver is the same host, so blocking it entirely would leave every job unable to resolve anything.

For the whole posture in one command:
sudo /usr/local/sbin/gitlab-runner-verify.sh
Step 11: What jobs are allowed to do
The sandbox is written into the runner's configuration when you connect it, and you can read it back at any time:
sudo /usr/local/sbin/gitlab-runner-posture-check.sh
It refuses four things, and each is refused for a reason:
- A non-
dockerexecutor. Theshellexecutor would run job scripts directly on this VM, as the runner's own account, outside every isolation property above. This image refuses it at registration. - Privileged containers. A privileged job container is effectively the host.
- Host bind mounts. Any volume entry that maps a host path into the job — the Docker socket is only the most famous one — is refused. Jobs get a Docker-managed
/cachevolume and nothing from the host filesystem. - Extra kernel capabilities and host devices.
Jobs run one at a time (concurrent = 1 in /etc/gitlab-runner/config.toml), which suits Standard_B2s. Raise it on a larger VM if you want parallel jobs:
grep -E '^(concurrent|check_interval)' /etc/gitlab-runner/config.toml
The service account that runs the daemon is in the docker group, which on any host is equivalent to root. That is unavoidable for a runner whose job is to create containers, and it is why this image is meant to be a dedicated runner VM rather than a machine shared with anything else.
Step 12: Keeping it patched
The OS and Docker update through Ubuntu's normal unattended upgrades. GitLab Runner has no package in the Ubuntu archive, so this image adds the project's own repository to the allowed origins — without that line the runner would be the one component that never got patched:
grep -A2 'Allowed-Origins' /etc/apt/apt.conf.d/52cloudimg-gitlab-runner-origin
apt-cache policy gitlab-runner | sed -n '1,3p'
The package is pinned inside the 19.x line, because a runner has to be compatible with the GitLab instance it serves and a major-version jump is a decision worth making deliberately. To move to a new major version when your GitLab is ready:
grep -A2 'Package: gitlab-runner' /etc/apt/preferences.d/gitlab-runner
Edit that pin, then sudo apt-get update && sudo apt-get install --only-upgrade gitlab-runner.
Step 13: Move the runner to another instance
The runner refuses to silently re-register somewhere new, because that would abandon the runner your first instance still lists while this VM quietly stopped claiming its jobs. To move it deliberately:
On the VM, disconnect it:
sudo /usr/local/sbin/gitlab-runner-disconnect.sh
That stops the runner, asks your instance to delete the runner object, and removes the local registration. Then repeat Step 8 with the new instance URL and a token created in that instance.
Use this rather than calling gitlab-runner unregister directly. The daemon on this image runs as an unprivileged account, and unregister run under sudo rewrites /etc/gitlab-runner/config.toml as root, which leaves the service unable to read its own configuration afterwards. The command above does the same work and puts the ownership back.
Troubleshooting
gitlab-runner.service is inactive and systemctl start does nothing. That is the shipped state before you connect it. Check what the condition sees:
sudo /usr/local/sbin/gitlab-runner-registered-check.sh --explain || echo "(exit 1 means: not connected yet)"
If it says there is no runner configured, go back to Step 8.
Registration was rejected. The usual causes are a token that is not a runner authentication token (it must begin glrt-), a token that has been revoked, a runner object deleted in GitLab, or an instance URL pointing at a project rather than the instance root. The service reports which:
sudo journalctl -u gitlab-runner-register.service -n 30 --no-pager
Jobs stay queued. If the runner was created with tags, the job needs a matching tags: list, or the runner needs run untagged jobs enabled — both are settings on the runner object in GitLab. Check the runner is online in your Runners page, and that this VM is polling:
sudo journalctl -u gitlab-runner.service --no-pager -n 50 | grep -m1 'Checking for jobs' || echo "runner is not polling yet"
A job cannot reach something on the network. Jobs are deliberately blocked from the Azure instance metadata service and the Azure agent endpoints (Step 10). Everything else, including DNS and ordinary internet access, is unaffected:
docker run --rm alpine:3.22 sh -c 'nslookup gitlab.com >/dev/null 2>&1 && echo "DNS works from a job container"'
Check the whole appliance at once.
sudo /usr/local/sbin/gitlab-runner-verify.sh
Support
cloudimg provides 24/7 support for this image: deployment, connecting the runner to GitLab, pipeline and container image questions, caching, scaling to multiple runners, and upgrades. Contact support through the Azure Marketplace listing or at cloudimg.co.uk.