Gitea Actions runners: register gitea-runner (the renamed act_runner) on a VM, Docker or Kubernetes

Gitea Actions does not execute jobs on the Gitea server. You run a separate program, the Gitea Runner (called act_runner until the May 2026 rename), register it with a token from your instance, organization or repository, and map its labels to Docker images or to the host. This guide covers runner 4.x against Gitea 1.21 or later.

How Gitea Actions runners work

A Gitea runner polls your Gitea instance for pending jobs, runs each job in a container (or on the host), streams logs back and reports the result. The runner only makes outbound connections, so it can sit behind NAT on a home server or in a private subnet. Workflow files live in .gitea/workflows/ and use GitHub Actions syntax.

The project changed names in 2026. Release 1.0.0 (May 2026) renamed act_runner to Gitea Runner: the binary became gitea-runner, the image moved from gitea/act_runner to gitea/runner and downloads moved to dl.gitea.com/gitea-runner/. Version 3.0.0 (July 2026) switched on the v2 cache service, so stock actions/upload-artifact@v4 and actions/cache work. The current release is 4.1.0 (October 2026), which adds a Kubernetes backend. Old act_runner blog posts still apply in spirit; swap the binary and image names.

Self-hosting makes sense once you need more CPU than a shared box gives you, arm64 builds, GPUs, or access to private networks. On a self-hosted Gitea instance you need at least one runner for any CI, because Gitea ships no hosted runners.

Prerequisites and enabling Actions

  • Gitea 1.21 or later. Actions is on by default since 1.21.0. If an admin turned it off, set [actions] ENABLED = true in app.ini and restart.
  • A Linux host (VM or bare metal) with Docker for container jobs. Host jobs need Git on the machine.
  • Network access from the runner to the Gitea ROOT_URL. Use the public URL when Gitea and the runner run in different containers; localhost points at the runner's own container.
  • Per repository: Settings, then enable Enable Repository Actions.
[actions]
ENABLED = true

Getting a registration token

Gitea issues registration tokens at three scopes. The scope decides which repositories the runner serves:

ScopePageServes
Instance/-/admin/actions/runnersEvery repository on the instance
Organization/<org>/settings/actions/runnersRepositories of that org
Repository/<owner>/<repo>/settings/actions/runnersOne repository

Click Create new Runner to see the token. It stays valid until you reset it in the UI or through the API, so treat it like a password and rotate it if it leaks.

Install and register on a Linux VM

Download the binary for your architecture from dl.gitea.com/gitea-runner or the release page, then create a service user:

sudo install -m 0755 gitea-runner-4.1.0-linux-amd64 /usr/local/bin/gitea-runner
sudo useradd --system --create-home --home-dir /var/lib/gitea-runner gitea-runner
sudo usermod -aG docker gitea-runner
gitea-runner --version

Register as the service user without prompts. The command writes a .runner file holding the runner's identity and API credentials:

cd /var/lib/gitea-runner
sudo -u gitea-runner gitea-runner register --no-interactive \
  --instance https://gitea.example.com \
  --token <registration_token> \
  --name build-01 \
  --labels "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"

Other register flags: --token-file reads the token from a file, and --ephemeral registers a single-job runner. Leave out --no-interactive and the runner prompts for the instance URL, token, name and labels.

Do not copy .runner to a second machine and do not edit it by hand. Since 3.0.0 the runner holds an advisory lock on it, so two daemons cannot share one file. If you lose it, delete the runner in the UI and register again.

The Gitea docs ship a systemd unit. A trimmed version:

# /etc/systemd/system/gitea-runner.service
[Unit]
Description=Gitea Runner
After=docker.service

[Service]
Type=simple
User=gitea-runner
Group=gitea-runner
WorkingDirectory=/var/lib/gitea-runner
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
Restart=on-failure
RestartSec=5
TimeoutStopSec=3h

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner

Generate and edit config.yaml

The runner works without a config file, but you will want one for capacity, labels and container options. In 4.x, config init writes an empty config.yaml and config generate prints the commented example with every option. The older generate-config subcommand still runs but prints a deprecation notice.

sudo mkdir -p /etc/gitea-runner
gitea-runner config generate | sudo tee /etc/gitea-runner/config.yaml.example > /dev/null
sudo gitea-runner -c /etc/gitea-runner/config.yaml config init
sudo gitea-runner -c /etc/gitea-runner/config.yaml config set runner.capacity 2

A minimal file looks like this:

runner:
  file: .runner
  capacity: 2
  timeout: 3h
  labels:
    - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
    - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
container:
  network: ""
  privileged: false
  valid_volumes: []
  docker_host: ""
host:
  workdir_parent:

If runner.labels is set in the YAML file, register ignores --labels. At daemon start the most explicit source wins: --labels or GITEA_RUNNER_LABELS, then runner.labels, then the labels stored in .runner.

Labels and runs-on

A label has the form <name>[:<schema>[:<args>]]. The schema is docker, host, or (from 4.1.0) kubernetes:

  • ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest runs the job in a container from that image.
  • linux_amd64:host runs the steps on the machine itself, with whatever tools it has installed.
  • ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest runs the job as a pod.

A workflow targets the runner through runs-on. Gitea matches it against label names and the first match picks the environment:

# .gitea/workflows/ci.yaml
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test

The gitea/runner-images repository publishes the default images. Pin images by tag or digest, and give host labels distinct names (linux_amd64 instead of ubuntu-latest) so nobody lands on an unsandboxed host by accident.

Run the runner in Docker or docker compose

The gitea/runner image comes in three flavours: 4 (no Docker daemon, you mount the host socket), 4-dind (bundled daemon, needs --privileged) and 4-dind-rootless (bundled rootless daemon as UID 1000, also needs --privileged). The runner README now recommends the dind flavour, because a mounted host socket gives every job root-equivalent access to the host.

docker run -d --name gitea-runner --privileged \
  -e GITEA_INSTANCE_URL=https://gitea.example.com \
  -e GITEA_RUNNER_REGISTRATION_TOKEN=<registration_token> \
  -e GITEA_RUNNER_NAME=build-01 \
  -v "$PWD/data:/data" \
  -v runner-docker:/var/lib/docker \
  docker.io/gitea/runner:4-dind

The entrypoint registers on first start and stores .runner under /data. Other variables it reads: GITEA_RUNNER_LABELS, GITEA_RUNNER_EPHEMERAL, GITEA_RUNNER_ONCE, GITEA_RUNNER_REGISTRATION_TOKEN_FILE and CONFIG_FILE. A compose file with your own config:

services:
  runner:
    image: docker.io/gitea/runner:4-dind
    privileged: true
    restart: always
    environment:
      CONFIG_FILE: /config.yaml
      GITEA_INSTANCE_URL: "${INSTANCE_URL}"
      GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
      GITEA_RUNNER_NAME: "${RUNNER_NAME}"
    volumes:
      - ./config.yaml:/config.yaml
      - ./data:/data
      - runner-docker:/var/lib/docker
volumes:
  runner-docker:

Docker-in-Docker caveats

  • Jobs that run docker build need a daemon. With the dind image they get the bundled one; with the plain image and a mounted socket they get the host daemon, which means any workflow author can control every container on that host.
  • container.privileged: true in config.yaml makes job containers privileged. You only need it if jobs themselves start a nested daemon. Since 3.0.0 the runner strips options such as --cap-add, --pid and --security-opt from a workflow's container.options unless the runner runs privileged.
  • With container.network empty, each concurrent job takes a subnet from the daemon's address pool. A high capacity can exhaust it; widen default-address-pools in the daemon config.
  • In 4.0.0, container.network: bridge stops jobs from reaching the runner's cache. Leave it empty or use a user-defined network.

Run on Kubernetes

You have two official paths. The gitea/helm-actions chart and the manifests in the runner repo's examples/kubernetes run the runner with a privileged dind sidecar (dind-docker.yaml), as a StatefulSet (statefulset-dind.yaml, recommended past one replica) or with a rootless daemon (rootless-docker.yaml, needs fsGroup: 1000). The examples read the token from a Secret.

Runner 4.1.0 adds a native backend: a kubernetes:// label runs each job as a pod without any Docker daemon. The runner needs RBAC for pods, pods/exec, pods/log and secrets, the cluster must run Kubernetes 1.30 or later, and job images need sh, tar, tail and env. Docker actions and docker:// steps fail on this backend, so keep a dind runner for those.

runner:
  labels:
    - "ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest"
kubernetes:
  namespace: ci
  pod_template:
    spec:
      containers:
        - name: job
          resources:
            limits:
              memory: 4Gi

Gitea Enterprise (23.8.0 and later) also offers an Actions Runner Controller operator with scale sets. It is not part of the open-source release.

Ephemeral runners and autoscaling

Gitea 1.24 added ephemeral runners. An ephemeral runner accepts one job; Gitea revokes its credentials as soon as it assigns the job, so code inside the job cannot use the token to grab further work. Register with --ephemeral, or set GITEA_RUNNER_EPHEMERAL=1 in the container:

gitea-runner register --no-interactive --ephemeral \
  --instance https://gitea.example.com --token <registration_token> \
  --labels "linux_amd64:host"
gitea-runner daemon

daemon --once is a weaker variant: the runner exits after one job but keeps a reusable registration. Open-source Gitea has no built-in autoscaler. Teams build one from a script or controller that watches the job queue through the API, boots a VM or pod, registers it as ephemeral and deletes it after exit.

Security hardening

  • Register at the narrowest scope that works. A repository runner cannot pick up jobs from other repositories.
  • Prefer the dind or Kubernetes backend over a mounted host socket and over host labels. A host job runs as the runner user and can read .runner.
  • Keep valid_volumes empty unless you need it. Any listed volume is readable and writable by every job. In 4.0.0, * no longer matches across /; use ** for nested paths.
  • Use ephemeral VMs for public repositories that accept pull requests from strangers.
  • Set runner.timeout below the 3-hour default if your jobs finish in minutes.

Troubleshooting

Job stays in "Waiting" forever

No online runner carries a label matching runs-on, or you registered the runner at a scope that does not include the repository. Compare the labels shown in the runner list with the workflow.

Registration fails with connection refused

The instance URL points at localhost from inside a container. Use the Gitea ROOT_URL.

"Cannot connect to the Docker daemon" inside a step

The job container has no daemon. Switch to the 4-dind image, or set container.docker_host to a daemon you accept sharing with jobs.

Jobs cancel each other after an upgrade to 3.x

Two processes share one .runner file. Give each daemon its own registration.

Cache or artifact uploads time out

Jobs cannot reach the runner's cache server. Check container.network, and set cache.host and cache.port when jobs run on a remote DOCKER_HOST.

Cost trade-offs

A single 4 vCPU VM with capacity: 2 covers a small team, and the bill is the VM. The hidden cost sits elsewhere: patching the host, pruning images and volumes, rebuilding after a job fills the disk, and keeping capacity for the morning rush while the machine idles at night. Ephemeral VMs fix the isolation problem but require a provisioning loop you write and maintain.

FAQ

Is act_runner the same as gitea-runner?

Yes. Gitea renamed act_runner to Gitea Runner in release 1.0.0. Replace act_runner with gitea-runner in scripts and gitea/act_runner with gitea/runner in image references.

Can one Gitea runner serve several repositories?

Yes. Register it with an organization or instance token instead of a repository token.

Does Gitea Actions support .github/workflows?

Gitea reads workflows from .gitea/workflows/. Repositories mirrored from GitHub often keep .github/workflows/; check your Gitea version's documentation before you rely on it.

How do I run Gitea Actions on arm64?

Install the arm64 binary or image on an arm64 host and give it its own label, for example ubuntu-arm64:docker://..., then target it with runs-on: ubuntu-arm64.

Running Forgejo instead? See the Forgejo runner guide. Comparing with GitHub? See self-hosted GitHub Actions runners.

Managed runners with cirunner.dev

cirunner.dev provisions a fresh VM per job, registers it with your CI using a token you provide, scales with the queue and destroys the machine after the job. You choose CPU, RAM, x86_64 or arm64, region, base image and an optional GPU, and pay per compute minute.

The service is in early access with GitHub Actions and GitLab CI at launch. Gitea Actions is on the roadmap; vote for it on the early-access form.

Skip the runner fleet

cirunner.dev boots a fresh VM for every Gitea Actions job, registers it, and destroys it when the job ends. Gitea Actions is on our roadmap. Vote for it on the early-access form.

Join early access