People move their code off GitHub for all sorts of reasons — to actually own it, to stop depending on someone else’s uptime, to control what gets done with their data. (The Dutch government did exactly this in April 2026, standing up a self-hosted forge, code.overheid.nl, for its public source code — and notably picked Forgejo.) Whatever your reason, the good news is that the forge part is genuinely easy: a container, a database, a reverse proxy, and you have your own GitHub.
The bad news is the part the quickstarts gloss over, and it’s the part that actually matters. The moment your self-hosted CI runs npm install, it is executing untrusted code — on a machine you own, very possibly inside your home network. On github.com that’s Microsoft’s problem to contain. Self-hosted, it’s yours. So this is a walkthrough of standing up Forgejo, and then a much harder look at the runner — because the forge is the boring part, and the runner is the work. (The specific build below follows a writeup by Jorijn Schrijvershof; I’m pulling out the reusable pattern.)
The forge: the boring, easy part
Forgejo is a self-hosted Git forge — pull requests, issues, a GitHub-like web UI, and its own CI system, Forgejo Actions, that’s deliberately close to GitHub Actions. It’s a community fork of Gitea, fully open source under the GPL, and governed by a non-profit (Codeberg e.V.) rather than a company — which, if your reason for leaving was “I don’t want to depend on one vendor’s whims,” matters.
Deploying it is unremarkable in the best way: Forgejo, a Postgres database, and a reverse proxy, all in Docker.
services:
forgejo:
image: codeberg.org/forgejo/forgejo:latest
environment:
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
volumes:
- ./forgejo-data:/data
depends_on: [db]
db:
image: postgres:17
environment:
POSTGRES_DB: forgejo
POSTGRES_USER: forgejo
POSTGRES_PASSWORD: change-me
volumes:
- ./pg-data:/var/lib/postgresql/data
# a reverse proxy (Traefik or Caddy) terminates TLS in front of ForgejoBring it up, point a reverse proxy at it for HTTPS, and you have a working forge: push repositories, open pull requests, host your own packages. There is nothing clever here, and that’s exactly the point — Forgejo plus Postgres plus a proxy is a solved problem. If self-hosting your code were only this, everyone would do it. The reason it takes thought is what runs your pipelines.
The part that bites: CI runs untrusted code
Here’s the thing nobody warns you about. A CI runner exists to do things like npm install, pip install, composer install, go build — against lockfiles produced by your repositories and everything those lockfiles drag in. Package installs run lifecycle scripts. Builds run whatever the project’s tooling tells them to. In other words, a CI job is a machine that executes arbitrary code from your entire dependency tree, on a schedule.
That’s fine when it’s GitHub’s hardware in GitHub’s datacenter — their isolation problem, their blast radius. Run that same job on a box in your home office, naively, and a single poisoned dependency is now executing on a machine that sits on your LAN, a short hop from your router admin page, your NAS, and everything else you run at home. This isn’t hypothetical: a whole class of supply-chain attacks (the npm worms, the compromised popular packages) is specifically built to ride dependency-update bots that pull and run new code automatically, often auto-merging within the hour.
So flip the framing. The runner’s job is not to run the code. The runner’s job is to contain the code while it runs. Everything about how you build the runner should follow from that one sentence — and it’s why the runner, not the forge, is where a self-hosted setup gets serious.
Five fences around the runner
The defensible approach is depth: not one strong wall, but several overlapping ones, each assuming the one outside it might fail. Here are five layers, softest to hardest, the way the reference setup stacks them.
internet ──▶ [ nftables egress filter ] ← can reach npm/pypi, CANNOT reach your LAN
[ KVM virtual machine ] ← own kernel, not the host's
[ gVisor (runsc) runtime ] ← syscalls handled in userspace
[ job container ] ← the actual CI job
(weekly destroy-and-rebuild · scope-bound tokens — across all of it)1. A real virtual machine, not a host container. Run the runner inside its own KVM virtual machine (via Incus, libvirt, whatever you like), not as a container on the host. The whole point is that the VM has its own kernel; a Linux kernel exploit triggered inside a CI job has to break out of the VM before it can touch the actual host. A container shares the host kernel — for code you don’t trust, that’s the wrong boundary.
2. gVisor as the container runtime inside that VM. Within the VM, run job containers under gVisor (runsc) instead of the default runtime. gVisor is a sandbox that intercepts a container’s system calls in user space rather than passing them straight to the kernel, which dramatically shrinks the kernel attack surface a malicious job can reach. Now an escape has to defeat gVisor and the surrounding VM.
3. A weekly destroy-and-rebuild. Treat the runner as disposable. On a schedule — say every Monday at 02:00 — destroy the entire VM and recreate it from a freshly built base image, re-registering the runner. Nothing persistent survives longer than a week, so a quiet foothold a compromised job manages to establish has a hard expiry date, and you pick up the latest OS and kernel patches for free each cycle.
4. An egress filter that says no to your LAN. This is the cheap layer with the biggest payoff. Put an nftables rule on the runner’s network so it can reach the public internet it actually needs — package registries, your own forge over its public hostname, the ports for HTTP/HTTPS/SSH/DNS — but cannot reach private ranges: 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12. A compromised job can fetch from npm all it likes; it cannot scan your LAN, can’t reach your router’s admin interface, and can’t poke at the other services on your network. Most of the real-world damage from a runner compromise is lateral movement, and this single rule closes that door.
5. Scope-bound tokens, never admin. Register the runner with a token scoped to exactly one user or org, with no admin rights. If that token leaks, the blast radius is that one scope — it can’t register runners elsewhere and certainly can’t administer your forge.
None of these layers is exotic; every primitive is upstream and well documented. The work — and the value — is in wiring them together so that “my CI ran a bad package” stops at the runner instead of becoming “something is loose on my home network.”
That fourth layer is worth making concrete, because it’s the cheapest to get right. A minimal nftables ruleset on the runner’s network — default-drop, allow return traffic, drop the private ranges, then allow the few ports a build actually needs — is about twelve lines:
table inet runner {
chain egress {
type filter hook output priority 0; policy drop;
ct state established,related accept
ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } drop # no LAN
tcp dport { 80, 443, 22 } accept # registries, git
udp dport 53 accept # DNS
}
}The order matters: the LAN drop comes before the port allows, so a job can reach npm out on :443 but is refused the instant it aims :443 at your router. Twelve lines, and lateral movement is off the table.
Start with two fences, not five
Five layers can read like a wall of prerequisites, and if “I need gVisor and a weekly VM teardown before I’m allowed to self-host” is the thing that stops you, that’s the wrong takeaway. The layers are ranked for a reason, and the two that do the most work are also the cheapest: a VM (layer 1) and the egress filter (layer 4). Those two alone put a compromised job inside a throwaway kernel that can’t reach anything on your network — which neutralizes the genuinely scary part, lateral movement, on day one.
So a perfectly reasonable starting point is: run the runner in its own small VM, put the egress rule on it, and register it with a non-admin scoped token. (You register against the forge with forgejo-runner register --instance https://code.example.com --token <RUNNER_TOKEN> --labels ubuntu:docker://node:22; the token comes from the forge’s runner settings.) Ship that. Then add gVisor when you want a second wall around the kernel, and the weekly rebuild when you want footholds to expire. Defense in depth is something you grow into, not a prerequisite you finish before you begin. The mistake isn’t starting with two fences — it’s starting with zero.
What you give up (the honest list)
Moving off GitHub isn’t free, and a writeup that pretends otherwise isn’t worth reading. The real costs:
- Dependabot. Forgejo doesn’t have it. The drop-in answer is Renovate, self-hosted on the same runner, on a schedule — it does the same job with more configuration. Budget a day to set it up.
- GitHub Actions compatibility. Forgejo Actions aims for familiarity, not 1:1 compatibility. Most workflows run; some details bite. Workflow-level
permissions:blocks are ignored, a few popular actions need specific versions or Forgejo-hosted forks, and OpenID Connect uses a different key than GitHub’spermissions: id-token: write. None of it is a blocker, but if your pipelines lean hard on GitHub-specific features, migrating them is a project, not an evening. - A vendor to call. GitHub Enterprise gives you a support phone number and an SLA. Forgejo gives you an issue tracker and a chat room. For one person or a small team that’s fine; for a large org it may not be.
- Discovery. GitHub is where contributors find you. The usual fix is to keep the public GitHub repos as archives once you’ve migrated, each pointing at your new canonical home — so people still find you via GitHub, see the notice, and follow the link. The discovery path stays intact; you just move where the code actually lives.
You own the data now — so back it up
The flip side of “I own this” is “I’m the one who restores it when the disk dies.” On GitHub, durability was someone else’s problem; self-hosted, it’s yours, and it isn’t optional. Forgejo makes the routine part easy — forgejo dump produces a single archive of your repositories, database, and configuration — but the discipline is on you: run it on a schedule, and copy the result off the machine, because a backup that lives on the same disk as the thing it backs up is a rehearsal, not a backup.
forgejo dump -c /data/forgejo/conf/app.ini # → forgejo-dump-<timestamp>.zipPair that with a database dump and an offsite copy — another machine, object storage, anywhere not in your house — and you have a forge you can actually rebuild from scratch. Owning your code is only real if you can get it back.
When you shouldn’t do this
Self-hosting your forge is a good move for a lot of people and a bad move for others. Skip it, and reach for managed Forgejo (Codeberg for open source, or a hosted provider) or just stay put, if any of these is true:
- You have no appetite for running infrastructure. This is a thing you operate, forever. A managed forge removes most of that, but you still own the migration.
- You’re deep in GitHub-specific features — Codespaces, the Apps marketplace, Advanced Security. Forgejo is a forge, not a whole developer platform; you’d be giving those up.
- Your contributors are the GitHub social graph. If discoverability matters more to you than ownership, that’s a legitimate reason to stay where the people are.
- You don’t have a credible answer for the runner. This is the real gate. If you’re not willing to think about VM isolation, gVisor, egress rules, and rebuilds, then either run your CI on a managed runner host or stay on GitHub. A self-hosted forge with an unsandboxed runner on your home network is worse than no self-hosting at all.
The forge was never the hard part
If you take one thing from this, let it be the shape of the problem rather than the specific tools. Standing up Forgejo is an afternoon — a container, a database, a proxy, done — and it’s tempting to think that’s the whole job. It isn’t. The afternoon you spend on the forge buys you ownership of your code; the care you spend on the runner is what keeps that ownership from turning into a hole in your own network. Build the forge for the satisfaction of owning your code. Build the runner like you assume one of your dependencies is already compromised — because sooner or later, statistically, one of them will be. Get that boundary right and self-hosting is a genuine upgrade. Get it wrong and you’ve just volunteered your home network to run whatever the internet’s worst packages feel like running today.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.