Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/forgejo-actions-runners-self-hosted/
You’ve already got Forgejo running on your own iron. Your code, your server, your rules. And then you open a CI tab and… reach for GitHub Actions anyway. It’s fine, until it isn’t. Until you’re hitting rate limits, or your build needs to touch internal services, or you’d just rather not feed your pipeline config to a third party.
Forgejo has its own CI system, Forgejo Actions, and it runs YAML that looks a lot like what you already know. Most GitHub Actions workflows drop in with small changes. You keep the muscle memory, and your CI runs on hardware you own. Let’s wire it up.
Versions in this article: Forgejo 16.0.3 and Forgejo Runner 13.0.0, current as of August 2026. The runner moves fast, so check the tag before you copy a Compose file off the internet, including this one.
How Forgejo Actions Works
Forgejo Actions shares its ancestry with Gitea Actions, because Forgejo forked Gitea. The piece that runs your build is a separate program called Forgejo Runner, installed and configured on its own.
You may see it called act_runner in older guides. That is Gitea’s name for its fork. Forgejo’s binary is forgejo-runner, and the two have drifted enough that Gitea instructions will lead you wrong.
The mental model:
- Forgejo server: watches for commits, PRs and schedules, queues jobs, stores logs and artifacts
- Forgejo Runner: asks the server for pending jobs, runs them, streams logs back
- Docker or LXC: each job runs in a container the runner creates
The runner talks to Forgejo over HTTP or HTTPS. It only makes outbound connections, so it needs no inbound ports. You can run one runner or twenty, on one box or spread across machines. Labels on the runner control which jobs it picks up, which is how you route “build on ARM” or “deploy from the trusted runner only.”
Workflow file location
Forgejo reads .forgejo/workflows/. If that directory is missing, it falls back to .github/workflows/. An imported GitHub repo therefore runs without you touching anything. Rename the directory for tidiness if you like. Nothing requires it.
What’s Familiar (and What Isn’t)
Worth setting expectations properly, because the Forgejo project is blunt about this: Forgejo Actions is designed to be familiar to GitHub Actions users, not compatible with GitHub Actions. Most of what you write carries over. The gaps are small and specific.
Works:
actions/checkout,actions/setup-node,setup-python,setup-goactions/cache(the runner ships its own cache server)docker/build-push-action- Custom shell steps, matrix builds,
needs:,workflow_dispatch, cron schedules - Reusable workflows via
uses: ./.forgejo/workflows/reusable.yml - Secrets and variables at repo, user and org level
secrets.GITHUB_TOKENandgithub.token, which Forgejo aliases toFORGEJO_TOKENandforgejo.token. Both spellings resolve to the same token, injected into every job.
Doesn’t work, or needs a workaround:
permissions:andcontinue-on-error:on a job are silently ignored- Some keys in the
githubcontext are missing actions/upload-artifactanddownload-artifactneed v3, or a patched v4- OIDC token generation uses an
enable-openid-connectkey, notpermissions: id-token: write - Actions that call the GitHub API directly, for obvious reasons
- The GitHub Marketplace. Actions resolve against
DEFAULT_ACTIONS_URL, which defaults tohttps://data.forgejo.org. That mirror covers the common ones and is licensed as free software, but the long tail is not there. - Hosted runner specs. You’re on your own hardware now.
For internal projects and home lab pipelines the gap is rarely a blocker. What breaks is usually GitHub-specific glue, not your actual build steps.
The Docker Socket Trap
Before the Compose file, the thing that wastes an evening.
Most guides tell you to mount /var/run/docker.sock into the runner so it can create job containers. The official runner image runs as uid 1000 and is not in the host docker group, so that mount gets you permission denied on startup. The obvious fix, adding the runner to the docker group, hands a process that executes arbitrary code from your repositories root-equivalent access to the host.
Use a Docker-in-Docker sidecar instead. Job containers live in their own daemon, the runner reaches it over TLS, and a compromised build gets a throwaway daemon rather than your machine.
volumes: docker_certs:
services: docker-in-docker: image: data.forgejo.org/oci/docker:dind # TLS certs are only valid for the hostname `docker` or `localhost`. hostname: docker privileged: true environment: DOCKER_TLS_CERTDIR: /certs volumes: - docker_certs:/certs restart: unless-stopped
runner: image: data.forgejo.org/forgejo/runner:13.0.0 depends_on: - docker-in-docker user: 1000:1000 environment: DOCKER_HOST: tcp://docker:2376 DOCKER_CERT_PATH: /certs/client DOCKER_TLS_VERIFY: "1" volumes: - ./data:/data - docker_certs:/certs restart: unless-stopped command: forgejo-runner --config config.yml daemonThe dind service still runs privileged. That is the cost of building containers in CI, and it is a smaller blast radius than the host daemon.
One prerequisite before docker compose up: the runner writes its config, cache and job workspaces into /data as uid 1000, so the bind mount has to be owned by it.
mkdir -p data/.cachesudo chown -R 1000:1000 datachmod 775 data/.cache && chmod g+s data/.cacheSkip that and the daemon exits on the first write.
The runner also has to pass the TLS certs through to each job container, or docker/build-push-action inside a job can’t reach the daemon. That part lives in the config file you’re about to create:
runner: envs: DOCKER_HOST: tcp://docker:2376 DOCKER_TLS_VERIFY: "1" DOCKER_CERT_PATH: /certs/client
container: network: host options: -v /certs/client:/certs/client valid_volumes: - /certs/client docker_host: "-"Note which block each key lives in. envs goes under runner:, and there is no container.envs in Runner 13, whatever an older guide tells you. docker_host: "-" tells the runner to leave DOCKER_HOST alone rather than auto-injecting a socket path over the value above.
network: host is the line people leave out, and the failure it causes looks like anything but a network setting. Job containers are created by the dind daemon, so they land on dind’s own bridge, where the name forgejo means nothing. actions/checkout then sits there retrying git fetch for four and a half minutes per attempt before the job dies. host puts job containers in the dind container’s network namespace, which is on your Compose network, and checkout resolves the server immediately.
Note there’s no FORGEJO__actions__ENABLED=true anywhere. Actions has been on by default since Forgejo v1.21. You only touch that setting to turn it off.
Registering the Runner
Registration gives the runner a UUID and a token, which work like a username and password. The old forgejo-runner register command still exists but is deprecated. The current flow puts the credentials in the config file.
First, generate a config file:
docker run --rm data.forgejo.org/forgejo/runner:13.0.0 \ forgejo-runner generate-config > data/config.ymlThen create the runner in Forgejo’s web UI. Where you create it decides what it can run:
| Scope | Path |
|---|---|
| Whole instance | /admin/actions/runners |
| Organization | /org/{org}/settings/actions/runners |
| User | /user/settings/actions/runners |
| Single repository | /{owner}/{repo}/settings/actions/runners |
Click Create new runner, give it a name, and copy the UUID and Token from the dialog. Paste them into data/config.yml:
server: connections: forgejo: # this key is just a name, pick anything url: https://git.example.com/ uuid: 33834eef-e758-48c4-a676-1745426747aa token: d4fe2db46a4c6bdc434a9ce3378d9a1489c1b30eOne runner can hold several connections, to different instances or to different orgs on the same instance. Add another block per connection.
If you’re deploying with Ansible or Kubernetes and a manual copy-paste step is unacceptable, use offline registration instead: generate a 40-character hex secret yourself, register it on the Forgejo host with forgejo forgejo-cli actions register --secret <secret>, and put that same secret in the config as the token. It needs admin access to the server, so it’s not an option on a hosted instance like Codeberg.
Labels: Where the Routing Lives
Labels map a workflow’s runs-on: value to a container image, and they live in the runner’s config file. Old Gitea guides put them on the registration command line, and people then hunt for them in Forgejo’s admin UI when that fails. This is the single most common thing to get wrong here.
runner: capacity: 1 labels: - ubuntu-latest:docker://data.forgejo.org/oci/node:22-bookworm - node22:docker://docker.io/library/node:22-alpine - arm64:docker://docker.io/library/node:22-bookworm?platform=linux/arm64 - ci:docker://git.example.com/you/ci-runner:latestThe format is <label-name>:<label-type>://<image>. The type is one of three:
dockerruns steps in a container, which is what you want almost alwayslxcruns them in an LXC container, useful when a job needs systemd or nested virtualisationhostruns them in a shell on the runner itself, with no isolation whatsoever. A single job can permanently destroy the box. Reserve it for a machine you’d happily rebuild.
Architecture goes in a query string on the image. ?platform=linux/arm64 works. A bare linux/amd64 sitting in the label list does nothing at all.
The image has to carry the tools
The label image is the whole job environment, and two things bite here at once.
Every JavaScript action, actions/checkout included, is executed with node inside your job container. Pick docker:cli for a build job and checkout fails with exec: "node": executable file not found in $PATH. Pick node:22-bookworm and your docker build step fails with docker: command not found. Neither image is wrong. They just each carry half of what a build job needs.
For jobs that check out code and build images, use an image with both:
FROM data.forgejo.org/oci/node:22-bookwormRUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ && install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ && chmod a+r /etc/apt/keyrings/docker.asc \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \ > /etc/apt/sources.list.d/docker.list \ && apt-get update \ && apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \ && rm -rf /var/lib/apt/lists/*Build it, push it to your Forgejo container registry, and point a label at it:
- ci:docker://git.example.com/you/ci-runner:latestTen minutes of setup that saves you from debugging command not found in a CI log at 2 AM.
A single runner can serve several runs-on: targets this way, so you rarely need more than one runner per machine.
Where multiple labels earn their keep is routing to specific hardware. Put both labels in a job’s runs-on: array and only a runner declaring both will take it:
jobs: train: runs-on: [docker, gpu]That’s how you keep the model build on the box with the GPU, and how you keep community PRs on a sandbox runner that holds no credentials while internal deploys go to a trusted one.
Your First Workflow
Build a Node app, run tests, build a Docker image.
name: Build and Test
on: push: branches: - main - "feature/**" pull_request: branches: - main
jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6
- name: Setup Node uses: actions/setup-node@v4 with: node-version: "22"
- name: Cache dependencies uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-
- name: Install deps run: npm ci
- name: Run tests run: npm test
build-image: runs-on: ci needs: test if: github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v6
- name: Build Docker image uses: docker/build-push-action@v6 with: context: . push: false tags: myapp:${{ github.sha }}runs-on: ubuntu-latest matches the label defined above, so the test job lands in a node:22-bookworm container. The build job asks for ci instead, because it needs the docker CLI as well as node. That image is a lot leaner than GitHub’s ubuntu-latest, which carries a large toolchain preinstalled. If a step assumes some binary is present, either pick a fatter image for the label or install it in a run: step.
Secrets work identically: define them under repo Settings, reference them as ${{ secrets.MY_SECRET }}.
Caching That Actually Caches
actions/cache works because the runner runs its own cache server and passes ACTIONS_CACHE_URL into job containers. Nothing goes to the Forgejo server.
cache: enabled: true dir: "" # empty means $HOME/.cache/actcache, which is under /data port: 0 # 0 picks a free port external_server: "" # point several runners here to share one cache secret: "" # required when external_server is set host: "" # hostname used to build ACTIONS_CACHE_URLTwo of these get misread often. dir is where cache data is written on disk, and leaving it empty does not disable anything. host is the address job containers use to reach the cache proxy, autodetected when empty; set it when the Docker daemon creating job containers sits on a different network than the runner. The field for pointing at a different cache server is external_server, and setting it stops the runner spawning its own.
For Docker layer caching, run a registry in the same stack and let BuildKit push cache to it:
- name: Build with cache uses: docker/build-push-action@v6 with: context: . push: false cache-from: type=registry,ref=registry.local/myapp:buildcache cache-to: type=registry,ref=registry.local/myapp:buildcache,mode=maxFirst build pays full price. Every one after is meaningfully faster.
When You’d Still Keep GitHub
Forgejo Actions is strong for closed-source internal work, home lab automation, and small teams whose whole contributor base can reach the Forgejo instance.
It gets awkward when:
- You’re open source and want community PRs. Contributors can’t see CI results on a private instance. Replicating GitHub’s public runner visibility means exposing Forgejo publicly, and then you’re accepting untrusted code onto your runners, which needs ephemeral runners and a hard look at the security docs.
- You need the long tail of the Marketplace. The
data.forgejo.orgmirror covers common actions. Niche cloud-provider deploy actions and specialised test frameworks are not mirrored, so you write more shell. - Your build needs hosted runner specs. If a compile wants 64 vCPUs and your NAS is the biggest thing you own, you’ll notice.
- You depend on GitHub-native integrations. Codecov, dependency graph, some security scanners: all wired to github.com. Most have workarounds. All are friction.
For everything else, build, test, deploy, lint, scan, release, Forgejo Actions holds up. Your 2 AM “why is CI broken” energy is exactly the same either way; at least now it’s your runner’s fault.
Practical Notes Before You Go Live
Pin the runner tag. Use runner:13.0.0, not runner:13. The runner ships releases frequently and behaviour does shift between them. You don’t want a surprise on a Friday push.
Start repo-scoped. Register the runner against a single repository first, then move up to org or instance scope once you trust it. An instance-wide runner picks up jobs from every repo on the server, including any you add later.
Consider ephemeral mode. Pass --ephemeral at registration and the runner takes exactly one job before Forgejo deletes it. Use it for untrusted pull requests: no state carries between builds, so a poisoned cache or a leftover credential has nowhere to live.
The runner crash-loops for a few seconds on first boot. It starts before dind has finished generating its TLS certs, fails with could not read CA certificate "/certs/client/ca.pem", and gets restarted. Mine did it four times over three seconds and then came up clean. restart: unless-stopped is what makes that self-healing, so keep it.
Job logs go to disk, not the database. Forgejo writes them under actions_log/. If you want them somewhere else, that’s the [storage.actions_log] section of app.ini, not a key under [actions]. What you probably want to change is retention:
[actions]LOG_RETENTION_DAYS = 365ARTIFACT_RETENTION_DAYS = 90LOG_COMPRESSION = zstdBoth defaults are generous. On a busy instance, dropping logs to 30 days and artifacts to 14 saves real disk.
Container images are never updated once pulled. The runner does the equivalent of docker run <image>, so a :latest label keeps running whatever it pulled the first time. Pin image digests in labels if you care about reproducible jobs.
The Pitch
If you’re already self-hosting Forgejo, not running Forgejo Actions is leaving CI on the table. You have the server. The runner is one container plus a dind sidecar. Your existing workflows run in place, and the gaps are a short list you can read in one sitting.
It will not replace GitHub if you need public open-source CI. For internal projects, home lab automation, and any team that’s already made the jump to self-hosted git, it’s the obvious next step. Set it up once, forget about it, and enjoy watching your pipelines run on the machine under your desk.
Your forklift is already there. Might as well use it for something.
Common Questions
Do I need to rename .github/workflows to .forgejo/workflows?
No. Forgejo checks .forgejo/workflows/ first and falls back to .github/workflows/ when that directory does not exist. An imported GitHub repository runs its pipelines with no changes. Rename it only if you want the directory name to reflect where the CI actually runs.
Does secrets.GITHUB_TOKEN work in Forgejo Actions?
Yes. Forgejo injects FORGEJO_TOKEN into every job and aliases the GitHub spellings to it: secrets.GITHUB_TOKEN equals secrets.FORGEJO_TOKEN, and github.token equals forgejo.token. Workflows that authenticate against the local instance with the GitHub name keep working untouched.
Can I mount the host Docker socket into the Forgejo Runner?
Not without breaking it. The official runner image runs as uid 1000 and is not in the host docker group, so the mount fails with permission denied. Adding it to that group gives a process running untrusted repository code root-equivalent host access. Use a Docker-in-Docker sidecar instead.
Why does my job hang on “Fetching the repository”?
The job container cannot reach Forgejo. With a Docker-in-Docker setup, job containers are created by the dind daemon and land on its bridge, where the Forgejo service name does not resolve. Set network: host under container: in the runner config. That puts jobs in the dind container’s network namespace, which is on your Compose network.
How much hardware does a Forgejo Runner need?
Very little for the runner itself: a couple of hundred MB of RAM and one core handles capacity: 1 fine. Your jobs set the real floor. A Node test suite is happy on 2 GB, container builds want 4 GB and fast disk. Cache and job workspaces live under /data, so budget disk there.