TIL: how to run a local Forgejo Runner

· Iván Hernández Cazorla - Blog

Today I learned how to run my CI jobs on Codeberg using a Forgejo Runner on a local device


Versión en español disponible


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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

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.

last updated:

Todas las entradas están bajo licencia CC BY-SA 4.0. Las imágenes y recursos de terceros tienen su licencia especificada.

All posts are licensed under CC BY-SA 4.0. Third-party images and resources have their licenses specified.

Si te resulta útil lo que escribo, puedes invitarme a un café.

If you find my writing useful, you can buy me a coffee.