Skip to content
Go back

Forgejo Actions: Self-Hosted GitHub-Style CI Without GitHub

By SumGuy 10 min read
Forgejo Actions: Self-Hosted GitHub-Style CI Without GitHub

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.

Here’s the thing: Forgejo has its own CI system — Forgejo Actions — and it runs the same YAML you already know. Most GitHub Actions workflows drop in with zero or minimal changes. You keep the .github/workflows/ muscle memory (well, .forgejo/workflows/ now), and your CI runs on hardware you own. Let’s wire it up.


How Forgejo Actions Works

Forgejo Actions is built on top of the same architecture as Gitea Actions — because Forgejo forked Gitea. The runner is a separate binary called act_runner, and it’s where the actual build execution happens.

The mental model:

act_runner talks to Forgejo over HTTP/HTTPS using a registration token. It’s stateless — you can run one runner or twenty, on the same 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 trusted runner only.”

Workflow file location

GitHub uses .github/workflows/. Forgejo uses .forgejo/workflows/. That’s the main migration step — rename the directory. The YAML syntax inside is nearly identical to GitHub Actions.


What’s Compatible (and What Isn’t)

Most of the GitHub Actions ecosystem just works. Steps that use actions/checkout, actions/setup-node, actions/cache, docker/build-push-action — all functional. Forgejo’s runner fetches actions from their original repos (or from Forgejo mirrors) so you’re not rewriting pipelines from scratch.

Works out of the box:

Doesn’t work or needs workarounds:

Honestly, for internal projects and home lab pipelines, the compatibility gap is rarely a blocker. The stuff that breaks is usually GitHub-specific glue code, not your actual build steps.


Standing Up Forgejo + Runner with Docker Compose

If you’re starting fresh or want a reproducible setup, here’s a Compose file that runs both Forgejo and act_runner together. For existing Forgejo installs, skip the forgejo service and just add the runner.

docker-compose.yml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:10
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__actions__ENABLED=true
volumes:
- forgejo_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "22:22"
restart: unless-stopped
networks:
- forgejo_net
runner:
image: code.forgejo.org/forgejo/runner:6
container_name: forgejo_runner
depends_on:
- forgejo
environment:
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
networks:
- forgejo_net
command: >
bash -c "
while ! curl -s http://forgejo:3000/api/healthz | grep -q 'pass'; do
echo 'Waiting for Forgejo...'; sleep 3;
done &&
forgejo-runner daemon --config /data/config.yml
"
volumes:
forgejo_data:
runner_data:
networks:
forgejo_net:
driver: bridge

A few things worth noting here. The runner needs Docker socket access to spawn job containers — that’s the /var/run/docker.sock mount. You’re giving it root-equivalent access to your Docker daemon, so don’t expose this setup to untrusted code without thinking it through. For internal pipelines it’s fine. For a public-facing instance taking PRs from strangers, you want a dedicated runner VM with nothing sensitive on it.

FORGEJO__actions__ENABLED=true is the env var that flips the Actions feature on in Forgejo. Without it, the UI won’t show the Actions tab and the runner will have nothing to connect to.


Registering the Runner

Registering is a one-time step. You need a token from your Forgejo instance.

Get the token:

  1. Go to your Forgejo instance → Site AdministrationRunners (or repo/org level for scoped runners)
  2. Click Create new Runner → copy the registration token

Register the runner:

Terminal window
docker exec -it forgejo_runner \
forgejo-runner register \
--no-interactive \
--token YOUR_TOKEN_HERE \
--name "my-runner" \
--instance http://forgejo:3000 \
--labels "ubuntu-latest:docker://node:20,linux/amd64"

The --labels flag is where things get interesting. Each label maps a workflow runs-on: value to a container image. When your workflow says runs-on: ubuntu-latest, the runner sees the ubuntu-latest label and spins up a node:20 container for that job.

You can define multiple labels to support different environments:

Terminal window
--labels "ubuntu-latest:docker://ubuntu:24.04,ubuntu-22.04:docker://ubuntu:22.04,node20:docker://node:20-alpine"

This way a single runner handles multiple runs-on: targets without spinning up multiple runner instances.

After registration, a config.yml lands in /data inside the runner container. You can edit it directly if you need to tune concurrent job count, logging, or cache settings.


Your First Workflow

Here’s a realistic 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@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- 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: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:${{ github.sha }}

Notice actions/cache@v4 — this works because act_runner implements the Actions cache protocol. Your npm cache persists between runs on the same runner. Not as fast as GitHub’s global cache network, but for a home lab or small team it’s plenty.

Secrets work the same way — define them in Forgejo (repo → Settings → Secrets and Variables → Actions), reference them as ${{ secrets.MY_SECRET }} in the workflow. No changes to the syntax.


Persistent Cache and Build Optimization

act_runner stores caches locally on the runner host by default. For a single-runner setup that’s fine — the cache directory lives in the runner’s data volume and survives container restarts.

For multi-runner setups you’ll want a shared cache backend. The runner supports an external cache server via the cache section in config.yml:

config.yml (runner)
runner:
labels:
- "ubuntu-latest:docker://ubuntu:24.04"
cache:
enabled: true
dir: "" # leave empty to use built-in cache server
host: "" # set to external cache host if you have one
port: 0

The built-in cache server is fine for a single runner. If you’re running multiple runners hitting the same workloads, a shared object store (MinIO, or even a plain HTTP server) behind the host/port settings gives you cross-runner cache hits.

For Docker layer caching inside docker/build-push-action, use a local registry:

.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=your-registry.local/myapp:buildcache
cache-to: type=registry,ref=your-registry.local/myapp:buildcache,mode=max

Pair this with a self-hosted registry container in your Compose stack and your Docker builds get meaningfully faster after the first run.


Label-Based Runner Routing

Labels are how you get intentional about which runner handles which job. A few practical patterns:

Trusted vs. untrusted builds — Register two runners with different labels. Internal services build on trusted-runner, community PRs build on sandbox-runner (a throwaway VM with no credentials).

jobs:
deploy:
runs-on: trusted-runner # only the runner with this label picks it up

Architecture routing — Got an ARM box? Register a runner on it with arm64 label, use runs-on: arm64 for cross-platform builds.

Resource tiersfast-runner for quick unit tests, heavy-runner (the machine with the big RAM) for integration tests or model builds.

Labels are set at registration time and can be updated in Forgejo’s admin UI without re-registering the runner.


When You’d Still Keep GitHub

Let’s be honest about the limits. Forgejo Actions is excellent for closed-source internal work, home lab automation, and small team projects where your whole contributor base can reach your 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

Enable Actions in app.ini for source installs — if you’re not using the Docker env var, add this to your Forgejo config:

[actions]
ENABLED = true

Runner version pinning — pin your runner image version in Compose (runner:6.0.1 not runner:6). Runner updates occasionally change behavior; you don’t want a surprise on a Friday push.

Runner scope — runners can be instance-wide (admin-registered), org-scoped, or repo-scoped. Start repo-scoped for a single project, move to org-scoped when you have multiple repos that need CI.

Firewall the runner — the runner calls out to Forgejo, not the other way around. No inbound ports needed on the runner host. The Docker socket mount is the attack surface to protect.

Log retention — Forgejo stores job logs in the database by default. For a busy instance, configure log storage to go to the filesystem instead of the DB:

[actions]
LOG_STORAGE_TYPE = local

This is the kind of thing you discover at 3 GB of SQLite database and wish you’d set up earlier.


The Pitch

If you’re already self-hosting Forgejo, not running Forgejo Actions is leaving CI on the table. You already have the server. The runner is a ~50 MB container. Your existing GitHub Actions YAML needs a directory rename and maybe five minutes of tweaking. The result is a complete CI loop that never touches GitHub’s infrastructure, runs faster on local hardware (no queue time), and integrates cleanly with internal services your pipelines need to reach.

It’s not a replacement for GitHub if you need public open-source CI. But for internal projects, home lab automation, and any team that’s 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.


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.


Next Post
iperf3 + nload: Network Diagnosis

Discussion

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

Related Posts