GitHub to Codeberg Migration - Part 3 - Set up and Running an own Forgejo Runner On Hetzner
In my previous blog post about the GitHub to Codeberg migration, my teaser was that the next step will be to set up a Forgejo Action to automate the deployment of my Codeberg pages.
Forgejo Action is inspired by GitHub Action. So if you are familiar with GitHub Action, the entry barrier for Forgejo Action is minimal for you.
Similar to GitHub Action, Forgejo Action needs a runner (called Forgejo Runner) for execution. Codeberg itself provides limited runners, so for the first steps with Forgejo Action, you can use their hosted version. I've decided to set up my own Forgejo runner to preserve the limited runner.
As an automation fan, I wanted a fully automated Forgejo Runner set up. The following steps I wanted to automate:
- Create a virtual machine
- Software Provisioning (install Docker, Docker Compose, copy config files)
- Deploy the Forgejo runner as a Docker container.
Step 1 - Create a virtual machine, aka how to set up cloud infrastructure components
I choose Hetzner as a cloud provider. I wanted a European provider, and I only need a virtual machine where I can start containers via Docker Compose. Hetzner has good value for money. The second argument for Hetzner is that they have a Terraform provider for their resources.
Terraform is an infrastructure-as-code tool that allows you to define and manage cloud infrastructure in a declarative manner. I usually like to use Terraform to set up my cloud resources. After HashiCorp changed their license, a fork, OpenTofu, was created. With this project, I would like to learn if I have the same good experience with OpenTofu as I have with Terraform for setting up infrastructure components in the Hetzner Cloud. Therefore, I choose OpenTofu.
The Terraform provider for Hetzner is compatible with OpenTofu. Therefore, I could follow the documentation of the Hetzner Terraform provider to configure a VM (the resource is called hcloud_server) in the Hetzner cloud. Additionally, I also create an SSH key on the VM (the resource is called hcloud_ssh_key) so that I can access the created VM via SSH.
The SSH key is pre-generated, and we pass the path of the private and public keys via variables (var.ssh_private_key and var.ssh_public_key).
1# Configure the Hetzner Cloud Provider
2provider "hcloud" {
3 token = var.hcloud_token
4}
5
6# Create a new SSH key
7resource "hcloud_ssh_key" "hetzner-forgejo-runner-ssh-key" {
8 name = "hetzner-forgejo-runner-ssh-key"
9 public_key = file(var.ssh_public_key)
10}
11
12# Create a server
13resource "hcloud_server" "forgejo-runner" {
14 name = "ubuntu-forgejo-runner"
15 image = "ubuntu-24.04"
16 server_type = "cx23"
17 location = "nbg1"
18 ssh_keys = [
19 "hetzner-forgejo-runner-ssh-key"
20 ]
21
22 connection {
23 type = "ssh"
24 user = "root"
25 host = hcloud_server.forgejo-runner.ipv4_address
26 private_key = file(var.ssh_private_key)
27 }
28
29 depends_on = [
30 hcloud_ssh_key.hetzner-forgejo-runner-ssh-key
31 ]
32}
33
34resource null_resource "local-ssh-setup" {
35 provisioner "local-exec" {
36 command = "sleep 20; ssh-keygen -R ${hcloud_server.forgejo-runner.ipv4_address}; ssh-keyscan -t rsa -H ${hcloud_server.forgejo-runner.ipv4_address} >> ~/.ssh/known_hosts"
37 }
38
39 depends_on = [
40 hcloud_server.forgejo-runner
41 ]
42}
For running the above tofu script, we need an API token.
This can be generated via the Hetzner Project Dashboard, and we can pass it via a variable (var.hcloud_token).
So at the end, we can run the script via CLI:
1tofu apply -var="hcloud_token=xxxxxx" -var="ssh_private_key=/path/private.key" -var="ssh_public_key=/path/public.key"
Step 2 - Software Provision
After creating the resources, we have to provide some software on the VM. Normally, I would use Ansible for that, but in this case, it feels too heavy because I need only to install some Docker-related packages, start the Docker daemon, and provide a few config files.
After studying the documentation of the Terraform provider, I found out that it is possible to provide the server with cloud-init.
Cloud-Init is a tool that helps to initialize and configure virtual machines on first boot. It can handle early setup tasks like setting up users, installing / updating packages, etc.
It is not as powerful as Ansible, but for my use case, it seems it is good enough.
I created a cloud-init.yaml that configures the apt repository of Docker.io, installs all needed packages for Docker, configures the docker user, and at the end, starts the Docker systemd service.
We need Docker because Forgejo Actions uses containers to run their workflow. It is also possible to use Podman or LXC as a container runtime.
1#cloud-config
2apt:
3 sources:
4 source1:
5 source: "deb [arch=amd64] https://download.docker.com/linux/ubuntu noble stable"
6 key: |
7 -----BEGIN PGP PUBLIC KEY BLOCK-----
8
9 mQINBFit2ioBEADhWpZ8/wvZ6hUTiXOwQHXMAlaFHcPH9hAtr4F1y2+OYdbtMuth
10 lqqwp028AqyY+PRfVMtSYMbjuQuu5byyKR01BbqYhuS3jtqQmljZ/bJvXqnmiVXh
11 38UuLa+z077PxyxQhu5BbqntTPQMfiyqEiU+BKbq2WmANUKQf+1AmZY/IruOXbnq
12 L4C1+gJ8vfmXQt99npCaxEjaNRVYfOS8QcixNzHUYnb6emjlANyEVlZzeqo7XKl7
13 UrwV5inawTSzWNvtjEjj4nJL8NsLwscpLPQUhTQ+7BbQXAwAmeHCUTQIvvWXqw0N
14 cmhh4HgeQscQHYgOJjjDVfoY5MucvglbIgCqfzAHW9jxmRL4qbMZj+b1XoePEtht
15 ku4bIQN1X5P07fNWzlgaRL5Z4POXDDZTlIQ/El58j9kp4bnWRCJW0lya+f8ocodo
16 vZZ+Doi+fy4D5ZGrL4XEcIQP/Lv5uFyf+kQtl/94VFYVJOleAv8W92KdgDkhTcTD
17 G7c0tIkVEKNUq48b3aQ64NOZQW7fVjfoKwEZdOqPE72Pa45jrZzvUFxSpdiNk2tZ
18 XYukHjlxxEgBdC/J3cMMNRE1F4NCA3ApfV1Y7/hTeOnmDuDYwr9/obA8t016Yljj
19 q5rdkywPf4JF8mXUW5eCN1vAFHxeg9ZWemhBtQmGxXnw9M+z6hWwc6ahmwARAQAB
20 tCtEb2NrZXIgUmVsZWFzZSAoQ0UgZGViKSA8ZG9ja2VyQGRvY2tlci5jb20+iQI3
21 BBMBCgAhBQJYrefAAhsvBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEI2BgDwO
22 v82IsskP/iQZo68flDQmNvn8X5XTd6RRaUH33kXYXquT6NkHJciS7E2gTJmqvMqd
23 tI4mNYHCSEYxI5qrcYV5YqX9P6+Ko+vozo4nseUQLPH/ATQ4qL0Zok+1jkag3Lgk
24 jonyUf9bwtWxFp05HC3GMHPhhcUSexCxQLQvnFWXD2sWLKivHp2fT8QbRGeZ+d3m
25 6fqcd5Fu7pxsqm0EUDK5NL+nPIgYhN+auTrhgzhK1CShfGccM/wfRlei9Utz6p9P
26 XRKIlWnXtT4qNGZNTN0tR+NLG/6Bqd8OYBaFAUcue/w1VW6JQ2VGYZHnZu9S8LMc
27 FYBa5Ig9PxwGQOgq6RDKDbV+PqTQT5EFMeR1mrjckk4DQJjbxeMZbiNMG5kGECA8
28 g383P3elhn03WGbEEa4MNc3Z4+7c236QI3xWJfNPdUbXRaAwhy/6rTSFbzwKB0Jm
29 ebwzQfwjQY6f55MiI/RqDCyuPj3r3jyVRkK86pQKBAJwFHyqj9KaKXMZjfVnowLh
30 9svIGfNbGHpucATqREvUHuQbNnqkCx8VVhtYkhDb9fEP2xBu5VvHbR+3nfVhMut5
31 G34Ct5RS7Jt6LIfFdtcn8CaSas/l1HbiGeRgc70X/9aYx/V/CEJv0lIe8gP6uDoW
32 FPIZ7d6vH+Vro6xuWEGiuMaiznap2KhZmpkgfupyFmplh0s6knymuQINBFit2ioB
33 EADneL9S9m4vhU3blaRjVUUyJ7b/qTjcSylvCH5XUE6R2k+ckEZjfAMZPLpO+/tF
34 M2JIJMD4SifKuS3xck9KtZGCufGmcwiLQRzeHF7vJUKrLD5RTkNi23ydvWZgPjtx
35 Q+DTT1Zcn7BrQFY6FgnRoUVIxwtdw1bMY/89rsFgS5wwuMESd3Q2RYgb7EOFOpnu
36 w6da7WakWf4IhnF5nsNYGDVaIHzpiqCl+uTbf1epCjrOlIzkZ3Z3Yk5CM/TiFzPk
37 z2lLz89cpD8U+NtCsfagWWfjd2U3jDapgH+7nQnCEWpROtzaKHG6lA3pXdix5zG8
38 eRc6/0IbUSWvfjKxLLPfNeCS2pCL3IeEI5nothEEYdQH6szpLog79xB9dVnJyKJb
39 VfxXnseoYqVrRz2VVbUI5Blwm6B40E3eGVfUQWiux54DspyVMMk41Mx7QJ3iynIa
40 1N4ZAqVMAEruyXTRTxc9XW0tYhDMA/1GYvz0EmFpm8LzTHA6sFVtPm/ZlNCX6P1X
41 zJwrv7DSQKD6GGlBQUX+OeEJ8tTkkf8QTJSPUdh8P8YxDFS5EOGAvhhpMBYD42kQ
42 pqXjEC+XcycTvGI7impgv9PDY1RCC1zkBjKPa120rNhv/hkVk/YhuGoajoHyy4h7
43 ZQopdcMtpN2dgmhEegny9JCSwxfQmQ0zK0g7m6SHiKMwjwARAQABiQQ+BBgBCAAJ
44 BQJYrdoqAhsCAikJEI2BgDwOv82IwV0gBBkBCAAGBQJYrdoqAAoJEH6gqcPyc/zY
45 1WAP/2wJ+R0gE6qsce3rjaIz58PJmc8goKrir5hnElWhPgbq7cYIsW5qiFyLhkdp
46 YcMmhD9mRiPpQn6Ya2w3e3B8zfIVKipbMBnke/ytZ9M7qHmDCcjoiSmwEXN3wKYI
47 mD9VHONsl/CG1rU9Isw1jtB5g1YxuBA7M/m36XN6x2u+NtNMDB9P56yc4gfsZVES
48 KA9v+yY2/l45L8d/WUkUi0YXomn6hyBGI7JrBLq0CX37GEYP6O9rrKipfz73XfO7
49 JIGzOKZlljb/D9RX/g7nRbCn+3EtH7xnk+TK/50euEKw8SMUg147sJTcpQmv6UzZ
50 cM4JgL0HbHVCojV4C/plELwMddALOFeYQzTif6sMRPf+3DSj8frbInjChC3yOLy0
51 6br92KFom17EIj2CAcoeq7UPhi2oouYBwPxh5ytdehJkoo+sN7RIWua6P2WSmon5
52 U888cSylXC0+ADFdgLX9K2zrDVYUG1vo8CX0vzxFBaHwN6Px26fhIT1/hYUHQR1z
53 VfNDcyQmXqkOnZvvoMfz/Q0s9BhFJ/zU6AgQbIZE/hm1spsfgvtsD1frZfygXJ9f
54 irP+MSAI80xHSf91qSRZOj4Pl3ZJNbq4yYxv0b1pkMqeGdjdCYhLU+LZ4wbQmpCk
55 SVe2prlLureigXtmZfkqevRz7FrIZiu9ky8wnCAPwC7/zmS18rgP/17bOtL4/iIz
56 QhxAAoAMWVrGyJivSkjhSGx1uCojsWfsTAm11P7jsruIL61ZzMUVE2aM3Pmj5G+W
57 9AcZ58Em+1WsVnAXdUR//bMmhyr8wL/G1YO1V3JEJTRdxsSxdYa4deGBBY/Adpsw
58 24jxhOJR+lsJpqIUeb999+R8euDhRHG9eFO7DRu6weatUJ6suupoDTRWtr/4yGqe
59 dKxV3qQhNLSnaAzqW/1nA3iUB4k7kCaKZxhdhDbClf9P37qaRW467BLCVO/coL3y
60 Vm50dwdrNtKpMBh3ZpbB1uJvgi9mXtyBOMJ3v8RZeDzFiG8HdCtg9RvIt/AIFoHR
61 H3S+U79NT6i0KPzLImDfs8T7RlpyuMc4Ufs8ggyg9v3Ae6cN3eQyxcK3w0cbBwsh
62 /nQNfsA6uu+9H7NhbehBMhYnpNZyrHzCmzyXkauwRAqoCbGCNykTRwsur9gS41TQ
63 M8ssD1jFheOJf3hODnkKU+HKjvMROl1DK7zdmLdNzA1cvtZH/nCC9KPj1z8QC47S
64 xx+dTZSx4ONAhwbS/LN3PoKtn8LPjY9NP9uDWI+TWYquS2U+KHDrBDlsgozDbs/O
65 jCxcpDzNmXpWQHEtHU7649OXHP7UeNST1mCUCH5qdank0V1iejF6/CfTFU4MfcrG
66 YT90qFF93M3v01BbxP+EIY2/9tiIPbrd
67 =0YYh
68 -----END PGP PUBLIC KEY BLOCK-----
69
70package_upgrade: true
71packages:
72 - apt:
73 [
74 docker-ce,
75 docker-ce-cli,
76 containerd.io,
77 docker-buildx-plugin,
78 docker-compose-plugin,
79 ]
80users:
81 - name: docker
82 no_user_group: true
83 groups: docker
84runcmd:
85 - "systemctl enable docker.service"
86 - "systemctl enable containerd.service"
87 - "systemctl start docker.service"
The next step is to add user_data configuration to the hcloud_server resource in the tofu script so that the server starts the cloud-init process.
1resource "hcloud_server" "forgejo-runner" {
2 name = "ubuntu-forgejo-runner"
3 image = "ubuntu-24.04"
4 server_type = "cx23"
5 location = "nbg1"
6 user_data = file("config-init.yaml")
7 ssh_keys = [
8 "hetzner-forgejo-runner-ssh-key"
9 ]
10}
Step 3 - Forgejo Runner Deployment
After the software provision, we have to deploy the Forgejo Runner.
We have two possibilities to install the runner:
- as binary or
- as a container.
I decide to go the container way. I have no good argument for why I chose this way. Maybe because nowadays I very often run my self-hosted software as a container. But this decision has a disadvantage. I have to configure a Docker-in-Docker approach to use Docker within Forgejo Action. That is needed in my case because I have some projects that use Testcontainers (it needs a container socket) during tests.
So I set up a Docker Compose file that ensures that a runner and a dind container are started.
1services:
2 docker:
3 image: docker:dind
4 privileged: true
5 healthcheck:
6 test: ["CMD", "docker", "ps"]
7 interval: 30s
8 timeout: 10s
9 retries: 3
10 start_period: 30s
11 volumes:
12 - certs:/certs
13 restart: always
14
15 runner:
16 image: code.forgejo.org/forgejo/runner:12.0.0
17 environment:
18 DOCKER_HOST: tcp://docker:2376
19 DOCKER_TLS_VERIFY: 1
20 DOCKER_CERT_PATH: /certs/client
21 env_file: .dockerenv
22 volumes:
23 - $PWD:/data
24 - certs:/certs
25 command: "/data/start.sh"
26 depends_on:
27 docker:
28 condition: service_healthy
29 restart: always
30volumes:
31 certs:
The runner is configured via a config.yaml.
The following configuration parts were important so that the Docker Daemon was found in the Forgejo Action:
1runner:
2 # Extra environment variables to run jobs.
3 envs:
4 DOCKER_HOST: tcp://docker:2376
5 DOCKER_TLS_VERIFY: 1
6 DOCKER_CERT_PATH: /certs/client
7container:
8 # Specifies the network to which the container will connect.
9 # Could be host, bridge or the name of a custom network.
10 # If it's empty, create a network automatically.
11 network: host
12 # And other options to be used when the container is started (eg, --volume /etc/ssl/certs:/etc/ssl/certs:ro).
13 options: '-v /certs:/certs'
14 # Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
15 # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
16 # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
17 # valid_volumes:
18 # - data
19 # - /etc/ssl/certs
20 # If you want to allow any volume, please use the following configuration:
21 # valid_volumes:
22 # - '**'
23 valid_volumes:
24 - /certs
25 # Overrides the docker host set by the DOCKER_HOST environment variable, and mounts on the job container.
26 # If "-" or "", no docker host will be mounted in the job container
27 # If "automount", an available docker host will automatically be found and mounted in the job container (e.g. /var/run/docker.sock).
28 # If it's a url, the specified docker host will be mounted in the job container
29 # Example urls: unix:///run/docker.socket or ssh://user@host
30 # The specified socket is mounted within the job container at /var/run/docker.sock
31 docker_host: "-"
The next step is to register the runner to our Forgejo instance, in our case, codeberg.io.
Therefore, we create a token on the Codeberg settings page and pass the token via CLI argument.
1forgejo-runner register --no-interactive --instance https://codeberg.org --name sparsick-runner --token $FORGEJO_RUNNER_TOKEN
2forgejo-runner --config config.yml daemon
The runner is in a container so that this shell script is mounted to the runner container.
The last step is to set up a systemd config so that the Docker Compose file is started as a service:
1// forgejo-runner.service
2[Unit]
3Description=Forgejo Runner Application Service
4Requires=docker.service
5After=docker.service
6
7[Service]
8Type=oneshot
9RemainAfterExit=yes
10WorkingDirectory=/opt/forgejo-runner
11ExecStart=/usr/bin/docker compose up -d
12ExecStop=/usr/bin/docker compose down
13TimeoutStartSec=0
14
15[Install]
16WantedBy=multi-user.target
At the end, we have to put it all together on the VM.
Tofu has provisioners for basic provisioning tasks like copy files (file provisioner) and execute commands on the vm (remote-exec provisioner).
1 provisioner "remote-exec" {
2 inline = [
3 "mkdir -p /opt/forgejo-runner"
4 ]
5 }
6
7 provisioner "file" {
8 source = "../runner-setup/"
9 destination = "/opt/forgejo-runner"
10 }
11
12 provisioner "file" {
13 source = "systemd/"
14 destination = "/etc/systemd/system"
15 }
The Forgejo token is passed via a tofu variable (var.forgejo_runner_token), so that we have to wait till cloud-init is finished with the provisioning and then start the Forgejo runner:
1provisioner "remote-exec" {
2 inline = [
3 "cloud-init status --wait",
4 "echo FORGEJO_RUNNER_TOKEN=${var.forgejo_runner_token} > /opt/forgejo-runner/.dockerenv",
5 "chown -R docker:docker /opt/forgejo-runner",
6 "chmod a+x /opt/forgejo-runner/start.sh",
7 "systemctl enable forgejo-runner",
8 "systemctl start forgejo-runner"
9 ]
10}
The tofu command call is now:
1 tofu apply -var="hcloud_token=xxxx" -var="forgejo_runner_token=yyyy"
Conclusion
In this approach, I use three tools to setup a Forgejo runner:
- OpenTofu
- Docker Compose
- Cloud-Init
I can reduce the list, if I configure the Forgejo runner as binary on the VM (Docker Compose setup is then not needed). I will see on the long term how stable the DIND approach is. The inital DIND configuration was not so easy, because runner config has to match exactly with the configuration of the DIND container.
The repository with all scripts.
The next step is to set up a Forgejo Action.