Today, or yesterday, depending on how you look at it, I had one of those days where you're so tired
you can't focus on what you should be doing, but you don't want to take a nap either. And of course,
I ended up procrastinating. The Forgejo Actions system came to mind, which allows running CI/CD
pipelines on forges like Codeberg. However, CI/CD, while very interesting, is an expensive and
delicate service. That's why free projects like Codeberg, although they offer this service, encourage
their users to use it wisely and sparingly (see Working with Codeberg's CI
and Rules and Conditions from the dedicated meta repository).
Since I was doing some testing with Forgejo Actions for a personal project, yet to be published,
I had been thinking about possible ways to avoid unnecessary usage, or at least indiscriminate usage
(e.g., not running the pipeline on every commit, nor on every change in a PR). I put together a proof
of concept for checking whether a PR was marked as WIP,
which I ended up proposing in the actions/meta repository, and the user mahlzahn proposed another approach
that, although I haven't tried yet, is probably even more efficient.
However, Forgejo and its Forgejo Actions have a very interesting component, mentioned by both Codeberg and Forgejo in their documentation: Forgejo Runner.
What is Forgejo Runner #
In a nutshell, it's a daemon that fetches workflows from the repositories where it has been configured, executes them, and returns the log for each step along with its result.
The following aspects caught my attention:
- You don't need to be a Forgejo expert, neither in its codebase nor in its tooling ecosystem, to get a test up and running. Of course, doing it professionally involves paying attention to many details, especially related to security. But for anyone who wants to build proofs of concept, or even to ease the load on the runners provided by Codeberg or any other Forgejo-based forge, it's quite accessible.
- You don't need your own Forgejo instance, since the runner connects to any existing instance (like Codeberg). You don't need to spin up other Forgejo components alongside it either. It can run in isolation, it can be installed as a binary, as a NixOS package, or as a Docker container.
- If it's for a private or public but personal project, or one whose pipelines can wait to be executed (because there's no rush to get results), setting up a disposable local instance sounds really useful to me. Take the case where you're starting to develop a tool but want to keep a public record of your tests, or you work from the start with a pull request-based approach with integrated CI, or small repositories with a few contributors. Big projects? That would involve a different infrastructure altogether, but also different resources and tools.
- It works by periodically pulling pending workflows from the repositories where the runner has been set up. This is very important when running it locally, since we don't have to figure out how to send pending workflows from Codeberg to a service running on our local network. Important: this does not mean we shouldn't be careful about other aspects, such as arbitrary code injection on our local machine.
Running Forgejo Runner on a local device #
What I initially wanted to verify was how complex it would be to set up, configure, and use a Forgejo Runner on a device in my local network for the use case described in point 3 of the previous section.
Note: this post is not intended to be a detailed tutorial by any means, it's more of a quick and direct guide, but not necessarily a fully correct one, nor does it cover all the details it should. If you prefer a more formal, detailed read that addresses each aspect, start with the Forgejo Runner installation guide.
From my perspective, if you want to get started quickly with a Forgejo Runner you basically need:
- Docker and Docker Compose, to spin up Forgejo Runner.
- The URL of the Forgejo instance you want to connect the runner to.
- The registration token, which in a Forgejo instance you can obtain in different ways depending on
the scope you want the runner to have:
- Per user: go to "User Settings", then "Actions" and then "Runners", then click
"Create new runner"; or directly at
https://<instance>/user/settings/actions/runners. - Per organization: same path as for user, but going to the organization's settings; or directly
at
https://<instance>/org/<organization>/settings/actions/runners. - Per repository: same, but in the repository settings; or directly at
https://<instance>/<owner>/<repo>/settings/actions/runners.
- Per user: go to "User Settings", then "Actions" and then "Runners", then click
"Create new runner"; or directly at
- Keep in mind that there are 3 execution modes and what each one implies:
docker://<image>: each job runs inside a new Docker container, isolating jobs from each other and from the host.lxc://<template>:release: each job runs inside an LXC system container, isolating jobs similarly to Docker but at the operating system level.host: each job runs directly on the machine where the runner is running, with no isolation. Very easy to configure and tremendously risky due to the lack of isolation.
After taking all of the above into account, in my specific case today, I started by creating a directory structure for the runner:
1$ mkdir -p forgejo-runner/data/.cache
2$ chown -R 1000:1000 forgejo-runner/data
3$ chmod 775 forgejo-runner/data/.cache/ && chmod g+s forgejo-runner/data/.cache/
I don't know about you, but even though I've been using chmod for years, it's something my brain
always has to relearn and keep fresh. So the following note will be great for my future self: chmod g+s
activates the setgid bit, which basically means that every file or subdirectory within the target
directory will inherit the same group as the parent directory (i.e., .cache/a will have the same
group as .cache).
Then I created a very minimal docker-compose.yml:
1services:
2 forgejo-runner:
3 container_name: forgejo-runner
4 image: data.forgejo.org/forgejo/runner:12
5 command: ["tail", "-f", "/dev/null"]
6 volumes:
7 - /var/run/docker.sock:/var/run/docker.sock
8 - ./data:/data
The tail in the command allows me to keep the container alive, since without configuration,
forgejo-runner would fail. After that, I proceeded to register the runner:
1$ docker compose up -d
2$ docker exec -it forgejo-runner sh
3$ cd /data
4$ forgejo-runner register
After running forgejo-runner register, a guided registration process would start, asking for the
Forgejo instance URL, the token, the runner name, and its labels. Important: the first part of the
label is what's used in the runs_on key of a Forgejo Actions workflow to determine which runner
should execute the job, followed by the mode and then the image (e.g., test-runner:docker://node:20-bookworm).
If you'd rather skip the guided process and run a single command, you can use parameters for each
value. Run forgejo-runner register --help for more information.
After registering the runner, you can run cat /data/.runner to take a look at the generated
configuration file. When you're done poking around, you can exit the container (exit).
After exiting the container, I replaced the command in docker-compose.yml with the following:
1command: '/bin/sh -c "forgejo-runner -c /data/config.yml daemon"'
And I ran docker compose down && docker compose up -d && docker compose logs -f and... it failed!
forgejo-runner | time="2026-03-20T17:05:41Z" level=info msg="Fetch interval for connection forgejo-runner-home-lpa has been increased to the minimum of 30 seconds for Codeberg"
forgejo-runner | time="2026-03-20T17:05:41Z" level=info msg="Starting runner daemon"
forgejo-runner | Error: cannot ping the docker daemon. is it running? permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Head "http://%2Fvar%2Frun%2Fdocker.sock/_ping": dial unix /var/run/docker.sock: connect: permission denied
It basically fails because the container user needs permissions to use /var/run/docker.sock.
If we run ls -la /var/run/docker.sock we can see that among the groups there's docker, but
we need its gid with getent group docker, which will return something like docker:x:yyy.
So after figuring that out, I edited the docker-compose.yml and added group_add, along with
restart, which I had forgotten:
restart: unless-stopped
group_add:
- "<your-docker-group-gid>"
Once again, I ran docker compose down && docker compose up -d && docker compose logs -f and... it worked!
forgejo-runner | time="2026-03-20T17:14:35Z" level=info msg="Fetch interval for connection forgejo-runner-home-lpa has been increased to the minimum of 30 seconds for Codeberg"
forgejo-runner | time="2026-03-20T17:14:35Z" level=info msg="Starting runner daemon"
forgejo-runner | time="2026-03-20T17:14:35Z" level=info msg="runner: forgejo-runner-home-lpa, with version: v12.7.2, with labels: [<label>], ephemeral: false, declared successfully"
forgejo-runner | time="2026-03-20T17:14:35Z" level=info msg="[poller] launched"
forgejo-runner | time="2026-03-20T17:18:05Z" level=info msg="task 3935967 repo is ivanhercaz/codeberg-test-lab https://data.forgejo.org https://codeberg.org"
So I went ahead and forced a pipeline run: the first time it took about 2 minutes to download the
Docker image defined in the label, the second time it took seconds. Of course, it's also important
to keep in mind that what it was running was the check-wip workflow, something very lightweight.
Potential and reflection #
I think both Forgejo and Forgejo Runner present a very interesting approach and ecosystem that is well worth studying, both for open source software development and for private forges of companies, associations, and others. Furthermore, it makes it easy for anyone to set up ephemeral or permanent instances to help projects that use Forgejo, such as Codeberg, to ease the burden that comes with providing certain services.
But we must not forget the most important thing: security. In CI pipelines, code runs on a target machine, and that target machine must be sufficiently secured, as well as the system managing the pipeline, so that the execution of that code doesn't break anything or produce a security breach. This is something we don't usually keep in mind when using a third-party CI, because one way or another, that third party has security policies that force the user to follow or that are directly applied internally in their system. Please, do not allow code to run arbitrarily on any of your devices, whether local, a VPS, or a cloud server.
Throughout this post, which I think turned out longer than I expected, I've been leaving some links to relevant documentation that I relied on for today's experiment. I recommend reading all of it, and more.
It would be impressive to set up a purely FOSS, secure, and sustainable infrastructure, maintained by the users themselves, but the security topic makes that concept very complicated. However, I'm going to keep studying Forgejo Runner because I'm very interested in the ephemeral runner approach (start, use, and shut down), and I also want to try the Docker-in-Docker approach described in the Forgejo documentation.