Skip to content

CI on Codeberg

Linked Open Limburg is developed entirely on Codeberg, the non-profit, community-run forge – matching a platform whose code, communication, and results are all openly shared. This page explains how our CI works there and why we run part of it ourselves, as a worked example for other platforms considering the same setup.

What Codeberg provides

Codeberg ships Forgejo Actions, a CI system that is workflow-compatible with GitHub Actions, plus free hosted runners for public, freely-licensed projects (labels codeberg-tiny/-small/-medium, up to 4 CPU / 8 GB / 10 minutes per job). For most projects that is all the CI you need – our QA pipeline (lint, typecheck, test, build) ran on them unchanged.

Why we added our own runner

Two limits pushed us to self-host a runner:

  1. Docker image builds. Our release pipeline builds the apps into container images and publishes them to the Codeberg registry. That requires a Docker daemon inside the job, which the shared hosted runners – reasonably, for security – do not offer.
  2. Queue time. Hosted runners are a shared pool; at busy moments jobs wait. A dedicated runner picks jobs up within seconds and runs our QA about twice as fast.

A Forgejo runner only makes outbound connections – it polls Codeberg for jobs – so it can run on any cheap VPS, home server, or laptop, with no open ports, reverse proxy, or DNS.

How ours is set up

The full configuration is public in infra/ci-runner/:

  • Host: a small EU VPS (Hetzner Cloud CX23 – 2 vCPU, 4 GB – in Nuremberg; an EU-owned provider was a deliberate choice). Everything the machine is, is declared in one cloud-init file – installing Docker, a dedicated runner user, the checksum-verified forgejo-runner binary, and a systemd unit.
  • One label, docker, mapped to the same container image Codeberg’s hosted runners use, so jobs behave identically – except ours also mounts the host’s Docker socket, enabling docker build and docker push in workflows. That mount hands workflows root-equivalent access to the VM, which is acceptable only because the runner serves our own repositories, with approval required for first-time contributors’ workflow runs.
  • Workflows opt in by label: runs-on: docker routes a job to our runner; runs-on: codeberg-small keeps it on Codeberg’s pool. Routing is entirely per-job.

github.com rate limits

CI hosted anywhere still calls github.com – actions/setup-node does so to resolve node-version: lts/* – and unauthenticated requests are capped at 60 an hour per IP. A shared runner pool exhausts that constantly; a dedicated runner gets the same 60, so a busy hour eventually exhausts it too. The symptom is API rate limit exceeded in a step unrelated to the change under test.

A github.com token with no scopes raises the cap to 5000 an hour; it grants no access, it only identifies the caller. Note that setup-node’s token input defaults to the forge’s own Actions token, which github.com does not accept – on Forgejo it must be set explicitly. We keep one as the GH_TOKEN secret, shared with Renovate, which needs it for the same reason.

GH_ rather than the more obvious GITHUB_: Forgejo refuses to store any secret whose name starts with FORGEJO_, GITEA_, GITHUB_ or a digit, reserving those for the variables it injects itself. Worth knowing before you reach for one, because that refusal is the only warning you get – a workflow reading a secret that does not exist interpolates the empty string rather than failing, so the mistake surfaces much later, as the very API rate limit exceeded error the token was meant to prevent.

Immutable rebuilds

We never reconfigure the VM by hand. When the runner’s machine configuration changes on main, a workflow – running on Codeberg’s hosted runners, since it wipes our own – recreates the VPS from scratch through the provider’s API, re-rendering the cloud-init file as the installer’s provisioning script. The runner’s identity survives rebuilds because its connection credentials are injected from CI secrets rather than stored on the machine’s image. Cost: about ten minutes of runner downtime per configuration change, during which queued jobs simply wait.

Dependency updates

Codeberg has no Dependabot, and – unlike GitHub – no shared bot instance that repositories can simply opt into; the open request for one dates from 2022. The practical answer, and the one Forgejo itself uses for its own repositories, is to self-host Renovate, which speaks Forgejo natively.

Ours is an hourly workflow running the Renovate container on our own runner, opening one pull request per update that the same QA pipeline gates as it would any human change. The cron only decides how often Renovate looks; its configuration decides when it may act, and there most updates are held to a Monday-morning window. Only the dependency we actively follow – LDE – is exempt, so its releases land within the hour while everything else stays batched.

Two things make that gating worth more than usual here: the LDE base images and the @lde/search package our schema is written against are grouped into a single pull request, so CI sees the combination that will actually ship; and the pipeline boots the proposed indexer image against the bundled schema, so an incompatible bump fails in the pull request rather than at deploy.

Two credentials are involved, both stored as Actions secrets: a Codeberg token on a dedicated lolbot account (below) and a scopeless github.com token. The second is easy to overlook: most npm packages are developed on GitHub, and unauthenticated github.com API access is capped at 60 requests an hour per IP, which a single lookup pass exhausts.

The lolbot machine account

Forgejo gives each workflow run a built-in Actions token, and for most jobs that is the right credential: it is scoped to the one repository and expires with the run. But it cannot act as an identity, and two things here need one. Renovate pushes branches and maintains its dependency-dashboard issue, which should be attributable to something other than whoever merges its pull requests; and pushing to a different repository – our Codeberg Pages target – is outside the token’s scope entirely.

So the limburg organisation has a machine account, lolbot. It is a plain Codeberg account with no special status; what makes it useful is that its authority is bounded. Today it holds one credential, RENOVATE_TOKEN, with the four scopes Renovate needs (repository and issue read/write, user and organization read) and write access to this repository alone. Its commits carry lolbot@noreply.codeberg.org, so bot changes are distinguishable from human ones at a glance in the log.

The other two Codeberg credentials this repository uses – PAGES_TOKEN and PACKAGE_TOKEN – still belong to a maintainer’s personal account, which means CI holds tokens carrying that person’s full authority across every repository they can reach. Moving them to lolbot would bound that the same way; it is a known gap rather than a decision.

Takeaways for other platforms

  • Codeberg’s free hosted runners cover a standard build-and-test pipeline; you need your own runner only for Docker builds or guaranteed pickup.
  • A single cheap VPS suffices, and nothing about the setup is Codeberg-specific – it works against any Forgejo instance.
  • Keep the machine definition in one committed file and rebuild instead of mutating; the only secret that leaves CI is the runner’s own connection token.
  • Give automation its own account rather than lending it a maintainer’s: the built-in Actions token covers most jobs, and anything it cannot do is a sign that a bounded machine identity belongs there.
  • Authenticate to github.com even when you host elsewhere; a scopeless token turns a 60-an-hour cap into 5000, and a dedicated runner only postpones the problem.
  • Dependency automation is not a platform feature you either get or go without: Renovate is a container you schedule, so any forge with CI and an API can have it.