I'm moving away from Github to Codeberg.

The reason being what has been talked about recently: the increase push for AI, i.e., letting Github use your data for AI training and the possibility of price increases for the Github Actions runner minutes.

Now, I'm on the free tier, but thinking that I might have to pay for the runners and see my data used for AI training for free didn't sit well with me.

For basic repository handling Codeberg does exactly what's needed. The only thing keeping me from going full in are the Github Actions alternatives.

In Github we have Github free minutes for actions which so far have been more enough for my personal projects.

Codeberg does not have the same feature (not even free), but it is possible to "bring your own runner".

Codeberg does have what seems to be a free runner, Woodpecker, but it's invite only and I assume it's aimed at open source projects, so I didn't look into this alternative.

I already have instances in Digital Ocean, so I decided to create my own runner(s) there and start moving some actions so I could migrate from Github entirely.

While I'm at it, I wanted to:

  • deploy this instance through terraform so I could easily destroy the DO droplet if not being used for a while;
  • use opentofu instead of terraform. The main advantage of OpenTofu is the possibility of storing encrypted state in the cloud. Terraform requires an Hashicorp paid account to do that. Even though I'm not actually using it for this project, in production-grade deploys it's a must. OpenTofu will give you this for free. More info on their [website](https://opentofu.org/docs/language/state/encryption/.

There are several pages of official and community documentation to implement what I'm after, so I went through them and came up with a plan:

Deploying a Forgejo instance using OpenTofu

The first thing I needed as a runner compatible with Codeberg and Github actions syntax. Forgejo runner is exactly that.

So I got this implementation going and I'm pretty happy with the result. Adding an instance to Digital Ocean and get Codeberg to use it was pretty straightforward. However, I wasn't really able to do the full deploy using opentofu / terraform on the first try.

Installing Forgejo Runner requires adding a specific user, doing some operations on it, going back to root, etc.

I didn't really to spend want too much time on this part of the process, so I skipped it on my initial iteration. It went really well and fast, apart from quirks I found related to the runner labels: more on that later. The instance creation in Digital Ocean was pretty straightforward. I just had to tweak some settings based on the community tutorial and I was good to go.

But this wasn't enough, I really needed the full deploy with OpenTofu so I could destroy and recreate the instance on demand. After circling to the very beggining, this is the end result.

First I installed opentofu. Terraform will do fine, if you want

brew install opentofu

Then the tf setup. It's mostly what the DO community guide above shows, with some tweaks. I have a repo in Codeberg (of course) with all the details.

I also didn't use the remote-exec command, but instead a cloud-init config file to add several of the tools required (mosh, neovim, docker and the forgejo runner). Using remote-exec was failing with ssh connection dropping.

And tweaked the variables a bit. Not only I moved them to a different file but also added the codeberg runner registration token to it. This way I could run the full runner setup in one go.

We'll need, beforehand:

  • an ssh key added to digitalocean;
  • the DO personal toke api key;
  • the codeberg runner registrationtoken.

Now, when inside the folder with the .tf files, all we need to do is set the variables needed:

export TF_LOG=INFO # this was required in my case as I had an incorrect log level set elsewhere.
export DO_OPENTOFU_TOKEN=
export CODEBERG_REGISTRATION_TOKEN=
export DROPLET_NAME="forgejo-runner"

run plan to confirm the syntax is valid (although it won't check for cloud-init),

tofu plan \
    -var "forgejo_runner_name=${DROPLET_NAME}" \
    -var "do_opentofu_token=${DO_OPENTOFU_TOKEN}" \
    -var "pvt_key=$HOME/.ssh/do_opentofu_token" \
    -var "codeberg_registration_token=$CODEBERG_REGISTRATION_TOKEN"

and if there are no errors just run apply

tofu apply \
  -var "forgejo_runner_name=${DROPLET_NAME}" \
  -var "do_opentofu_token=${DO_OPENTOFU_TOKEN}" \
  -var "pvt_key=$HOME/.ssh/do_opentofu_token" \
  -var "codeberg_registration_token=$CODEBERG_REGISTRATION_TOKEN"

This takes a while to complete. Once it's done, we can get info for the created droplet by either by going to DO's webpage or

doctl compute droplet list --output json | jq '.[] | select(.name = "$DROPLET_NAME")' 

to get just the ip connection address:

echo DROPLET_IP=$(doctl compute droplet list --output json | jq -r '.[] | select(.name == "$DROPLET_NAME") | .networks.v4[] | select(.type == "public").ip_address')

With this we can connect to the instance using ssh (or mosh, after installing it).

Accessing the server

We already have the droplet's IP address from the Deploying a Forgejo instance using OpenTofu step above. We'll use it in :

ssh -i ~/.ssh/do_opentofu_token" root@DROPLET_IP

or

mosh --ssh="ssh -i ~/.ssh/do_opentofu_token" root@DROPLET_IP

In here, the first thing I did was set up my editor to use neovim (interactively) with sudo update-alternatives --config editor.

Now for a sanity check, let's confirm Forejo and docker are running:

forgejo-runner -v
sudo systemctl status docker

And for visibility on the service we can run

systemctl status forgejo-runner.service
# and 
journalctl -u forgejo-runner.service

We expect Forejo to be installed,

forgejo-runner -v
# forgejo-runner version v12.5.2

running:

systemctl status forgejo-runner.service
# ● forgejo-runner.service - Forgejo Runner
#      Loaded: loaded (/etc/systemd/system/forgejo-runner.service; enabled; preset: enabled)
#      Active: active (running) since Thu 2026-01-15 13:56:33 UTC; 5min ago
#        Docs: https://forgejo.org/docs/latest/admin/actions/
#    Main PID: 1240 (forgejo-runner)
#       Tasks: 6 (limit: 1078)
#      Memory: 26.1M (peak: 27.7M)
#         CPU: 165ms
#      CGroup: /system.slice/forgejo-runner.service
#              └─1240 /usr/local/bin/forgejo-runner daemon

as well as Docker:


sudo systemctl status docker
# ● docker.service - Docker Application Container Engine
#      Loaded: loaded (/usr/lib/systemd/system/docker.service; enabled; preset: enabled)
#      Active: active (running) since Thu 2026-01-15 12:56:33 UTC; 4min 44s ago
# TriggeredBy: ● docker.socket
#        Docs: https://docs.docker.com
#    Main PID: 976 (dockerd)
#       Tasks: 8
#      Memory: 124.2M (peak: 124.3M)
#         CPU: 511ms
#      CGroup: /system.slice/docker.service
#              └─976 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

Is it working?

If we now go to the runners page in codeberg we'll see our alongside the base ones!

Description

Now to actually use the runner.

Navigate to a repository you want to run actions on. In the repository settings -> units -> overview, select Actions. This will activate the actions tab.

Now we'll have the action available in the repository. In my case I reused the exact Github action file. Description

And once we run it, we'll have the same flow as in Github actions:

Description

What I learned

I learned a few things when setting up the droplet.

cloud init

The most interesting one was the cloud-init setup, and the cloud-init status command. Cloud-init takes a while to finish, and opentofu does not wait for it to finish before "completing" the provisioning. This made me repeat the process a few times before i realized what was happening.

The droplet creation is quite fast, so when logging in after opentofu finishes, I ssh'ed into the droplet and checked cloud-init status --long until it was "done".

Also, cloud-init only runs the first time we deploy. So as I was doing the iterations, I needed to delete the instance and create it with

tofu destroy -target=digitalocean_droplet.forgejo-runner \
    -var "do_opentofu_token=${DO_OPENTOFU_TOKEN}" \
    -var "pvt_key=$HOME/.ssh/do_opentofu_token" \
    -var "codeberg_registration_token=$CODEBERG_REGISTRATION_TOKEN"

To check for the cloud init logs there are the files /var/log/cloud-init.log and /var/log/cloud-init-output.log which came in handy. I found the cloud-init debugging reference, quite helpful, specially the validator (cloud-init schema -c cloud-init.yaml --annotate), which warned me for some emojis that were breaking the script.

"Label"ing differences

The labels were the only thing i had to revert at some point. In theory the default one should work, but in order to use the same runs-on: ubuntu-latest setting I had on Github, I add to manually add these entries. They should be a string, comma separated. Showing them line by line for better visibility:

ubuntu-20.04:docker://node:25-bookworm
ubuntu-18.04:docker://node:25-bookworm
ubuntu-latest:docker://node:25-bookworm

Check choosing labels in Forejo's docs for more info.

I would have liked to install podman, but the process is more involved, so that will be a next step well.