Fall 2026brianmars.comBuilt at EGBT Technologies
ReferenceCI

Forgejo Actions runner with dind

A CI runner whose job containers reach Forgejo at the same address the runner does, from inside a nested Docker daemon.

Names and addresses are placeholders. The list says what each one stands for.

What it does here

The runner connects out to Forgejo and runs each job in a container on its own Docker daemon (docker-in-docker, or dind). This card turns on one rule: the runner and its job containers must reach Forgejo at the same address, and that address has to work from inside dind.

From the manual

Canonical setup

Get a registration token from Site Administration, then Actions, then Runners, then Create new runner. Register the runner with it, then run the runner's daemon next to a dind service that executes the jobs.

Upstream: Forgejo Actions: installation with Docker.

Edited for this build

In this build

  1. Registered against the public URLhttps://git.dev.tailnet.example, not http://forgejo:3000. Job containers live on dind's own daemon, where the name forgejo means nothing.
  2. Runs as rootuser: "0:0", because the data folder is root-owned.
  3. An explicit commandThe image has no entrypoint, so daemon alone doesn't run and no command just prints help.
  4. The dind service is named dockerIts TLS certificate is issued for that name.
  5. DNS set twice for dindA dns: block for its own image pulls, and a mounted daemon.json for the job containers it starts.
  6. One firewall rule for the hairpinContainers reach Caddy on 443; see the UFW card.
dind's DNS config
mkdir -p /srv/stacks/forgejo-runner && cd /srv/stacks/forgejo-runner
printf '{ "dns": ["1.1.1.1", "1.0.0.1"] }\n' > dind-daemon.json

Register on one line, as root, with the token pasted in place of PASTE_TOKEN (no angle brackets). Each attempt needs a fresh token; a failed registration uses it up.

Register
sudo rm -f /srv/data/forgejo-runner/data/.runner
docker run --rm -it --user root -v /srv/data/forgejo-runner/data:/data code.forgejo.org/forgejo/runner:6 forgejo-runner register --no-interactive --instance https://git.dev.tailnet.example --token PASTE_TOKEN --name devbox-runner --labels docker:docker://node:24-bookworm
/srv/stacks/forgejo-runner/docker-compose.yml
services:
  runner:
    image: code.forgejo.org/forgejo/runner:6
    container_name: forgejo-runner
    user: "0:0"
    working_dir: /data
    command: ["forgejo-runner", "daemon"]
    restart: unless-stopped
    environment:
      - DOCKER_HOST=tcp://docker:2376
      - DOCKER_CERT_PATH=/certs/client
      - DOCKER_TLS_VERIFY=1
    volumes:
      - /srv/data/forgejo-runner/data:/data
      - dind-certs:/certs
    depends_on: [docker]
    networks: [runner]

  docker:
    image: docker:dind
    container_name: forgejo-runner-dind
    privileged: true
    restart: unless-stopped
    environment:
      - DOCKER_TLS_CERTDIR=/certs
    dns:
      - 1.1.1.1
      - 1.0.0.1
    volumes:
      - dind-certs:/certs
      - /srv/stacks/forgejo-runner/dind-daemon.json:/etc/docker/daemon.json:ro
    networks: [runner]

networks:
  runner:

volumes:
  dind-certs:
Where it breaks

Seams

Where this meets the rest of the build. Each one leads with what you will actually see.

dind meets the internal URL

SymptomCould not resolve host: forgejo

The runner was registered with the internal address. It works for the runner's own API calls, then checkout fails inside the job. Re-register with https://git.dev.tailnet.example.

dind meets its TLS certificate

Symptomx509: certificate is valid for docker

dind issues its certificate for the hostname docker. A service with any other name fails verification. Name the service docker.

The token meets angle brackets

Symptoma shell error before registration starts

< is a shell redirect, so a token pasted as <TOKEN> breaks the command. Paste the bare token.

A rename meets container_name

Symptomcontainer name already in use

Renaming a service that has an explicit container_name needs docker compose down first. up --force-recreate doesn't free the old name.

Reading DNS failures

Symptomsomething can't be resolved, and it isn't clear which daemon is asking

docker pull fails inside jobs: fix dind's dns: block or daemon.json. checkout can't resolve forgejo: re-register with the public URL. code.forgejo.org fails in the runner's own log: fix the host's daemon.json (see the Docker card).

Verify

docker compose up -d && docker compose logs -f shows the runner connecting, with no help text and no restart loop. Forgejo lists it as online under Actions, then Runners. Online only means it connected; a green run is the real proof.

.forgejo/workflows/ci.yml (in a test repository)
on: [push]
jobs:
  test:
    runs-on: docker
    steps:
      - uses: actions/checkout@v4
      - run: echo ok
Corrections and updates
  1. 17 Oct 2026First written.