What you will set up
Forgejo Actions runs your tests, builds and deploys when you push to Forgejo, with workflow files much like GitHub Actions. Forgejo itself only hands out the work. A runner picks up each job and runs it, here in a fresh rootless container under a user of its own called runner.
Forgejo Runner is made by the Forgejo project, a community that develops Forgejo in the open under the umbrella of Codeberg e.V., a non-profit association in Berlin. It is free software under the GPL, version 3.0 or later (checked on 14 October 2026).
Forgejo publishes the runner as a single program and as a container image, code.forgejo.org/forgejo/runner. With rootless Podman the program fits better: it talks straight to the runner user's Podman, and the cache it serves to jobs listens on the server's own address. In a container it would need that Podman's socket mounted into it and its cache port published, and the job containers would still be started by the same Podman. The cost is that podman auto-update does not update it; step 17 shows how.
This guide builds on the Forgejo guide, which sets up the runner in two short steps. Here it goes further. Every step below was run on a Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Forgejo Runner 13.2.0 ran jobs for Forgejo 15.0.10 in rootless containers, two at a time.
- A workflow checked out a small Node.js project, ran its tests on Node.js 22 and 24 side by side, and restored its npm cache on the next run. Each run kept a package as an artifact, which downloaded from the run's page. A schedule and the Run workflow button both started it.
- A secret showed as
***in the log. A job built a container image with Buildah, without privileged mode, and pushed it to Forgejo's package registry. The next job deployed it over SSH with a key that can run one command only, and the new version answered behind Caddy. - A pull request from another user's fork waited for approval. Once approved, its job got none of the secrets, and its token could not push or change the repository.
- Jobs could not reach the Podman socket or apps that listen only on the server's loopback address, and could not mount file systems.
- The runner was updated from 13.1.0 to 13.2.0 and restored from a backup, and everything came back by itself after a reboot.
The runner used about 35 MB of memory between jobs. With two jobs running, the server used about 450 MB more. Each job image takes about 1.3 GB of disk.
Before you start
You need:
- Forgejo set up as in the Forgejo guide, steps 1 to 6, with Caddy running. If you also did its steps 7 and 8, you have done steps 2 to 4 here. Replace your runner's settings and service with those in steps 5 and 6, keep your own
server:block, and restart it; - ufw switched on as in the security guide. The runner opens ports for its jobs' cache, which the firewall keeps closed to the internet;
- for the deploy in step 11, an A record and an AAAA record for
hello.example.compointing at your server.
The examples use git.example.com for Forgejo, hello.example.com for the app you deploy, anna as your user name and 203.0.113.10 for your server's IPv4 address. Replace them throughout.
Forgejo is also available in Swedish: under Settings > Appearance, choose Svenska as Default language, then Change language. This guide uses the English names of menus and buttons.
1. Check that Actions is on
Forgejo 15 has Actions switched on, also for new repositories. In a repository, Settings > Units shows Actions ("Enable integrated CI/CD pipelines with Forgejo Actions") ticked, and the repository has an Actions tab. If you switched it off for a repository, tick it there and choose Save settings.
2. Install the runner
As root, install Git, which the runner uses to download actions, and download the runner. Forgejo signs each release, so check the signature before you install it:
apt install -y git gpg curl
v=13.2.0
url=https://code.forgejo.org/forgejo/runner/releases/download/v$v/forgejo-runner-$v-linux-amd64
curl -fL -o forgejo-runner $url
curl -fL -o forgejo-runner.asc $url.asc
gpg --keyserver hkps://keys.openpgp.org --recv EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-runner.asc forgejo-runner
The last command must print Good signature from "Forgejo <contact@forgejo.org>". A warning that the key is not certified is expected. 13.2.0 was the newest version when we wrote this; code.forgejo.org/forgejo/runner/releases lists newer ones. Install it:
install -m 755 forgejo-runner /usr/local/bin/forgejo-runner
rm forgejo-runner forgejo-runner.asc
3. Create the user
Jobs run code from your repositories, so the runner gets a user of its own. Still as root:
useradd -m -s /bin/bash runner
loginctl enable-linger runner
systemctl --user -M runner@ enable --now podman.socket
machinectl shell runner@
podman.socket is the runner user's Docker-compatible Podman API. The runner starts each job's container through it, as this user. Everything up to step 7 runs as runner.
Jobs usually start by fetching your code from https://git.example.com, which is this server. Rootless containers normally share the server's own address, and so cannot reach it. Give this user's containers addresses of their own:
mkdir -p ~/.config/containers
Create ~/.config/containers/containers.conf:
# Give job containers addresses of their own, so they can reach this
# server's public address, and Forgejo on it.
[network]
pasta_options = ["-a", "10.0.2.0", "-n", "24", "-g", "10.0.2.2", "--dns-forward", "10.0.2.3"]
This is the second fix in step 8 of the Podman guide, here without IPv6. It applies to the runner user only: Forgejo and the app you deploy run as other users and keep their settings.
4. Create the runner in Forgejo
In Forgejo, open https://git.example.com/admin/actions/runners and choose Create new runner. Name it, for example server-runner, and choose Create. Forgejo shows the runner's UUID and token once, and a block that starts with server:. Keep the page open for the next step.
A runner created here takes jobs from every repository. A repository's Settings > Actions > Runners has the same button, for a runner that only takes that repository's jobs.
The same page also has Registration Token (Deprecated), for the older forgejo-runner register command. It still works, and writes the token to a .runner file that every user on the server can read. The way above keeps it in a file only runner can read.
5. Configure it
Create ~/runner-config.yml, with the server: block from Forgejo at the end in place of the one below:
runner:
# How many jobs run at the same time
capacity: 2
# Stop a job that runs longer than this
timeout: 1h
# On stop or reboot, give running jobs this long to finish
shutdown_timeout: 10m
labels:
- docker:docker://data.forgejo.org/oci/node:24-trixie
- ubuntu-latest:docker://data.forgejo.org/oci/node:24-trixie
cache:
enabled: true
container:
# No privileged containers, no Podman socket and no server folders in jobs
privileged: false
docker_host: "-"
valid_volumes: []
server:
connections:
forgejo:
url: https://git.example.com/
uuid: 00000000-0000-0000-0000-000000000000
token: 0000000000000000000000000000000000000000
Then make it readable for runner only, since it holds the token:
chmod 600 ~/runner-config.yml
What the settings do:
- A workflow picks a runner with
runs-on:. Both labels run the job in a container fromdata.forgejo.org/oci/node:24-trixie, Debian 13 with Node.js, Git and Python, which Forgejo copies from Docker Hub.ubuntu-latestlets workflows written for GitHub run as they are, in this image, which has much less installed than GitHub's own machines. capacity: 2suits two vCPUs. Raise it on a bigger server, or for jobs that mostly wait.cachestarts the cache server thatactions/cacheuses, on a random port on the server. ufw keeps that port closed to the internet.- The
containerlines are the defaults, written out so you can see them: jobs run without privileged mode, cannot reach the runner user's Podman, and cannot mount folders from the server. Step 7 explains what that gives.
forgejo-runner generate-config prints every setting, with comments.
6. Start it
Create ~/.config/systemd/user/forgejo-runner.service:
mkdir -p ~/.config/systemd/user
[Unit]
Description=Forgejo Runner, which runs Forgejo Actions jobs in rootless Podman
After=podman.socket
Wants=podman.socket
[Service]
ExecStart=/usr/local/bin/forgejo-runner daemon -c %h/runner-config.yml
WorkingDirectory=%h
Restart=always
RestartSec=10
# A little longer than shutdown_timeout in runner-config.yml
TimeoutStopSec=15min
[Install]
WantedBy=default.target
Start it:
systemctl --user daemon-reload
systemctl --user enable --now forgejo-runner
journalctl --user -u forgejo-runner
The log says declared successfully, with the labels, and the runner page in Forgejo shows it as Idle. After changing runner-config.yml, use systemctl --user restart forgejo-runner. Do not use reload: it stopped the runner in our tests.
7. What a job can and cannot do
We checked from inside a job. Each job gets a new container on a Podman network of its own, and the runner removes both when the job ends. Root inside the container is the runner user outside it, so a job cannot read Forgejo's files or those of any other app on the server. It cannot reach the Podman socket, so it cannot start or stop containers on the server. Mounting a file system was refused. Jobs reached the internet, the server's public ports, such as 22 and 443, as any visitor would, and the runner's cache. Apps that listen only on 127.0.0.1, such as Forgejo itself on port 8089, could not be reached.
What it does not give:
- Jobs share one kernel with the server. A container is not a virtual machine. A flaw in the kernel could let a job out, as the
runneruser, with the runner's token. Keep code you do not trust off this server, or give it a runner on a server of its own (step 14). - Anyone who can push can read the secrets. A workflow in the repository gets its secrets, and a changed workflow can send them anywhere. Forgejo hides a secret's exact value in logs, not a copy of it in another form.
- Pull requests from forks wait for you. Forgejo held such a run until we chose Approve once or Approve always on the pull request, or Deny. Once approved, the job got none of the repository's secrets, and its token could not push, edit the repository or add labels. It still runs the fork's code on your server, with internet access, and sees the repository's variables. Read the change before you approve it.
- Privileged mode stays off. Nothing in this guide needs it, not even building images in step 10.
8. Test a project
This part is on your computer, in the hello repository from step 9 of the Forgejo guide. Add a small Node.js app with a test. Create package.json:
{
"name": "hello",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test",
"start": "node server.js"
}
}
greet.js:
import ms from 'ms';
export function greet(name, uptimeMs) {
return `Hello, ${name}! Up for ${ms(uptimeMs, { long: true })}.`;
}
greet.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greet } from './greet.js';
test('greets by name', () => {
assert.equal(greet('Anna', 60000), 'Hello, Anna! Up for 1 minute.');
});
server.js:
import http from 'node:http';
import { greet } from './greet.js';
const started = Date.now();
http.createServer((req, res) => {
res.end(greet('world', Date.now() - started) + '\n');
}).listen(8080);
And .gitignore:
node_modules/
*.tgz
With Node.js installed, run npm install ms@2, which adds the one dependency and writes package-lock.json. Then create .forgejo/workflows/ci.yml:
on:
push:
pull_request:
schedule:
- cron: '30 3 * * 1'
workflow_dispatch:
jobs:
test:
runs-on: docker
strategy:
matrix:
node: [22, 24]
container:
image: data.forgejo.org/oci/node:${{ matrix.node }}-trixie
steps:
- uses: actions/checkout@v6
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
- run: npm ci
- run: npm test
- run: npm pack
- uses: forgejo/upload-artifact@v5
with:
name: hello-node${{ matrix.node }}
path: '*.tgz'
Commit and push everything:
git add .
git commit -m "Add an app, tests and CI"
git push
The repository's Actions tab shows the run, with one job per Node.js version, and each step's output. What the workflow does:
on:starts it on every push and pull request, every Monday at 03:30, and from the Run workflow button, which appears on the Actions tab when you selectci.yml. Forgejo reads the schedule in UTC, and runs it on the default branch.matrixruns the job once for each Node.js version, at the same time, andcontainer:picks the image for each. Without it, the job runs in the image of the runner's label.actions/cachesaves~/.npmwhen the job ends and restores it the next time, whilepackage-lock.jsonis unchanged. The second run's log saysCache restored from key.forgejo/upload-artifactkeeps the package thatnpm packmade. The run's page lists it under Artifacts, to download as a ZIP file. Forgejo keeps artifacts for 90 days.
9. Secrets and variables
Passwords and keys that jobs need go in secrets. In the repository, open Settings > Actions > Secrets, and choose Add secret. Settings that are not secret, such as an address, go under Variables > Add variable. A workflow reads them as ${{ secrets.NAME }} and ${{ vars.NAME }}:
- run: ./upload.sh "$TARGET"
env:
TARGET: ${{ vars.UPLOAD_TARGET }}
API_KEY: ${{ secrets.API_KEY }}
Pass them through env:, as here, rather than writing them into the command itself, so that a value with quotes or spaces cannot change the command. Forgejo replaces a secret's value with *** in the log. Nobody can read a secret back in Forgejo once it is saved, only replace it. The next two steps use both.
10. Build a container image
A job can build your app's image and push it to Forgejo's own package registry, so that you can run it anywhere with podman pull git.example.com/anna/hello.
The job's automatic token could log in to the registry, but not push: Forgejo answered authentication required. Make a token that can. In Forgejo, open your avatar's menu, Settings > Applications > Generate new token. Name it ci-packages, leave Repository and organization access at All, set package to Read and write under Select permissions, and choose Generate token. Copy it, and in the hello repository add it as the secret REGISTRY_TOKEN.
On your computer, add a Containerfile to the repository:
FROM docker.io/library/node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY *.js ./
USER node
EXPOSE 8080
CMD ["node", "server.js"]
And .containerignore, which keeps those folders out of the image:
.git
node_modules
Add this job at the end of ci.yml, indented by two spaces like test::
image:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker
steps:
- uses: actions/checkout@v6
- name: Install Buildah
run: apt-get update -qq && apt-get install -y -qq buildah > /dev/null
- name: Build the image
run: buildah build --storage-driver vfs --isolation chroot -t hello .
- name: Push it to Forgejo
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
registry=${GITHUB_SERVER_URL#https://}
echo "$TOKEN" | buildah login -u "$GITHUB_ACTOR" --password-stdin $registry
buildah push --storage-driver vfs hello $registry/$GITHUB_REPOSITORY:${GITHUB_SHA::7}
buildah push --storage-driver vfs hello $registry/$GITHUB_REPOSITORY:latest
needs: test builds only when the tests pass, and the if: line only for pushes to main. Commit and push. The image appears in Forgejo under your profile's Packages tab, as hello, with two tags: latest and the commit's short ID.
Buildah builds images without a background service, and here without any extra rights. Debian's buildah package is installed in each job, which adds about half a minute. --storage-driver vfs stores the layers as plain folders: the overlay driver needs /dev/fuse, which a job does not have, and failed with fuse: device not found. --isolation chroot runs the RUN lines without starting a container inside the container. In our test the whole image job took about 75 seconds.
The image is as public as your account. If your account or organisation is private, so is the image, and whoever pulls it must log in, as in step 11.
11. Deploy over SSH
The last job logs in to the server over SSH and starts the new image. The key it uses can run one command, and nothing else: no shell, no other command, no port forwarding.
Run the app. As root, create a user for it:
useradd -m -s /bin/bash hello
loginctl enable-linger hello
machinectl shell hello@
As hello:
mkdir -p ~/.config/containers/systemd
Create ~/.config/containers/systemd/hello.container:
[Unit]
Description=Hello, deployed from Forgejo Actions
[Container]
ContainerName=hello
Image=git.example.com/anna/hello:latest
# Only Caddy, on this server, can reach the app
PublishPort=127.0.0.1:8121:8080
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
If the image is private, first log in with a token that can read packages, made as in step 10 with package set to Read. Saved in ~/.config/containers/auth.json, the login lasts across reboots:
podman login --authfile ~/.config/containers/auth.json git.example.com
Start it:
systemctl --user daemon-reload
systemctl --user start hello
curl http://127.0.0.1:8121/
It answers Hello, world! Up for 2 seconds. Go back to root with exit, switch to machinectl shell caddy@, add this block at the end of ~/Caddyfile, and restart Caddy with systemctl --user restart caddy:
hello.example.com {
reverse_proxy 127.0.0.1:8121
}
Make the deploy key. On your computer:
ssh-keygen -t ed25519 -N '' -C deploy-hello -f hello-deploy
This makes hello-deploy, the private key, and hello-deploy.pub. As root on the server, switch to machinectl shell hello@ and create ~/.ssh/authorized_keys:
install -d -m 700 ~/.ssh
nano ~/.ssh/authorized_keys
Put in one line: the text below, a space, and the whole of hello-deploy.pub:
restrict,command="podman auto-update && podman image prune -f" ssh-ed25519 AAAA... deploy-hello
chmod 600 ~/.ssh/authorized_keys
Whatever a login with this key asks for, the server runs podman auto-update, which pulls a newer latest and restarts the app, and then removes the old image. restrict turns off port forwarding and the rest. In our test, a login with the key that asked for rm -rf ~ ran podman auto-update instead, and a port forward was refused.
Give Forgejo the key and the server's address. In the hello repository, add the whole of hello-deploy, from -----BEGIN to the last line, as the secret DEPLOY_KEY. Then delete hello-deploy from your computer, or keep it in your password manager. Add two variables: DEPLOY_HOST, with the server's address, 203.0.113.10, and DEPLOY_KNOWN_HOSTS, with the line this prints as root on the server:
echo "203.0.113.10 $(cut -d' ' -f1,2 /etc/ssh/ssh_host_ed25519_key.pub)"
That is the server's own SSH key, so the job can check that it reached the right server.
Add the job at the end of ci.yml:
deploy:
needs: image
runs-on: docker
steps:
- name: Deploy over SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key hello@${{ vars.DEPLOY_HOST }}
Change 'world' in server.js to your name, commit and push. The run tests, builds, pushes and deploys, in about two minutes, and the deploy job's log ends with a table where UPDATED is true. https://hello.example.com shows the new text.
The app can live on another server just as well: create the user and the key there, and put that server's address and SSH key in the two variables.
12. Actions from GitHub's marketplace
uses: actions/checkout@v6 names no server. Forgejo then fetches the action from https://data.forgejo.org, which keeps copies of about 650 popular action repositories: actions/*, docker/* and the like, but not all of them. For any other action, write the full address:
- uses: https://github.com/actions/github-script@v7
- uses: https://code.forgejo.org/actions/checkout@v5
We tested:
actions/checkoutv5 and v6,actions/cachev4 and v5, andactions/github-scriptfrom GitHub worked.actions/upload-artifactv4 and later stopped with "not currently supported on GHES". Forgejo's own copy,forgejo/upload-artifact, worked in v4 and v5, andforgejo/download-artifact@v4fetched an artifact in a later job.- Actions that run the
dockercommand, such asdocker/login-action, failed with "Unable to locate executable file: docker". The job image has no Docker, and jobs cannot reach Podman. Build images as in step 10 instead.
To fetch actions without a server from GitHub instead, add this line to forgejo.container from the Forgejo guide, as forgejo, and restart Forgejo:
Environment=FORGEJO__actions__DEFAULT_ACTIONS_URL=https://github.com
Forgejo saves it in its own settings file. To go back, change it to https://data.forgejo.org: removing the line is not enough.
13. Capacity and cleanup
capacity in runner-config.yml is how many jobs run at once. The matrix in step 8 used both places, and the server used about 450 MB more memory while they ran. Other jobs wait for a free place, and show as waiting on the Actions tab.
The runner removes each job's container and network when the job ends. What stays is the job images, about 1.3 GB each, and the actions it has downloaded, in ~/.cache/act. As runner, a weekly timer removes the images no job is using. Create ~/.config/systemd/user/runner-cleanup.service:
[Unit]
Description=Remove job images and build leftovers no job is using
[Service]
Type=oneshot
ExecStart=/usr/bin/podman system prune --all --force
And ~/.config/systemd/user/runner-cleanup.timer:
[Unit]
Description=Weekly cleanup of the runner's images
[Timer]
OnCalendar=Sun 04:30
Persistent=true
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now runner-cleanup.timer
The next job after a cleanup downloads its image again, so you also get the newest one each week. podman system df shows the space used. On the app's side, the podman image prune -f in the deploy key's command removes each replaced image.
14. A second runner on another server
When jobs need more room, or code you do not trust should stay away from Forgejo, add a runner on another server. Set that server up as in the Podman guide, with ufw, and do steps 2 to 6 there. It connects out to https://git.example.com, so it needs no open port. In step 4, create a new runner: each one has its own token. Give it a label of its own, such as big:docker://data.forgejo.org/oci/node:24-trixie, and workflows that say runs-on: big run there only. Each runner has its own cache. We did not test this setup.
15. What the runner connects to
Between jobs, the runner only asks Forgejo for work, every two seconds. We recorded the server's lookups and connections during the runs in this guide:
data.forgejo.org, for actions with short names and for the job images;github.comandcode.forgejo.org, for actions with those addresses;- what the jobs themselves fetched:
registry.npmjs.orgfornpm ci,deb.debian.orgfor Buildah, and Docker Hub for theFROMline in theContainerfile; - Forgejo, at
git.example.com, for the code, the cache keys, the artifacts and the registry.
The runner downloads each action the first time a job uses it, and keeps it in ~/.cache/act.
16. Back up
As runner:
umask 077
mkdir -p ~/backup
tar -czf ~/backup/runner.tar.gz runner-config.yml .config/containers/containers.conf .config/systemd/user
That is all the runner needs: its settings, with the token, and its services. Copy ~/backup to another machine and keep it private. Workflows, logs, artifacts, secrets and variables are in Forgejo, and in its backup.
To restore, do steps 2 and 3 up to machinectl shell runner@, copy the backup to ~/backup, and as runner:
tar -xzf ~/backup/runner.tar.gz
systemctl --user daemon-reload
systemctl --user enable --now forgejo-runner runner-cleanup.timer
The log says declared successfully again. If the token is lost, delete the runner in Forgejo and create a new one as in step 4.
17. Update
For a new runner version, repeat step 2 with its number, then as runner:
systemctl --user restart forgejo-runner
The log shows the new version. Running jobs get up to ten minutes to finish first. Read the release notes on code.forgejo.org/forgejo/runner/releases before a new major version, such as 14, which can change settings. The job images are renewed by the weekly cleanup in step 13, Buildah is installed fresh in each job, and an action named @v6 gets every release within version 6. Forgejo itself is updated as in the Forgejo guide.
Troubleshooting
A job waits and never starts. No runner has the label in its runs-on: line, or the runner is offline. Check the label, and that the runner shows as Idle at /admin/actions/runners. As runner, journalctl --user -u forgejo-runner shows why it stopped.
The checkout fails with Failed to connect to git.example.com port 443. The job container shares the server's address. Check ~/.config/containers/containers.conf from step 3, as runner.
upload-artifact fails with "not currently supported on GHES". Use forgejo/upload-artifact@v5 in place of actions/upload-artifact.
Buildah fails with fuse: device not found. --storage-driver vfs is missing on that command.
The push fails with authentication required. The token cannot write packages, or the job used its automatic token. Use a token as in step 10, in the REGISTRY_TOKEN secret.
The deploy fails with Host key verification failed. DEPLOY_KNOWN_HOSTS does not match the server's address in DEPLOY_HOST, or the server's SSH key has changed. Print the line again as in step 11.
The runner stopped after systemctl --user reload. Start it with systemctl --user start forgejo-runner, and use restart after changing its settings.