Deployment guide

Gitea Runner

Overview

Gitea Actions has two halves. Your Gitea instance stores the code, the workflow files and the run history. A runner is the other half: it 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. It runs each job in its own container on the VM it is installed on.

This SecureAnyCloud image delivers Gitea Runner 3.5.0 on Ubuntu 24.04 with Docker from the Ubuntu archive. It is ready to connect to your own Gitea or Forgejo instance.

What you get:

  • Gitea Runner 3.5.0 as a single Go binary at /usr/local/bin/gitea-runner, run by systemd as the unprivileged gitea-runner user
  • Docker Engine from the Ubuntu noble archive, so the container engine receives Ubuntu security updates along with the rest of the system
  • alpine:3.22, pinned by digest and 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 deliberately configured job sandbox: 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 and 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
  • 24/7 SecureAnyCloud support

This image ships connected to nothing. It contains no registration, no runner token and no remembered instance address. The runner service does not start until you tell it where to connect.

This is deliberate. A runner image that carried a registration would give every deployment the same identity. It would claim jobs from, and report 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, organization owner, or repository administrator)
  • An SSH key pair for the azureuser account
1

Deploy from the Azure Marketplace

Search the Azure Marketplace for Gitea Runner on Ubuntu 24.04 LTS by SecureAnyCloud, 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 because it dials out to your Gitea instance. Port 22 is there only for you to administer the VM.

2

Deploy from the Azure CLI

Accept the Marketplace image terms once per subscription, then create the VM. Run these commands on your own workstation, not on the VM.

az vm image terms accept --publisher sac --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 sac1647283583153:gitea-runner-ubuntu-24-04:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard
3

Connect to your VM

SSH to the VM as azureuser:

ssh azureuser@<vm-public-ip>
4

Confirm the runner is installed and idle

The runner is installed and enabled, but it is deliberately not running. 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 is not connected yet. It is not a fault. The service has a condition on the registration file, so 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
5

Check what the network can reach

List the listening sockets:

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 exact set, rather than looking for a few unwanted ports:

sudo /usr/local/sbin/gitea-runner-port-check.sh

This check confirms that the image is connected to nothing: no registration file, no runner token and no remembered instance address.

sudo /usr/local/sbin/gitea-runner-registration-assert.sh
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 a second VM you deploy never collides with the first. Print the identity file:

sudo grep -vE '^#' /root/gitea-runner-credentials.txt

That file is 0600 root:root and holds no secret. The 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>"
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 the scope you want this runner to serve:

  • Every repository on the instance: Site Administration > Actions > Runners > Create new runner
  • One organization: Organization Settings > Actions > Runners
  • One repository: Repository Settings > Actions > Runners

Copy the token. Also note the root URL of your instance, such as https://gitea.example.com/. Do not use the URL of a repository.

8

Connect the runner to your instance

Write the instance URL and the token to /etc/gitea-runner/registration.env, then 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 spent registration token from registration.env and starts the runner. Confirm that 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. The runner now appears under Runners in your Gitea settings, with the name from Step 6 and the labels from Step 11.

To have the runner connect on its very first boot, supply the same two keys as custom data when you create the VM, in the same KEY=VALUE form. Custom data is readable by anything on the VM that can query the Azure instance metadata service. If that matters to you, leave it out at launch and write the token in afterwards.

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.

10

Prove a job cannot escape its container

A runner executes code from anyone who can push to the repositories it serves, so it matters what a job can and cannot do. This probe runs a real workflow in a real container and reports back:

sudo /usr/local/sbin/gitea-runner-job-probe.sh

The probe asserts three things by running them, not 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.
  • The job could not reach the Azure instance metadata service, which is where a VM's managed identity credentials are handed out.

Firewall rules, applied to container traffic only, enforce the last of these:

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.

To check the whole security posture in one command, run:

sudo /usr/local/sbin/gitea-runner-verify.sh
11

Choose what jobs run inside

A label maps a runs-on: value to the container image that the job runs in. The shipped set is in /etc/gitea-runner/config.yaml:

grep -A5 '^ labels:' /etc/gitea-runner/config.yaml

To change the set, edit that list and re-register the runner. Alternatively, pass GITEA_RUNNER_LABELS in registration.env before you register. Labels take the form name:docker://image.

This image refuses a label ending in :host. Upstream supports such a label, which runs workflow steps directly on the VM instead of inside a container. That would void every isolation property described in Step 10. If you genuinely need host execution, this is not the right image.

Jobs run one at a time (capacity: 1 in the config), which suits Standard_B2s. On a larger VM, raise the value to run jobs in parallel.

12

Build caching

actions/cache works out of the box. While the runner is running, it starts a cache server on port 8088. 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.

13

Move the runner to another instance

The runner refuses to re-register somewhere new silently. Otherwise your first instance would keep listing a runner that this VM had quietly stopped serving. To move the runner deliberately:

  • Delete the runner on 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.
14

Upgrade

The OS and Docker update through Ubuntu's normal unattended upgrades. This includes the container engine, because the 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, then restart:

sudo systemctl restart gitea-runner.service gitea-runner --version

Check the release notes before you upgrade 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. This 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 instead of the instance root, or Actions not enabled on the instance. The service log shows 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 must match one of this runner's labels (Step 11), and the runner must be online on 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:

sudo 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

SecureAnyCloud provides 24/7 support for this image. Support covers 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 the SecureAnyCloud website.

Deploy on Azure

Launch Gitea Runner on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure