Skip to content
Go back

Forgejo Actions: Self-Hosted GitHub-Style CI

By SumGuy 16 min read
Forgejo Actions: Self-Hosted GitHub-Style CI
Contents

Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/forgejo-actions-runners-self-hosted/

You’ve already got Forgejo running on your own iron. Your code, your server, your rules. And then you open a CI tab and… reach for GitHub Actions anyway. It’s fine, until it isn’t. Until you’re hitting rate limits, or your build needs to touch internal services, or you’d just rather not feed your pipeline config to a third party.

Forgejo has its own CI system, Forgejo Actions, and it runs YAML that looks a lot like what you already know. Most GitHub Actions workflows drop in with small changes. You keep the muscle memory, and your CI runs on hardware you own. Let’s wire it up.

Versions in this article: Forgejo 16.0.3 and Forgejo Runner 13.0.0, current as of August 2026. The runner moves fast, so check the tag before you copy a Compose file off the internet, including this one.


How Forgejo Actions Works

Forgejo Actions shares its ancestry with Gitea Actions, because Forgejo forked Gitea. The piece that runs your build is a separate program called Forgejo Runner, installed and configured on its own.

You may see it called act_runner in older guides. That is Gitea’s name for its fork. Forgejo’s binary is forgejo-runner, and the two have drifted enough that Gitea instructions will lead you wrong.

The mental model:

The runner talks to Forgejo over HTTP or HTTPS. It only makes outbound connections, so it needs no inbound ports. You can run one runner or twenty, on one box or spread across machines. Labels on the runner control which jobs it picks up, which is how you route “build on ARM” or “deploy from the trusted runner only.”

Workflow file location

Forgejo reads .forgejo/workflows/. If that directory is missing, it falls back to .github/workflows/. An imported GitHub repo therefore runs without you touching anything. Rename the directory for tidiness if you like. Nothing requires it.


What’s Familiar (and What Isn’t)

Worth setting expectations properly, because the Forgejo project is blunt about this: Forgejo Actions is designed to be familiar to GitHub Actions users, not compatible with GitHub Actions. Most of what you write carries over. The gaps are small and specific.

Works:

Doesn’t work, or needs a workaround:

For internal projects and home lab pipelines the gap is rarely a blocker. What breaks is usually GitHub-specific glue, not your actual build steps.


The Docker Socket Trap

Before the Compose file, the thing that wastes an evening.

Most guides tell you to mount /var/run/docker.sock into the runner so it can create job containers. The official runner image runs as uid 1000 and is not in the host docker group, so that mount gets you permission denied on startup. The obvious fix, adding the runner to the docker group, hands a process that executes arbitrary code from your repositories root-equivalent access to the host.

Use a Docker-in-Docker sidecar instead. Job containers live in their own daemon, the runner reaches it over TLS, and a compromised build gets a throwaway daemon rather than your machine.

docker-compose.yml
volumes:
docker_certs:
services:
docker-in-docker:
image: data.forgejo.org/oci/docker:dind
# TLS certs are only valid for the hostname `docker` or `localhost`.
hostname: docker
privileged: true
environment:
DOCKER_TLS_CERTDIR: /certs
volumes:
- docker_certs:/certs
restart: unless-stopped
runner:
image: data.forgejo.org/forgejo/runner:13.0.0
depends_on:
- docker-in-docker
user: 1000:1000
environment:
DOCKER_HOST: tcp://docker:2376
DOCKER_CERT_PATH: /certs/client
DOCKER_TLS_VERIFY: "1"
volumes:
- ./data:/data
- docker_certs:/certs
restart: unless-stopped
command: forgejo-runner --config config.yml daemon

The dind service still runs privileged. That is the cost of building containers in CI, and it is a smaller blast radius than the host daemon.

One prerequisite before docker compose up: the runner writes its config, cache and job workspaces into /data as uid 1000, so the bind mount has to be owned by it.

Terminal window
mkdir -p data/.cache
sudo chown -R 1000:1000 data
chmod 775 data/.cache && chmod g+s data/.cache

Skip that and the daemon exits on the first write.

The runner also has to pass the TLS certs through to each job container, or docker/build-push-action inside a job can’t reach the daemon. That part lives in the config file you’re about to create:

data/config.yml
runner:
envs:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /certs/client
container:
network: host
options: -v /certs/client:/certs/client
valid_volumes:
- /certs/client
docker_host: "-"

Note which block each key lives in. envs goes under runner:, and there is no container.envs in Runner 13, whatever an older guide tells you. docker_host: "-" tells the runner to leave DOCKER_HOST alone rather than auto-injecting a socket path over the value above.

network: host is the line people leave out, and the failure it causes looks like anything but a network setting. Job containers are created by the dind daemon, so they land on dind’s own bridge, where the name forgejo means nothing. actions/checkout then sits there retrying git fetch for four and a half minutes per attempt before the job dies. host puts job containers in the dind container’s network namespace, which is on your Compose network, and checkout resolves the server immediately.

Note there’s no FORGEJO__actions__ENABLED=true anywhere. Actions has been on by default since Forgejo v1.21. You only touch that setting to turn it off.


Registering the Runner

Registration gives the runner a UUID and a token, which work like a username and password. The old forgejo-runner register command still exists but is deprecated. The current flow puts the credentials in the config file.

First, generate a config file:

Terminal window
docker run --rm data.forgejo.org/forgejo/runner:13.0.0 \
forgejo-runner generate-config > data/config.yml

Then create the runner in Forgejo’s web UI. Where you create it decides what it can run:

ScopePath
Whole instance/admin/actions/runners
Organization/org/{org}/settings/actions/runners
User/user/settings/actions/runners
Single repository/{owner}/{repo}/settings/actions/runners

Click Create new runner, give it a name, and copy the UUID and Token from the dialog. Paste them into data/config.yml:

data/config.yml
server:
connections:
forgejo: # this key is just a name, pick anything
url: https://git.example.com/
uuid: 33834eef-e758-48c4-a676-1745426747aa
token: d4fe2db46a4c6bdc434a9ce3378d9a1489c1b30e

One runner can hold several connections, to different instances or to different orgs on the same instance. Add another block per connection.

If you’re deploying with Ansible or Kubernetes and a manual copy-paste step is unacceptable, use offline registration instead: generate a 40-character hex secret yourself, register it on the Forgejo host with forgejo forgejo-cli actions register --secret <secret>, and put that same secret in the config as the token. It needs admin access to the server, so it’s not an option on a hosted instance like Codeberg.


Labels: Where the Routing Lives

Labels map a workflow’s runs-on: value to a container image, and they live in the runner’s config file. Old Gitea guides put them on the registration command line, and people then hunt for them in Forgejo’s admin UI when that fails. This is the single most common thing to get wrong here.

data/config.yml
runner:
capacity: 1
labels:
- ubuntu-latest:docker://data.forgejo.org/oci/node:22-bookworm
- node22:docker://docker.io/library/node:22-alpine
- arm64:docker://docker.io/library/node:22-bookworm?platform=linux/arm64
- ci:docker://git.example.com/you/ci-runner:latest

The format is <label-name>:<label-type>://<image>. The type is one of three:

Architecture goes in a query string on the image. ?platform=linux/arm64 works. A bare linux/amd64 sitting in the label list does nothing at all.

The image has to carry the tools

The label image is the whole job environment, and two things bite here at once.

Every JavaScript action, actions/checkout included, is executed with node inside your job container. Pick docker:cli for a build job and checkout fails with exec: "node": executable file not found in $PATH. Pick node:22-bookworm and your docker build step fails with docker: command not found. Neither image is wrong. They just each carry half of what a build job needs.

For jobs that check out code and build images, use an image with both:

ci-runner.Dockerfile
FROM data.forgejo.org/oci/node:22-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
&& rm -rf /var/lib/apt/lists/*

Build it, push it to your Forgejo container registry, and point a label at it:

- ci:docker://git.example.com/you/ci-runner:latest

Ten minutes of setup that saves you from debugging command not found in a CI log at 2 AM.

A single runner can serve several runs-on: targets this way, so you rarely need more than one runner per machine.

Where multiple labels earn their keep is routing to specific hardware. Put both labels in a job’s runs-on: array and only a runner declaring both will take it:

jobs:
train:
runs-on: [docker, gpu]

That’s how you keep the model build on the box with the GPU, and how you keep community PRs on a sandbox runner that holds no credentials while internal deploys go to a trusted one.


Your First Workflow

Build a Node app, run tests, build a Docker image.

.forgejo/workflows/build.yml
name: Build and Test
on:
push:
branches:
- main
- "feature/**"
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install deps
run: npm ci
- name: Run tests
run: npm test
build-image:
runs-on: ci
needs: test
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:${{ github.sha }}

runs-on: ubuntu-latest matches the label defined above, so the test job lands in a node:22-bookworm container. The build job asks for ci instead, because it needs the docker CLI as well as node. That image is a lot leaner than GitHub’s ubuntu-latest, which carries a large toolchain preinstalled. If a step assumes some binary is present, either pick a fatter image for the label or install it in a run: step.

Secrets work identically: define them under repo Settings, reference them as ${{ secrets.MY_SECRET }}.


Caching That Actually Caches

actions/cache works because the runner runs its own cache server and passes ACTIONS_CACHE_URL into job containers. Nothing goes to the Forgejo server.

data/config.yml
cache:
enabled: true
dir: "" # empty means $HOME/.cache/actcache, which is under /data
port: 0 # 0 picks a free port
external_server: "" # point several runners here to share one cache
secret: "" # required when external_server is set
host: "" # hostname used to build ACTIONS_CACHE_URL

Two of these get misread often. dir is where cache data is written on disk, and leaving it empty does not disable anything. host is the address job containers use to reach the cache proxy, autodetected when empty; set it when the Docker daemon creating job containers sits on a different network than the runner. The field for pointing at a different cache server is external_server, and setting it stops the runner spawning its own.

For Docker layer caching, run a registry in the same stack and let BuildKit push cache to it:

.forgejo/workflows/build.yml (with layer cache)
- name: Build with cache
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=registry,ref=registry.local/myapp:buildcache
cache-to: type=registry,ref=registry.local/myapp:buildcache,mode=max

First build pays full price. Every one after is meaningfully faster.


When You’d Still Keep GitHub

Forgejo Actions is strong for closed-source internal work, home lab automation, and small teams whose whole contributor base can reach the Forgejo instance.

It gets awkward when:

For everything else, build, test, deploy, lint, scan, release, Forgejo Actions holds up. Your 2 AM “why is CI broken” energy is exactly the same either way; at least now it’s your runner’s fault.


Practical Notes Before You Go Live

Pin the runner tag. Use runner:13.0.0, not runner:13. The runner ships releases frequently and behaviour does shift between them. You don’t want a surprise on a Friday push.

Start repo-scoped. Register the runner against a single repository first, then move up to org or instance scope once you trust it. An instance-wide runner picks up jobs from every repo on the server, including any you add later.

Consider ephemeral mode. Pass --ephemeral at registration and the runner takes exactly one job before Forgejo deletes it. Use it for untrusted pull requests: no state carries between builds, so a poisoned cache or a leftover credential has nowhere to live.

The runner crash-loops for a few seconds on first boot. It starts before dind has finished generating its TLS certs, fails with could not read CA certificate "/certs/client/ca.pem", and gets restarted. Mine did it four times over three seconds and then came up clean. restart: unless-stopped is what makes that self-healing, so keep it.

Job logs go to disk, not the database. Forgejo writes them under actions_log/. If you want them somewhere else, that’s the [storage.actions_log] section of app.ini, not a key under [actions]. What you probably want to change is retention:

[actions]
LOG_RETENTION_DAYS = 365
ARTIFACT_RETENTION_DAYS = 90
LOG_COMPRESSION = zstd

Both defaults are generous. On a busy instance, dropping logs to 30 days and artifacts to 14 saves real disk.

Container images are never updated once pulled. The runner does the equivalent of docker run <image>, so a :latest label keeps running whatever it pulled the first time. Pin image digests in labels if you care about reproducible jobs.


The Pitch

If you’re already self-hosting Forgejo, not running Forgejo Actions is leaving CI on the table. You have the server. The runner is one container plus a dind sidecar. Your existing workflows run in place, and the gaps are a short list you can read in one sitting.

It will not replace GitHub if you need public open-source CI. For internal projects, home lab automation, and any team that’s already made the jump to self-hosted git, it’s the obvious next step. Set it up once, forget about it, and enjoy watching your pipelines run on the machine under your desk.

Your forklift is already there. Might as well use it for something.

Common Questions

Do I need to rename .github/workflows to .forgejo/workflows?

No. Forgejo checks .forgejo/workflows/ first and falls back to .github/workflows/ when that directory does not exist. An imported GitHub repository runs its pipelines with no changes. Rename it only if you want the directory name to reflect where the CI actually runs.

Does secrets.GITHUB_TOKEN work in Forgejo Actions?

Yes. Forgejo injects FORGEJO_TOKEN into every job and aliases the GitHub spellings to it: secrets.GITHUB_TOKEN equals secrets.FORGEJO_TOKEN, and github.token equals forgejo.token. Workflows that authenticate against the local instance with the GitHub name keep working untouched.

Can I mount the host Docker socket into the Forgejo Runner?

Not without breaking it. The official runner image runs as uid 1000 and is not in the host docker group, so the mount fails with permission denied. Adding it to that group gives a process running untrusted repository code root-equivalent host access. Use a Docker-in-Docker sidecar instead.

Why does my job hang on “Fetching the repository”?

The job container cannot reach Forgejo. With a Docker-in-Docker setup, job containers are created by the dind daemon and land on its bridge, where the Forgejo service name does not resolve. Set network: host under container: in the runner config. That puts jobs in the dind container’s network namespace, which is on your Compose network.

How much hardware does a Forgejo Runner need?

Very little for the runner itself: a couple of hundred MB of RAM and one core handles capacity: 1 fine. Your jobs set the real floor. A Node test suite is happy on 2 GB, container builds want 4 GB and fast disk. Cache and job workspaces live under /data, so budget disk there.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
REALITY: Borrowing a TLS Handshake
Next Post
LoRa Mesh Hardware Buying Guide

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts