All posts

A Forgejo Actions runner on the same host

Forgejo Actions runner v13.2.1: registration token, DinD, one green workflow on the forge host. Idle 42.4 MiB.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

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.

ReadingValue
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 percent0.11, median
Runner statusidle (online), label docker
After proofWorkflow run success; log Job succeeded
Imagescode.forgejo.org/forgejo/runner:13.2.1, docker:29-dind
Data backupstopped tar of data/ ~4.6 KB
Skip thisWhat this run did
Mount host /var/run/docker.sockPrivileged DinD on TCP 2375 inside Compose
Actions left disabledFORGEJO__actions__ENABLED=true
Stopping at register successPushed a workflow until the job was green
Floating :latest / :13Pinned 13.2.1 by digest
Job container off the forge networkcontainer.network: host so DinD jobs share the forge path
Trusting the first Hub pull from DinDLoaded alpine:3.20 into DinD before the green run
Bar chart. Idle cgroup memory.current 42.4 MiB and docker stats 30.7 MiB for Forgejo runner v13.2.1 plus DinD.
Idle after one green job. Extra capacity and larger job images sit above this. Forgejo RAM is separate.

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 = github

Restart 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-token
docker 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.

Register command excerpt with instance URL, token, alpine label, and container.network host.
Registration token once. Labels and network decide whether the first job can start.

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-runner

A 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
docker-compose.yml for docker:29-dind and forgejo/runner 13.2.1 on a shared forge network.
Pinned digests, DinD for jobs, runner user 1001, data volume for .runner.
Forgejo site admin Actions runners list showing lab-runner active with label docker.
Online runner after register and daemon. Status idle means waiting for jobs.

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 -a
git add .forgejo/workflows/lab.yml
git commit -m 'add forgejo actions lab workflow'
git push origin main

runs-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.

lab.yml workflow with runs-on docker and two run steps echoing forgejo actions lab ok.
Minimal workflow. No checkout required for the install proof.
Forgejo Actions job view showing success for hello with forgejo actions lab ok in the log.
Green job after push. Runner version v13.2.1 on the task header.

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 runner

On 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.

Stop runner, tar data into an archive about 4.6K, start again.
Stopped copy of data/. Keep .runner private; it is the runner credential.

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.