Gitea Runner on Ubuntu 24.04 on Azure User Guide
Overview
Gitea Actions splits into two halves. Your Gitea instance stores the code, the workflow files 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, workflows queue and nothing happens.
Gitea Runner is the official runner, published by the Gitea project. It speaks the workflow syntax teams already know from GitHub Actions, so existing pipelines are largely portable, and it executes each job inside its own container on the VM it runs on.
This image delivers Gitea Runner 3.5.0 on Ubuntu 24.04 with Docker from the Ubuntu archive, ready to connect to your own Gitea or Forgejo instance. Backed by 24/7 cloudimg support.
What is included:
- Gitea Runner 3.5.0 as a single Go binary at
/usr/local/bin/gitea-runner, run by systemd as the unprivilegedgitea-runneruser - Docker Engine from the Ubuntu noble archive, so the container engine receives Ubuntu security updates along with everything else on the system
alpine:3.22, pinned by digest, already present so a workflow can run without reaching a 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 and a firewall rule installed and enabled as systemd units, 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 registration, no runner 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.
Gitea is a trademark of its respective owner. 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. This image packages the unmodified open source software, which is distributed under the MIT License.

Prerequisites
- An Azure subscription
- A Gitea 1.21 or later, or Forgejo, instance with Actions enabled, reachable from this VM
- Permission on that instance to create a runner registration token (site administrator, organisation owner, or repository administrator)
- An SSH key pair for the
azureuseraccount
Step 1: Deploy from the Azure Marketplace
Search the Azure Marketplace for Gitea 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 Gitea 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 gitea-runner-ubuntu-24-04 --plan default
az group create --name gitea-runner-rg --location eastus
az vm create \
--resource-group gitea-runner-rg \
--name gitea-runner-vm \
--image cloudimg:gitea-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.
gitea-runner --version
docker version --format '{{.Server.Version}}'
systemctl is-active docker gitea-runner-egress-filter
systemctl is-enabled gitea-runner.service
systemctl is-active gitea-runner.service || echo "(inactive is the correct state until the runner is connected)"
gitea-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 service carries a condition on the registration file; systemd skips it until that file exists.
One container image is present, so a workflow 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. The bundled checker asserts that set by equality rather than by looking for a few unwanted ports:
sudo /usr/local/sbin/gitea-runner-port-check.sh
And this confirms the image is connected to nothing — no registration file, no runner token, no remembered instance address:
sudo /usr/local/sbin/gitea-runner-registration-assert.sh

Step 6: Your VM's runner identity
Every VM from this image generates its own runner name on first boot. Two runners registered to one Gitea instance must not share a name, so this is what stops a second VM you deploy from colliding with the first.
sudo grep -vE '^#' /root/gitea-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 '^GITEA_RUNNER_NAME=' /root/gitea-runner-credentials.txt | cut -d= -f2-)"
echo "This VM will register as: ${NAME}"
echo "Recorded for this VM: <GITEA_RUNNER_NAME> at <GITEA_RUNNER_HOST>"

Step 7: Get a registration token from your Gitea instance
A registration token is a short-lived value that the runner exchanges, once, for its own long-lived token. Create one at whichever scope you want this runner to serve:
| Scope | Where to create it |
|---|---|
| Every repository on the instance | Site Administration → Actions → Runners → Create new runner |
| One organisation | Organisation Settings → Actions → Runners |
| One repository | Repository Settings → Actions → Runners |
Copy the token. Note the root URL of your instance — https://gitea.example.com/, not the URL of a repository.
Step 8: Connect the runner to your instance
Put the two values in /etc/gitea-runner/registration.env and start the registration service:
sudo tee /etc/gitea-runner/registration.env >/dev/null <<'EOF'
GITEA_INSTANCE_URL=https://<your-domain>/
GITEA_RUNNER_REGISTRATION_TOKEN=<your-token>
EOF
sudo chmod 0600 /etc/gitea-runner/registration.env
sudo systemctl start gitea-runner-register
Registration writes /var/lib/gitea-runner/.runner, removes the now-spent registration token from registration.env, and starts the runner. Confirm it connected:
systemctl is-active gitea-runner.service || echo "(not connected yet — complete the block above first)"
sudo journalctl -u gitea-runner.service -n 20 --no-pager
A line reading declare successfully means the instance has accepted this runner. It should now appear under Runners in your Gitea settings, with the name from Step 6 and the labels from Step 11.
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 workflow
In any repository the runner serves, commit .gitea/workflows/hello.yml:
name: hello
on: [push]
jobs:
hello:
runs-on: alpine-3.22
steps:
- run: echo "Hello from $(hostname)"
- run: cat /etc/os-release | head -2
Push it. The run appears under the repository's Actions tab, and the runner claims it within a couple of seconds.
runs-on: alpine-3.22 selects the image that ships with this VM, so this first workflow needs no registry access. runs-on: ubuntu-latest also works and is what most existing workflows use — it pulls the full Gitea runner image on first use, which takes a few minutes once and is then cached.
Step 10: Prove a job cannot escape its container
A runner executes code from anyone who can push to the repositories it serves, so it is worth knowing exactly what a job can and cannot do. This runs a real workflow in a real container and reports back:
sudo /usr/local/sbin/gitea-runner-job-probe.sh
It asserts three things, by running them rather than by reading configuration: the job produced its own output; the job container had no Docker socket, so a workflow step cannot create containers of its own or take over the host; and the job could not reach the Azure instance metadata service, which is where a VM's managed identity credentials are handed out.
The last of those is enforced by firewall rules applied to container traffic only:
sudo iptables -S DOCKER-USER | grep REJECT
sudo /usr/local/sbin/gitea-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/gitea-runner-verify.sh
Step 11: Choose what jobs run inside
A label maps a runs-on: value to the container image that job runs in. The shipped set is in /etc/gitea-runner/config.yaml:
grep -A5 '^ labels:' /etc/gitea-runner/config.yaml
To change it, edit that list and re-register the runner, or pass GITEA_RUNNER_LABELS in registration.env before registering. Labels take the form name:docker://image.
A label ending in :host is refused by this image. Upstream supports one, and it runs workflow steps directly on the VM instead of inside a container — which would void every isolation property described in Step 10. If you genuinely need that, this is not the right image for the job.
Jobs run one at a time (capacity: 1 in the config), which suits Standard_B2s. Raise it on a larger VM if you want parallel jobs.
Step 12: Build caching
actions/cache works out of the box. The runner starts a cache server on port 8088 when it is running, and a firewall rule allows that port only from the Docker address pool, so job containers can reach it and nothing off the VM can:
sudo iptables -S INPUT | grep 8088
Cached data lives under /var/lib/gitea-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:
- Delete the runner in the old instance's Actions → Runners page.
- On the VM, stop the service and remove the registration:
sudo systemctl stop gitea-runner.service
sudo rm -f /var/lib/gitea-runner/.runner
- Repeat Step 8 with the new instance URL and a token from that instance.
Step 14: Upgrade
The OS and Docker update through Ubuntu's normal unattended upgrades — including the container engine, because this image uses the archive docker.io package rather than a third-party repository that unattended upgrades would not cover.
To upgrade the runner itself, replace the binary with a newer release from gitea.com/gitea/runner and restart:
sudo systemctl restart gitea-runner.service
gitea-runner --version
Check the release notes before upgrading across a major version: a runner must be compatible with the Gitea instance it is registered to.
Troubleshooting
gitea-runner.service is inactive and systemctl start does nothing. That is the shipped state before registration. Check whether the registration file exists:
ls -l /var/lib/gitea-runner/.runner 2>&1 | tail -1
systemctl show gitea-runner.service -p ConditionResult --value
If it does not exist, go back to Step 8.
Registration was rejected. The usual causes are an expired or already-used registration token, an instance URL that points at a repository rather than the instance root, or Actions not being enabled on the instance. The service reports which:
sudo journalctl -u gitea-runner-register.service -n 30 --no-pager
Registration reports that a previous attempt is still running. An attempt against an unreachable instance holds a lock while it retries. Wait for it to time out, then try again.
Workflows stay queued. The job's runs-on: value has to match one of this runner's labels (Step 11), and the runner has to be online in your instance's Runners page. Check what this runner declared:
sudo journalctl -u gitea-runner.service --no-pager | grep -m1 'declare successfully' || echo "runner has not declared itself 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 gitea.com >/dev/null 2>&1 && echo "DNS works from a job container"'
Check the whole appliance at once.
sudo /usr/local/sbin/gitea-runner-verify.sh
Support
cloudimg provides 24/7 support for this image: deployment, connecting the runner to Gitea or Forgejo, workflow and container image questions, caching, scaling to multiple runners, and upgrades. Contact support through the Azure Marketplace listing or at cloudimg.co.uk.