Forgejo Runners without containers

  • 18th Aug 2025
  • 6 min read
  • Tags: 
  • tech

Forgejo has a cool feature called runners, that do stuff when events happen to your source code in the repository. Many people use this to build versions of the software based on new source code.

Because runners are very powerful, the documentation and base configuration is based on fairly complex setups, involving firing up Docker or LXC containers to run test and build processes in. This is all good and well, but my requirement for them was a lot simpler, which made setting up a runner pretty frustrating.

My simple setup

As I’ve said before, this blog is now generated static HTML. Originally, I had a copy of Zola on my laptop that I used to build the HTML, then I would rsync the HTML over to my web server. A bit later I fugured I can just pull the stuff from my Forgejo server with git pull and have a hook in git on the web server that runs Zola to build the site and copies the files to the right place.

This worked well, but still needed me to SSH to the web server to run the git pull command.

The basic idea

So why not just have a Forgejo runner sitting on the web server to run that git pull command when I push changes to Forgejo? Well, this is where the frustration with existing documentation started. By design, my web server is a very simple beast, it doesn’t do fancy stuff like Docker or LXC, and all the documentation I could find was geared for a setup where the runner fires off one or more containers, their consession to not using containers was a simple warning that running your jobs on the host was unsafe because there isn’t any separation from the host operating system.

So my requirement to just be able to run a simple git command on the web server was theoretically possible, but basically ignored by all the documentation I could find.

My setup

So… This blog post exists, so I must have figured it out, right? Yes I did, and this blog post exists in the hope that it will be of some use to someone else who has a very basic and simple Forgejo runner requirement.

On the web server

As discussed in the official documentation, the first task is to install the forgejo-runner daemon on the host. One thing I did, which I’m not convinced is needed, is that I only put a host tag in when I registered the runner with my Forgejo server. Oh, and because security is important, I also registered it at the user level on the Forgejo, not the server level.

Next I did forgejo-runner generate-config > ~/forgejo-config.yml to create a configuration file. I then edited the config file and, in the runner: part, put this:

  labels:
    - "self-hosted:host"

This effectively tells forgejo-runner that it will only be accepting jobs with the label self-hosted and it will run those jobs on the host. If you don’t do this, forgejo-runner will refuse to start because it will try to talk to Docker and fail.

The next step was to create a shell script in my home directory (here I’m going to change my username to user just because) called /home/user/fj-runner.sh with this in it:

#!/bin/bash

cd /home/user
nohup forgejo-runner daemon --config /home/user/forgejo-config.yml > /dev/null &

I then added to my crontab as follows:

@reboot /home/user/fj-runner.sh

The effect of that is to start the runner when the machine boots up without involving systemd in the process and adding that complexity.

The last step on the web server was just to run /home/user/fj-runner.sh manually to fire up the daemon.

In my repository

So to get a runner to actually do something, it has to be registered with the Forgejo server (done as part of installing forgejo-runner) and there has to be a workflow in the repository for it to run.

This workflow is a yaml file sitting in the .forgejo/workflows directory of your repository, so I created .forgejo/workflows/update-site.yml in my blog’s repository. There’s a lot of stuff that can be done in these workflows, but mine is pretty simple because I don’t need to do all manner of clever stuff with it, so it looks like this:

on:
  push:
    paths:
      - 'content/**'
      - 'static/**'

jobs:
  deploy:
    runs-on: self-hosted
    steps:
    - id: step1
      run: |
        cd /home/user/blog
        git pull

By way to basic description of what happens here:

  • The on: section tells the runner when it has to run this workflow. In my case, I decided to just have it run whenever I push changes and those changes involve anything in the content or static directories. I’m sure there are much better ways, like maybe when there are pushes to a specific branch meant for deploymnent.
  • The jobs: section defines the actions the runner must take, in my case just the one job called deploy.
    • The first line in deploy:, that runs-on: self-hosted is the bit of magic that tells the runner on my web server that it must run this job. The labels in my forgejo-runner config then tells it to run it on the host instead of trying to fire up a container.
    • The run: bit is basically a list of commands the runner will exdcute in a shell. This just goes to the directory where the blog’s git repository sits on the web server and then runs a git-pull, which does the rest of the stuff to build and publish the site.

Wait, whut? Git magic?

OK, so this bit is slightly out of context for this post, but pretty cool. Like I said, I have stuff happening magically when I (or forgejo-runner running as my user) do git pull in the /home/user/blog directory.

To keep stuff simple, I actually configured my web server to serve this site from the /home/user/blog/public directory, because that’s where Zola puts the site when it builds it.

So how does this magic work? Well, git will look for “hooks” in the .git/hooks directory whenever it does something, the file name being the event the script will run for. Since a git pull involves a merge event if anything was pulled, I created /home/user/blog/.git/hooks/post-merge to run after the merge event, this file literally just contains:

#!/bin/bash

zola build