Forgejo runners on MicroVMs on your local machine
  • Shell 57.3%
  • Nix 35.4%
  • Just 7.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-02 06:47:08 +01:00
examples feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
guest feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
nix feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
scripts feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
src feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
.envrc feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
.gitignore feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
flake.lock feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
flake.nix feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
justfile feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00
README.md feat: local ephemeral Forgejo runner in a microVM 2026-11-02 06:47:08 +01:00

forgejo-local-runner

Run one Forgejo Actions job on this machine, in a disposable NixOS microVM, without putting a registry or release credential in a file on the host.

The tool is a push-mode local runner:

  1. It asks Forgejo for a new ephemeral, repo-scoped runner.
  2. It boots a NixOS microVM with that runner's UUID and token.
  3. It dispatches a workflow that targets the runner's label.
  4. The VM runs exactly one job, writes the exit status back through a 9p share, and powers itself off.
  5. The tool deletes the runner if Forgejo has not already done so, and removes the run directory.

The only credential on the host is the user's existing fj login (or FORGEJO_TOKEN). The registry and release secrets stay in Forgejo and are injected into the job by the runner for the duration of that job.

What is in here

Path Purpose
nix/microvm.nix defines the x86_64-linux guest
guest/configuration.nix the NixOS system, tools, volumes and runner service
guest/run-job.sh the one-shot forgejo-runner one-job --wait wrapper
nix/package.nix the forgejo-local-runner command, with the guest baked in
src/forgejo-local-runner.sh the host-side API and VM lifecycle
scripts/register-fj-client.sh one-time OAuth2 app registration for fj

The guest adapts the toolset from git.shine.town/infra/runners. That repository builds a container rootfs, not a VM image, so it cannot be booted directly; a full NixOS guest is the adaptation it would need for a microVM.

Requirements

  • x86_64-linux with KVM. The cluster is amd64, so the guest is amd64 too.
  • Nix with flakes.
  • A Forgejo account that can create a runner on the target repository. The user needs repo write/admin; the wrapper creates a repo-level runner, not an instance-level one.
  • The target repository must contain a workflow with a single job on runs-on: local, for example qr-app/.forgejo/workflows/release-local.yml.

The guest is x86_64-linux and uses QEMU, so the first supported host is x86_64-linux. The flake also evaluates on aarch64-linux and aarch64-darwin for the devshell and wrapper, but running this guest there is a follow-up: on Apple Silicon, microvm.nix would use vfkit with an aarch64 guest, and qr images would still need an x86_64-linux builder. Evaluability is not runtime support.

Authentication

The tool uses fj's token store. To log in through Keycloak, Forgejo first needs a public OAuth2 application for fj:

  1. Create a personal access token with write:user on https://git.shine.town/user/settings/applications. Create it as the user the runner should act as (normally sshine, not the admin account).

  2. Run:

    export FORGEJO_TOKEN=<the token>
    just auth-client
    

    This creates the OAuth2 application forgejo-local-runner with redirect URI http://127.0.0.1:26218/, and writes its client ID to ~/.config/forgejo-cli/client_ids. The script is idempotent.

  3. Log in. Run this inside a git repository, because fj uses the repository to choose the host:

    just login
    # or: fj auth login --host https://git.shine.town
    

    The browser goes to Forgejo, which delegates the actual login to Keycloak. fj stores the OAuth token and refresh token in ~/.local/share/forgejo-cli/keys.json (mode 0600).

If you would rather not use the browser flow, fj auth add-token with a PAT also works; the wrapper reads either kind of login. You can also skip fj entirely with FORGEJO_TOKEN=....

Usage

nix run .# -- run \
  --repo sshine/qr \
  --workflow release-local.yml \
  --ref main \
  --input tag=v0.4.0

Useful options:

--host URL              Forgejo base URL (default https://git.shine.town)
--label LABEL           runner label (default local)
--timeout DURATION      wall clock limit (default 3h)
--keep                  keep the run directory and logs
--dry-run               print what would happen; create nothing

The repository must be OWNER/NAME. There is deliberately no default repository: the tool should not guess which project it is releasing.

First test

Copy examples/smoke-local.yml into the repository (qr-app already has .forgejo/workflows/smoke-local.yml), commit and push it, then run:

nix run .# -- run \
  --repo sshine/qr \
  --workflow smoke-local.yml

This proves the runner registration, the microVM boot, the Nix store and the job execution path without pushing an image or creating a release. Once it is green, dispatch release-local.yml with the real tag.

Logs land in /var/tmp/forgejo-local-runner/run/ while the run is active. On failure the directory is kept and the wrapper prints the tail of job.log. --keep keeps it on success as well.

The guest

  • QEMU user networking, one eth0.
  • Read-only erofs base store plus persistent block volumes for the writable store overlay, the Nix database, and the runner work directory.
  • local:host label, host-mode steps. The VM is the isolation boundary; the job has the guest's Nix store and tools, not the host's filesystem.
  • Nix sandbox is on. The guest is a real VM, so the sandbox does not need the privileged-container workaround the in-cluster runner uses.
  • as-runner is available for steps that refuse to run as root, matching the container runner's escape hatch.

The first run is cold: the guest boots, fetches the qr flake, and populates the writable store. Later runs reuse /var/tmp/forgejo-local-runner/*.img.

To throw the store and work volume away:

just reset

Why a microVM and not a host runner

A host runner with no container runs arbitrary repository code with the user's files and SSH keys. The microVM keeps the job in a separate kernel and filesystem, while still letting it build with the local CPU and a persistent Nix store. The trade is a little boot time and a fixed guest size (4 vCPU, 8 GiB by default; edit guest/configuration.nix and rebuild).

CI-triggered runs

This version is push-only. A CI pipeline cannot wake a sleeping laptop; it can only queue a job with runs-on: local. Supporting that would add a watch subcommand that polls Forgejo for a waiting job, creates the same ephemeral runner, and runs the same one-job lifecycle. The core is deliberately factored so that is an additional front-end, not a redesign.

Troubleshooting

  • no fj login for ...: run just login in a repository, or set FORGEJO_TOKEN.
  • OAuth unsupported: run just auth-client once; fj has no built-in client ID for git.shine.town.
  • virtiofsd/9p errors: the control share is 9p on purpose. Build output should never go through it; it is only runner.yaml, job.log and status.
  • QEMU is very slow: check /dev/kvm exists and the user can open it. Without KVM the guest falls back to TCG.
  • A runner stays offline: it is ephemeral and repo-scoped. Check the repository settings page; a failed run should have deleted its own row, but an aborted host process can leave one behind.
  • keel webhook failures in a release workflow are expected: Keel is a ClusterIP. Its poll loop still rolls :latest within five minutes, or the job can use kubectl if the workflow has a kubeconfig.