Forgejo Actions runners: install, register and secure forgejo-runner

Forgejo Actions needs a separate program, Forgejo Runner (forgejo-runner), to execute jobs. You create the runner in the Forgejo UI (or offline with forgejo-cli), paste the UUID and token into the runner's YAML config, choose labels that map to Docker, Podman, LXC or the bare host, and start forgejo-runner daemon. This guide uses Forgejo Runner 13.x, the current major line (13.2.0 shipped in September 2026).

What Forgejo Runner does

Forgejo schedules jobs from .forgejo/workflows/ but never runs them. A runner polls Forgejo over HTTPS, takes a job whose runs-on matches one of its labels, runs the steps in a container or on the host, and uploads logs and status. All traffic starts from the runner, so a machine behind NAT works fine. One runner can hold connections to several Forgejo instances or scopes at once.

You self-host runners because Forgejo ships none, and because you want control over hardware (arm64, GPUs, large RAM), network reach into private systems, or build caches on fast local disks. If your code lives on Codeberg, the Codeberg CI guide covers the hosted options and how to attach this same runner there.

Prerequisites

  • Forgejo v1.21 or later. Actions is on by default since v1.21; an admin can switch it off with [actions] ENABLED = false in app.ini.
  • Actions enabled per repository: Settings, Units, then tick Actions.
  • A Linux host with Git (tested from 2.24.3) and one container engine: Docker, Podman, or LXC on Debian bookworm.
  • Outbound HTTPS from the runner to Forgejo.

Install the binary on Linux

The Forgejo docs fetch the latest release, download the matching binary and verify its signature:

export ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
export RUNNER_VERSION=$(curl -X 'GET' https://data.forgejo.org/api/v1/repos/forgejo/runner/releases/latest | jq .name -r | cut -c 2-)
export FORGEJO_URL="https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-${ARCH}"
wget -O forgejo-runner ${FORGEJO_URL}
chmod +x forgejo-runner
wget -O forgejo-runner.asc ${FORGEJO_URL}.asc
gpg --keyserver hkps://keys.openpgp.org --recv EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-runner.asc forgejo-runner
sudo cp forgejo-runner /usr/local/bin/forgejo-runner
forgejo-runner -v

Create an unprivileged user. Add it to the docker group only if you use Docker; with Podman, enable a user socket instead:

sudo useradd --create-home runner
sudo usermod -aG docker runner                           # Docker only
sudo systemctl --user -M runner@ enable --now podman.socket   # Podman only
sudo loginctl enable-linger runner                       # Podman only

Generate the config as the runner user. Forgejo Runner has no default config path; you pass -c every time:

sudo -u runner forgejo-runner generate-config > /home/runner/runner-config.yml

For Podman, point container.docker_host at the socket that systemctl --user -M runner@ status podman.socket prints, for example unix:///run/user/1004/podman/podman.sock.

Register the runner

Forgejo needs a UUID and token for each runner connection. You get them one of three ways: the web UI (recommended), the HTTP API, or offline registration with forgejo-cli.

Interactive registration in the UI

Open the runner page for the scope you want and click Create new runner:

ScopePage
Instance (all repositories)/admin/actions/runners
Organization/org/{org}/settings/actions/runners
User (all your repositories)/user/settings/actions/runners
Repository/{owner}/{repository}/settings/actions/runners

Forgejo shows a UUID and a token once. Paste them into the server section of runner-config.yml:

server:
  connections:
    forgejo:
      url: https://forgejo.example.com/
      uuid: 33834eef-e758-48c4-a676-1745426747aa
      token: d4fe2db46a4c6bdc434a9ce3378d9a1489c1b30e

Repeat the process to add more connections under different keys. Each connection can carry its own labels and fetch_interval, and token_url: file:$CREDENTIALS_DIRECTORY/token.txt loads the token from a systemd credential file instead of inline.

Offline registration with a shared secret

For Ansible or Kubernetes deployments where you control the Forgejo server, generate a 40-character hex secret and register it on the Forgejo machine. The first 16 characters identify the runner, the remaining 24 form the secret:

openssl rand -hex 20
forgejo forgejo-cli actions register --name runner-01 --scope myorganization \
  --secret 7c31591e8b67225a116d4a4519ea8e507e08f71f

The command prints the UUID. Put the URL, that UUID and the 40-character secret (as token) into server.connections. Running the command again with the same first 16 characters rotates the secret. This method needs admin shell access to Forgejo, so it does not work on hosted services such as Codeberg.

The older forgejo-runner register and forgejo-runner create-runner-file --secret commands still exist in 13.x, and the runner marks them deprecated. They write a .runner file. New setups should use server.connections.

Start the daemon, then install the upstream forgejo-runner.service unit to /etc/systemd/system/:

sudo -u runner forgejo-runner daemon -c /home/runner/runner-config.yml
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.service
journalctl -u forgejo-runner.service -f

Labels and backends: docker, lxc, host

Labels go under runner.labels (or per connection) and follow <label-name>:<label-type>://<default-image>:

runner:
  capacity: 1
  timeout: 3h
  labels:
    - debian:docker://docker.io/library/node:lts
    - ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04
    - bookworm:lxc://debian:bookworm
    - self-hosted:host
  • docker runs every step as root inside a container from the image, through Docker or Podman. The runner does the equivalent of docker run, so it never refreshes a cached tag; pin digests or set container.force_pull. Actions such as actions/checkout need Node.js in the image.
  • lxc creates a system container from a template and release (default debian:bullseye) with Node.js 20 installed. The runner needs passwordless sudo for lxc-*; the lxc-helpers scripts set this up. bookworm:lxc://debian:bookworm:lxc docker allows nested LXC and Docker.
  • host runs steps in a shell forked from the runner, with no isolation.

A workflow selects runners with runs-on. A list requires every label, and the first label decides the backend:

# .forgejo/workflows/test.yml
on: [push, pull_request]
jobs:
  test:
    runs-on: [docker, gpu]
    steps:
      - uses: actions/checkout@v4
      - run: make test

Run the OCI image with docker compose

The image is data.forgejo.org/forgejo/runner:13 and runs as UID 1000. The documented compose setup pairs it with a separate docker:dind daemon so jobs never touch the host's Docker:

services:
  docker-in-docker:
    image: docker:dind
    privileged: true
    command: ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false']
    restart: unless-stopped

  runner:
    image: data.forgejo.org/forgejo/runner:13
    depends_on:
      - docker-in-docker
    environment:
      DOCKER_HOST: tcp://docker-in-docker:2375
    user: 1001:1001
    volumes:
      - ./data:/data
    restart: unless-stopped
    command: forgejo-runner daemon --config runner-config.yml
mkdir -p data/.cache && sudo chown -R 1001:1001 data
docker run --rm data.forgejo.org/forgejo/runner:13 forgejo-runner generate-config > data/runner-config.yml
# add labels and server.connections, then:
docker compose up -d

The plain-TCP dind daemon has no TLS, so keep it on the compose network and never publish port 2375.

Kubernetes

Forgejo has no Helm chart of its own. The runner repository ships examples/kubernetes/dind-docker.yaml: a Deployment with a privileged dind sidecar, registered through offline registration and a Kubernetes Secret, so you can raise the replica count. The privileged pod is a known escape risk; run it on dedicated nodes.

Ephemeral mode and on-demand runners

An ephemeral runner receives at most one job; Forgejo deletes it afterwards and enforces this on the server side. Enable it with --ephemeral on forgejo forgejo-cli actions register (or the API's ephemeral property). Ephemeral runners must use forgejo-runner one-job; daemon exits as soon as Forgejo switches it to ephemeral mode.

forgejo-runner one-job -c runner-config.yml --wait

Forgejo does not autoscale runners. The examples/on-demand directory in the runner repo shows a Bash loop that uses the Forgejo 13 API to create runners for waiting jobs (needs runner 11.3.0 or later). Its README lists the open problems: dynamic runners pile up, a job on a host runner can read its token, and an ephemeral runner that never gets a job keeps polling forever.

Security notes from the Forgejo docs

Forgejo's security page starts from the premise that a runner exists to execute remote code. Its main recommendations:

  • Register at the narrowest scope. A repository runner only runs that repository's workflows.
  • Anyone with push access can add a branch with an on: push workflow; branch protection does not stop that.
  • A fork's first pull request needs approval before workflows run; later ones run without it. pull_request runs get no repository secrets. Treat pull_request_target with care, because it has secrets.
  • Leave container.privileged at false. With true, a workflow author gets root on the runner host.
  • Keep container.network empty for a per-job network. host exposes services on 127.0.0.1.
  • Leave valid_volumes empty, and cap resources with container.options such as --memory=1g --cpus=2.
  • Log Docker out of private registries the runner should not pull from.
  • LXC protects the host from accidental damage; Forgejo does not consider it safe against malicious jobs. host mode exposes the runner's state file to jobs.
  • Put runners on an isolated subnet and subscribe to forgejo/security-announcements.

Troubleshooting

"Cannot connect to the Docker daemon at unix:///var/run/docker.sock"

The job container has no Docker access. Follow Forgejo's "Utilizing Docker within Actions" page: give jobs a dind daemon instead of the host socket.

Runner starts but never picks up jobs

Check that the workflow's runs-on matches a label, that the repository has Actions turned on under Units, and that the connection's scope covers the repository.

Old image used after a tag update

The runner does not re-pull tags. Pin a digest or set container.force_pull: true.

daemon exits right after start

You registered the runner as ephemeral. Use forgejo-runner one-job.

IPv6 fails inside jobs

Set container.enable_ipv6: true and configure IPv6 in /etc/docker/daemon.json. Rootless Podman needs 5.3 or later.

Cost trade-offs

A persistent runner on a spare VM costs the VM plus your time: disk cleanup, image updates, OS patching and the occasional runaway job that holds the slot for the full 3-hour default timeout. Ephemeral VMs give you clean isolation but require the provisioning loop above, and you pay for boot time on every job. For a few repositories, one well-hardened runner with capacity: 2 is often enough.

FAQ

Is forgejo-runner compatible with Gitea?

Forgejo Runner targets Forgejo. Gitea has its own runner; see the Gitea Actions runner guide.

Is forgejo-runner register deprecated?

Yes. It still works in 13.x, but the documented flow puts the UUID and token from the UI into server.connections.

Can one Forgejo runner connect to several instances?

Yes. Add one entry per instance or scope under server.connections.

Can I run Forgejo Actions jobs without Docker?

Yes, with LXC labels or host labels, or with Podman as a drop-in for Docker.

Using Woodpecker next to Forgejo? See Woodpecker CI agents.

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. Forgejo 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 Forgejo Actions job, registers it, and destroys it when the job ends. Forgejo Actions is on our roadmap. Vote for it on the early-access form.

Join early access