Forgejo Actions runner v13.2.1 plus docker:29-dind held 42.4 MiB idle on this lab host after one workflow on labadmin/lab-actions went green. That is cgroup memory.current for forgejo-runner and forgejo-dind, the median of twenty samples taken 30 seconds apart with a quiet poller. docker stats printed 30.7 MiB for the pair. The split was about 6.9 MiB for the runner and 35.3 MiB for DinD. CPU percent sat at 0.11. Site admin → Actions → Runners listed lab-runner as idle.
Forgejo Actions is GitHub Actions–compatible CI built into Forgejo: workflow YAML under .forgejo/workflows (or .github/workflows), jobs that runs-on labeled runners, and logs in the forge UI. Without a runner, Actions is only a switch. The runner process polls the forge; job steps usually run inside Docker-in-Docker so the workflow’s container: / services: blocks work without mounting the host socket.
The Forgejo Docker install left Actions without a runner, so that forge alone sat near 110 MiB. Official runner Compose uses a privileged DinD sidecar and forgejo-runner daemon. Most paste-ready guides stop when register prints success. The install is real when Actions is enabled, a runner shows online, job containers can reach the forge, and one .forgejo/workflows push finishes green, with the host docker socket left off the runner.
| Reading | Value |
|---|---|
cgroup memory.current (runner+dind) | 42.4 MiB, median of 20 |
docker stats (pair) | 30.7 MiB |
| Runner / DinD split (cgroup) | 6.9 / 35.3 MiB |
| CPU percent | 0.11, median |
| Runner status | idle (online), label docker |
| After proof | Workflow run success; log Job succeeded |
| Images | code.forgejo.org/forgejo/runner:13.2.1, docker:29-dind |
| Data backup | stopped tar of data/ ~4.6 KB |
| Skip this | What this run did |
|---|---|
Mount host /var/run/docker.sock | Privileged DinD on TCP 2375 inside Compose |
| Actions left disabled | FORGEJO__actions__ENABLED=true |
Stopping at register success | Pushed a workflow until the job was green |
Floating :latest / :13 | Pinned 13.2.1 by digest |
| Job container off the forge network | container.network: host so DinD jobs share the forge path |
| Trusting the first Hub pull from DinD | Loaded alpine:3.20 into DinD before the green run |
How do you enable Actions on Forgejo?
The forge ignores runners until Actions is on. Set it before the first registration:
# compose environment (or app.ini [actions])
FORGEJO__actions__ENABLED: "true"
FORGEJO__actions__DEFAULT_ACTIONS_URL: github[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = githubRestart Forgejo and open site admin → Actions → Runners. DEFAULT_ACTIONS_URL=github lets workflows pull public actions from GitHub when you need them. This lab’s workflow used only run: steps.
How do you register the runner?
Create a data directory owned by uid 1001 (the image user), generate config, then register against a forge URL the runner can reach on the shared Docker network:
mkdir -p ~/forgejo-runner/data/.cache
sudo chown -R 1001:1001 ~/forgejo-runner/data
chmod 775 ~/forgejo-runner/data/.cache
chmod g+s ~/forgejo-runner/data/.cache# on the Forgejo host
docker exec -u git forgejo forgejo actions generate-runner-tokendocker run --rm --user 1001:1001 -v "$PWD/data:/data" -w /data \
code.forgejo.org/forgejo/runner:13.2.1 \
forgejo-runner generate-config > data/config.yml
docker run --rm --user 1001:1001 --network forgejo \
-v "$PWD/data:/data" -w /data \
code.forgejo.org/forgejo/runner:13.2.1 \
forgejo-runner register --no-interactive \
--instance http://forgejo:3000 \
--token YOUR_REGISTRATION_TOKEN \
--name lab-runner \
--labels 'docker:docker://alpine:3.20'--instance must resolve from the register container. On the same Compose network that is the Forgejo service name. Behind Caddy use https://git.example.com. Workflows put that label name in runs-on (docker here). This lab used alpine:3.20 after DinD hit a Hub TLS timeout on node:22-bookworm.
In data/config.yml, point job containers at a network path that reaches the forge from inside DinD:
container:
network: "host"With DinD already on the forge’s Compose network, host is the DinD container’s network namespace, which already sees forgejo:3000. Leave network empty only when jobs get another route to ROOT_URL.
register writes data/.runner. Upstream marks interactive register as deprecated in favor of connections declared in config; the file still works for daemon. Crop tokens from screenshots and shell history.

How do you run DinD and the daemon?
Images are code.forgejo.org/forgejo/runner:13.2.1 (digest sha256:568cea8c15ddc01fc8f330124892f572af7db37fa28344a2c1914405793bf61d) and docker:29-dind (digest sha256:d035bd30886e451b2727ef3cdb9af9a5c537a10d4bc5d18c08d0ffa597e46aeb). Put both on the same network as Forgejo:
networks:
forgejo:
name: forgejo
services:
docker-in-docker:
image: docker:29-dind@sha256:d035bd30886e451b2727ef3cdb9af9a5c537a10d4bc5d18c08d0ffa597e46aeb
container_name: forgejo-dind
privileged: true
restart: unless-stopped
command: ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false']
networks: [forgejo]
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
runner:
image: code.forgejo.org/forgejo/runner:13.2.1@sha256:568cea8c15ddc01fc8f330124892f572af7db37fa28344a2c1914405793bf61d
container_name: forgejo-runner
restart: unless-stopped
user: "1001:1001"
depends_on:
docker-in-docker:
condition: service_started
environment:
DOCKER_HOST: tcp://docker-in-docker:2375
volumes:
- ./data:/data
working_dir: /data
networks: [forgejo]
command: forgejo-runner daemon --config config.yml
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Privileged DinD is the isolation boundary the official docs choose instead of handing the host socket to every job. TLS-off on 2375 is fine inside a private Compose network; tighten that when DinD is reachable beyond the stack. Log rotation matches the Compose checklist.
docker compose up -d
docker logs -f forgejo-runnerA healthy start logs declared successfully and [poller] launched. The runners page should show lab-runner idle/online.
Pre-load the job image into DinD before the first push if Hub is slow from that daemon:
docker pull alpine:3.20
docker save alpine:3.20 \
| docker exec -i -e DOCKER_HOST=tcp://127.0.0.1:2375 forgejo-dind docker load

What does one green workflow prove?
Add a workflow under .forgejo/workflows/ and push:
# .forgejo/workflows/lab.yml
name: lab
on:
push:
branches: [main]
jobs:
hello:
runs-on: docker
steps:
- run: echo "forgejo actions lab ok"
- run: uname -agit add .forgejo/workflows/lab.yml
git commit -m 'add forgejo actions lab workflow'
git push origin mainruns-on: docker must match the label from register. A success row with Job succeeded in the log means the poller took the task, DinD started the job image, and the steps ran. Registration only shows the forge accepted the token. This lab’s first attempts failed pulling node:22-bookworm through DinD; after loading alpine:3.20 and pointing the label at it, run #4 finished green.


How do you back up runner data?
./data holds .runner, config.yml, and cache. Stop the runner, archive, start again:
docker compose stop runner
tar -C ~/forgejo-runner -czf runner-data-$(date +%F).tar.gz data
docker compose start runnerOn this lab the stopped archive was about 4.6 KB. Point Restic at that tarball. Losing .runner means a new registration token and a new runner row on the forge.

How do you keep Actions runners sane?
Pin runner and DinD versions. Prefer container.network: host only when the lab proved Hub TLS needed it; document why. Do not mount the host docker socket into untrusted workflows. Labels in runner-config.yml are name:docker://image and must match runs-on (Forgejo runner config); pin digests instead of floating tags so jobs stay reproducible. Avoid host label types unless you accept no isolation. Images pulled once are never refreshed by the runner; rebuild or retag when you need updates. Register at repo, user, org, or instance scope depending on who should share the runner (registration). Clear unused DinD images on a timer.
FAQ
Actions or Woodpecker? Woodpecker on the Forgejo host is a separate CI with OAuth and its own UI. Actions stays inside Forgejo and needs this runner process. One CI path per repo keeps the failure story short.
Host docker.sock instead of DinD? It works and skips privileged DinD, but every job inherits the host engine. Prefer DinD on a VPS that already runs unrelated containers.
Clone fails from the job? ROOT_URL and the job container network must agree. Same-host DinD often needs container.network: host or a shared network the DinD daemon can attach. The clone URL in the job log is the first place to look.
Runner on a second host? Register with the public forge URL, point DOCKER_HOST at that host’s DinD or socket, and drop the shared Compose network assumption.
GitHub’s actions/runner? That binary talks to github.com. The VPS path for it is GitHub Actions on a VPS.
