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:
- 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.
- 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
runneruser, the checksum-verifiedforgejo-runnerbinary, 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, enablingdocker buildanddocker pushin 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: dockerroutes a job to our runner;runs-on: codeberg-smallkeeps it on Codeberg’s pool. Routing is entirely per-job.
The registry has a storage quota
The images that pipeline publishes land in Codeberg’s package registry, and that registry is metered in a way GitHub’s is not. GitHub Packages is free for public packages, and Container registry storage is currently not charged at all – a public project can publish an image on every push for years and never hear about it. Codeberg applies a storage quota per owner (user or organisation), and packages share its smaller pool: by default 750 MiB for Git and 1.5 GiB for packages, LFS, releases and attachments combined. The limburg organisation is on an extended group with 5 GiB for that pool – assigned without our asking, as far as we can tell, and the same amount Codeberg grants on request to other projects publishing container images.
Two things make the quota easy to hit and hard to see coming:
- Every version is charged in full. Versions of the same image share almost all their layers – the Node base alone is 50 MB compressed – but the quota sums each version’s blobs without deduplicating them. Our pair costs about 59 MB (indexer) plus 60 MB (API) compressed, times two architectures: roughly 240 MB per push to
main, so 5 GiB holds about twenty releases. With the release workflow publishing on every merge, Renovate’s lock-file bumps included, we went from comfortable to over quota in three days – on a pair a fifth larger than today’s, before the distroless runtimes. - The failure is silent until the last step. Forgejo checks the quota only when an upload starts, so the build and every test pass and the final
docker pushfails with413 Request Entity Too Large: enforcePackagesQuota. Nothing already stored is deleted; thelatesttag simply stops moving, and consumers pulling it keep getting the previous release with no indication that a newer one exists. Usage is visible in the organisation’s settings or viaGET /api/v1/orgs/{org}/quota.
The answer is retention, not a bigger allowance: old image versions are not an archive – the commit is – so the organisation has a package cleanup rule keeping the ten most recent versions of each image – a tagged multi-arch image is three registry versions, the index plus one per architecture, so that is about three releases – and removing anything older than seven days; the container registry keeps latest regardless. A consumer pinning a dated tag through LOL_TAG should expect it to be gone within the week. If the quota is ever exhausted again, check the usage first; Codeberg’s request repository grants more readily to free-software projects, but expects a bounded rollback history in return.
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 pull requests 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.
That batch is deliberately one pull request rather than one per dependency. main blocks merging a branch that has fallen behind, so Renovate rebases every open branch whenever another lands – meaning a batch of n separate pull requests costs on the order of n² force-pushes between them, each one a notification. Grouped, nothing else is merging alongside it, so the branch never falls behind.
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.
When a bump is not the whole update
Renovate edits manifests and lockfiles. It cannot run the code transformations that some tools ship alongside a release – and where those exist, a version bump on its own is only half the update.
Nx is our case. nx migrate moves nx and every @nx/* package as one set and applies the codemods that rewrite nx.json, project configuration and generated source. Those codemods are not a major-version-only concern: two of the packages we depend on ship them in a minor release. A bump merged without them leaves the workspace on new packages with old configuration, which typically still builds – so CI does not catch it either, and the damage surfaces much later as unexplained behaviour.
The fix is to let the tool own its own upgrade path. Renovate is told to ignore nx and @nx/* entirely, and a nightly workflow runs the real migration and opens its own pull request, codemods included. A version check runs first and everything expensive is skipped when nothing has moved, so an ordinary night costs a checkout and one registry lookup.
The same reasoning extends to the packages a tool constrains rather than owns. Nx peer-depends on particular ranges of ESLint, Vitest and Vite, and depends on a TypeScript patch range outright; each is currently one major release from the edge of what Nx accepts. Their majors are therefore held for the migration to deliver – Nx widens those ranges when it supports the new version – while minors and patches stay automatic, since every one of those ranges spans a whole major. Two of the peer declarations are marked optional, which means npm installs an unsupported combination silently instead of refusing it; the constraint is real even though nothing enforces it.
Actions are the part that does not port
The Codeberg-specific part of that workflow is opening the pull request. The actions everyone reaches for – peter-evans/create-pull-request, actions/create-github-app-token – talk to the github.com API and cannot be pointed at another forge, so on Forgejo they are simply unavailable.
There is a Forgejo action ecosystem, and you are probably already using it without noticing. A bare uses: actions/checkout@v7 does not fetch from github.com: Forgejo prefixes shorthand names with its DEFAULT_ACTIONS_URL, which points at data.forgejo.org. What lives there is about fifteen repositories – mirrors of the common GitHub actions (checkout, setup-node, setup-python, cache) plus a few Forgejo-native ones (forgejo-release, git-backporting, cascading-pr). Fully qualified URLs work too, so an action hosted anywhere is reachable.
But there is no action there for opening a pull request. cascading-pr is the closest by name and does something else – it syncs a pull request into a dependent repository. One community action exists on github.com, and it would work; we did not take it, because the step that would use it holds a token with write access to this repository, and a single-maintainer dependency is a poor trade for the twenty lines of fetch against /api/v1/repos/{owner}/{repo}/pulls that replace it.
Those lines live in a local action – .forgejo/actions/open-pull-request/ – rather than a script the workflow calls directly. uses: ./path works on Forgejo as it does on GitHub, and packaging it that way gives it named inputs, an output, and somewhere for the reasoning to live, so the next workflow that needs a pull request does not rediscover any of this.
This is the recurring shape of porting CI to Forgejo: the workflow syntax is compatible and the ordinary actions are mirrored, so what breaks is specifically the actions that call the forge API – and the replacement is usually a direct API call, kept local.
The docs deploy is authorized by DNS
Codeberg Pages runs git-pages, which takes an upload from CI rather than serving a repository. That dissolves the shape this repository used to have: a build step that force-pushed docs/.vitepress/dist into a second repository, limburg/pages, under a personal access token – because a run’s own token cannot reach another repository, and the second repository existed only to be the thing the Pages server read.
What replaces the token is a DNS record. The zone carries
_git-pages-forge-allowlist.linkedopenlimburg.nl. TXT "https://codeberg.org/limburg/lol.git"which git-pages reads as the statement that a workflow in this repository may publish that site. Whoever controls the domain decides which repository owns it, which is where that decision belongs; the built-in Actions token, scoped to this repository and expiring with the run, then suffices to deploy.
Three details are easy to trip over. The Action needs server: codeberg.page when publishing to a custom domain: git-pages issues no TLS certificate for a domain that holds no site yet, so the first upload cannot reach the site over HTTPS, and naming the server uploads to codeberg.page with the domain in the Host: header instead. TLS is then Codeberg’s to handle – it obtains and renews a Let’s Encrypt certificate once the site exists – so the zone’s CAA records must leave letsencrypt.org room to issue. And because a CNAME cannot live at a domain’s apex beside its NS and SOA records, the apex points at Codeberg’s addresses directly, which is the one part of this that does not follow Codeberg if they renumber.
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 one thing here needs one: Renovate pushes branches and maintains its dependency-dashboard issue, which should be attributable to something other than whoever merges its pull requests.
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 Codeberg credential this repository uses, PACKAGE_TOKEN, still belongs to a maintainer’s personal account, which means CI holds a token carrying that person’s full authority across every repository they can reach. Moving it 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.
- Codeberg’s package registry counts against a per-owner storage quota (1.5 GiB by default, shared with LFS and releases) that charges every image version in full, where GitHub’s is free for public packages. Set a cleanup rule on the organisation before the first release, or the day comes when
latestquietly stops moving. - 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.
- Deployment authority need not be a credential at all. git-pages authorizes a docs deploy by a TXT record naming the repository, which retired both a cross-repository push and the personal access token that made it possible.
- 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.
- Check whether your tools ship codemods with their releases. If they do, a dependency bot bumping the version is not the whole update, and the half-applied result usually still builds – give the tool its own scheduled job instead.
- Workflow syntax ports to Forgejo unchanged, and the common actions are mirrored at
data.forgejo.org. What does not port is the actions that call the forge API – creating pull requests, minting app tokens. The replacement is usually a direct API call, and packaging it as a local action (uses: ./path) keeps it reusable without taking on a single-maintainer dependency in a step that holds a write-scoped token.