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:
- Forgejo server — watches for commits/PRs/events, queues jobs
- act_runner — polls the server, picks up jobs, executes them using nektos/act under the hood
- Docker (or LXC) — each job step runs in a container; act_runner manages pulling images and running steps
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:
actions/checkout@v4actions/cache@v4actions/setup-node,setup-python,setup-godocker/build-push-action- Custom shell steps (
run:blocks) - Matrix builds
- Job dependencies (
needs:) - Secrets and environment variables
Doesn’t work or needs workarounds:
- Actions that call the GitHub API directly (obviously)
github.com-specific context variables likegithub.tokenfor repo access (use Forgejo secrets instead)- The GitHub Marketplace — but catalog.forgejo.org has a growing mirror
- GHES-specific actions that hardcode GitHub Enterprise endpoints
- Hosted runners with beefy specs (128 GB RAM, fast SSD) — you’re on your own hardware now
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.
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: bridgeA 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:
- Go to your Forgejo instance → Site Administration → Runners (or repo/org level for scoped runners)
- Click Create new Runner → copy the registration token
Register the runner:
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:
--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.
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:
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: 0The 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:
- 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=maxPair 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 upArchitecture routing — Got an ARM box? Register a runner on it with arm64 label, use runs-on: arm64 for cross-platform builds.
Resource tiers — fast-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:
- You’re open source and want community PRs. Contributors can’t access your private Forgejo instance to see CI results. GitHub’s public runner visibility is genuinely hard to replicate without a public-facing Forgejo.
- You need the full Marketplace. The Forgejo catalog covers a lot, but niche deployment actions (specific cloud providers, specialized test frameworks) may not be mirrored yet. You’ll write more custom shell steps.
- Your build requires hosted runner specs. GitHub’s
ubuntu-latestrunners have decent hardware. If you need 64 vCPUs for a compile job and your NAS is the most powerful machine you own, you’re going to notice. - GitHub-specific integrations. Codecov, some security scanners, dependency graph features — they’re wired to github.com. You can work around most of them, but it’s friction.
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 = trueRunner 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 = localThis 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.