Designing a Small Forgejo Actions Deployment: Trust Boundaries, Runner Placement, and Failure Modes
An architecture note for a minimal Forgejo Actions deployment: one forge, one isolated Docker runner, the trust boundaries that matter, and the failure modes to test before they surprise you.
08 Jun 2026, 05:08 UTC

If you run Forgejo and want CI, the tempting move is to install a runner on the same host as the forge and call it done. That works, but it puts arbitrary code execution one `git push` away from the machine that holds all your repositories. This note lays out the smallest design that is actually defensible: one Forgejo server, one dedicated runner host, and explicit decisions about who can reach what. It assumes a recent Forgejo release where Actions is available; the feature was experimental in earlier versions, so confirm behavior against your deployed version rather than older documentation.
Requirements worth stating first
Before picking a design, write down what you actually need, because each requirement changes the answer:
- Build and test on push and pull request for a handful of repositories.
- No inbound firewall changes for CI (the forge sits behind a reverse proxy you do not want to touch).
- Contributors you do not fully trust can open pull requests.
- Build artifacts and caches should not be able to starve the forge host of disk.
If your list includes multi-tenant isolation, Windows builds, or regulatory constraints, skip ahead to the section on conditions that change the design.
How the pieces connect
Forgejo Actions uses a GitHub-Actions-compatible syntax: YAML files under .forgejo/workflows (older setups may use .gitea/workflows) are parsed by the server when you push, and jobs are dispatched to registered runners. The important architectural fact is the direction of the connection: the runner dials out to the server and holds a persistent connection. The server never connects to the runner. That means the runner needs no inbound ports, no DNS name, and no reverse-proxy rule — it can sit behind NAT on a plain VM.
Registration uses a shared token. You generate a registration token on the server (instance-wide, per organization, or per repository, depending on scope) and feed it to the runner once at setup. After that the runner authenticates with its own credentials.
The smallest suitable design
One Forgejo server, one runner on a separate VM, using the Docker executor. The runner runs the official Forgejo runner binary as a system service; each job executes inside a container it launches on the local Docker daemon. That is the whole design. It satisfies the requirements above because:
- The runner's outbound-only connection needs no network changes.
- Docker image layers and workspaces live on the runner's disk, not the forge's.
- The blast radius of a malicious workflow is one expendable VM.
Registration on the runner host (as a user with permission to run the binary and access Docker) looks like:
forgejo-runner register \
--instance https://forge.example.com \
--token <registration-token> \
--name runner-01 \
--labels ubuntu-latest:docker://node:22-bookwormThen start it with forgejo-runner daemon under systemd. Check registration succeeded by looking at the runner list in the Forgejo admin UI — the runner should show as online within seconds. Do not treat a successful register exit code as proof; the UI status is the real check.
The trust boundary you must respect
Anyone who can push a workflow file to a repository can execute arbitrary code on the runner. That is the entire security model, and it has three consequences.
1. Isolate the runner from the forge and your internal network. Put the runner VM on a network segment that can reach the forge over HTTPS and can reach the internet for image pulls — and nothing else. No route to internal databases, no shared NFS, no cloud instance metadata with useful IAM roles.
2. Treat the Docker socket as root. A Docker-executor runner with a mounted Docker socket effectively gives every workflow author root on the runner host (mount the socket, chroot, done). This is acceptable only because the host is disposable and isolated. Do not run anything else on that VM.
3. Gate untrusted contributors. Secrets are injected into job environments by the server at dispatch time, and any job that runs for the repository can see them. A pull request from a fork that modifies the workflow can exfiltrate them. Enable the setting that requires approval before workflows run for first-time or external contributors, and review workflow changes in PRs with the same care as application code.
On runner scope: prefer organization- or repository-level runners over one global instance-level runner. If one project's workflow is compromised, a repo-scoped runner limits what an attacker can queue jobs against and which secrets are reachable.
Operational checks that earn their keep
- Runner online/offline status in the admin UI — alert on offline, because the failure is silent otherwise.
- Queue depth and job age. A job sitting in the queue with no runner picking it up is the canonical symptom of a dead runner.
- Runner disk usage. Docker images and workspace caches grow without bound. A scheduled
docker system prune -f(run on the runner host, as a user with Docker access; it deletes unused images, so expect slower first builds afterward) plus a disk-usage alert covers most cases. - Server-side log retention, so job logs do not accumulate forever.
Failure modes to expect
Silent runner disconnect. Jobs queue forever with no error surfaced to the pusher. Verify recovery deliberately: stop the runner service, push a trivial workflow, confirm the job shows as queued, restart the runner, and confirm the job drains. If your monitoring cannot see this state, it is not monitoring CI.
Disk exhaustion on the runner. Builds start failing with opaque Docker errors. Test your cleanup path by filling the disk with a large build and confirming both the alert and the prune restore capacity.
Syntax errors rejected at push. Annoying but harmless; the push succeeds and the workflow simply fails to parse. A minimal smoke workflow — checkout plus echo — is the fastest end-to-end verification after any server upgrade.
Registry rate limits. Pulling base images from Docker Hub on every job will eventually hit anonymous pull limits. A pull-through cache registry or pre-pulled base images on the runner mitigates this.
Compatibility gaps. GitHub Actions compatibility is substantial but not complete; some actions and expressions behave differently. Pin and test the specific actions you rely on rather than assuming parity.
Conditions that change the design
- Multi-tenant isolation: persistent Docker hosts are not enough. Move to ephemeral VM-based runners or a Kubernetes-backed executor so each job gets a fresh environment.
- Windows or macOS builds: add native runners per OS; the Docker executor cannot help you there.
- Regulatory or air-gap requirements: keep runners inside the same network enclave as the forge, pin action dependencies to internal mirrors, and audit workflow sources.
- High untrusted-contributor volume: consider a separate runner pool with no secrets at all for PR builds, and a trusted pool for merge-to-main jobs.
The one-server-one-runner design is not a compromise — for a small team it is the correct shape. The mistakes happen when the runner shares a host, a network, or a trust domain with something it should not. Keep it disposable, keep it isolated, and verify the failure modes before they verify you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.