progressive pride flag

a

i

c

h

a

n

.

m

o

e

How to set up a self-hosted Forgejo with CI runners & pushes to your own registry

This is a quick, reproducible path from zero to running Forgejo + a runner that builds Docker images and pushes to your Forgejo registry, plus optional GitHub mirroring.

Prerequisites

  • A domain pointing to your box (e.g., git.example.com)
  • Docker & Compose installed
  • A persistent data folder, e.g. /path/to/your/data/forgejo

⚠️ CRITICAL WARNING ⚠️

🛑 DO NOT START THE CONTAINERS BEFORE COMPLETING ALL SETUP STEPS!

Starting the containers before proper setup will cause initialization failures and require cleanup.

Docker Compose File

# MODIFY $MYSQL_ROOT_PASSWORD, $MYSQL_PASSWORD, $MAILER_PASSWORD and required volume paths
services:
  server:
    image: codeberg.org/forgejo/forgejo:12
    container_name: forgejo
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Europe/Madrid
      - GITEA__database__DB_TYPE=mysql
      - GITEA__database__HOST=db:3306
      - GITEA__database__NAME=forgejo
      - GITEA__database__USER=dbuser
      - GITEA__database__PASSWD=$MYSQL_PASSWORD
      - ROOT_URL=https://git.example.com
      - GITEA_CUSTOM=/data/gitea
      - FORGEJO__mailer__ENABLED=true
      - FORGEJO__mailer__SMTP_ADDR=example.com
      - FORGEJO__mailer__SMTP_PORT=587
      - FORGEJO__mailer__FROM=noreply@example.com
      - FORGEJO__mailer__USER=contact@example.com
      - FORGEJO__mailer__PASSWD=$MAILER_PASSWORD
      - FORGEJO__mailer__MAILER_TYPE=smtp
      - FORGEJO__mailer__IS_TLS_ENABLED=true
    networks:
      - forgejo
    volumes:
      - /path/to/your/data/forgejo/data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - 3002:3000
      - 223:22
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: mysql:8
    container_name: forgejo-db
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Europe/Madrid
      - MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
      - MYSQL_USER=dbuser
      - MYSQL_PASSWORD=$MYSQL_PASSWORD
      - MYSQL_DATABASE=forgejo
    networks:
      - forgejo
    volumes:
      - /path/to/your/data/forgejo/mysql:/var/lib/mysql
    restart: unless-stopped

  docker-daemon:
    image: docker:27-dind
    container_name: forgejo-runner-dind
    hostname: docker
    privileged: true
    environment:
      - DOCKER_TLS_CERTDIR=/certs
      - DOCKER_DRIVER=overlay2
    volumes:
      - /path/to/your/data/forgejo/runner-data/docker-certs-ca:/certs/ca
      - /path/to/your/data/forgejo/runner-data/docker-certs-client:/certs/client
      - /path/to/your/data/forgejo/runner-data/docker-data:/var/lib/docker
    networks:
      - forgejo
    restart: unless-stopped

  forgejo-runner:
    image: data.forgejo.org/forgejo/runner:9
    container_name: forgejo-runner
    user: "1000:100"
    working_dir: /data
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Europe/Madrid
      - DOCKER_HOST=tcp://docker:2376
      - DOCKER_TLS_VERIFY=1
      - DOCKER_CERT_PATH=/certs/client
      - DOCKER_BUILDKIT=1
    volumes:
      - /path/to/your/data/forgejo/runner-data:/data:rw
      - /path/to/your/data/forgejo/runner-data/docker-certs-client:/certs/client:ro
    depends_on:
      - docker-daemon
    # Security hardening (adjusted for DinD)
    security_opt:
      - no-new-privileges:true
      - apparmor:docker-default
    cap_drop:
      - ALL
    cap_add:
      - DAC_OVERRIDE
    read_only: true
    tmpfs:
      - /tmp:size=500M,noexec,nosuid,nodev
      - /var/tmp:size=200M,noexec,nosuid,nodev
      - /var/log:size=100M,noexec,nosuid,nodev
    # Use existing forgejo network for communication
    networks:
      - forgejo
    # wait if not registered yet, so it doesn't crash-loop
    command: ["/bin/sh","-c","while [ ! -f /data/.runner ] || [ ! -s /data/.runner ]; do echo 'Runner not registered yet. Please register first:'; echo 'docker exec forgejo-runner forgejo-runner register --instance http://git.example.com --token YOUR_TOKEN'; sleep 30; done; echo 'Runner registered, starting daemon...'; exec forgejo-runner daemon --config /data/config.yml"]
    restart: unless-stopped

networks:
  forgejo:
    name: forgejo

1) Prepare directories and create config.yml file

a) Prepare data directories

mkdir -p /path/to/your/data/forgejo/data
mkdir -p /path/to/your/data/forgejo/mysql
mkdir -p /path/to/your/data/forgejo/runner-data
chown -R 1000:1000 /path/to/your/data/forgejo/data
chown -R 1000:1000 /path/to/your/data/forgejo/runner-data

b) Create config.yml file

Create a config.yml file in /path/to/your/data/forgejo/runner-data/config.yml

log:
  level: info
runner:
  envs:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_CERT_PATH: /certs/client
    DOCKER_TLS_VERIFY: "1"
container:
  network: host
  options:
    -v /certs/client:/certs/client
    -v /etc/ssl/certs/ca-certificates.crt:/etc/pki/tls/certs/ca-bundle.crt:ro 
    -v /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
    -v /usr/local/share/ca-certificates/homelab-ca.crt:/usr/local/share/ca-certificates/homelab-ca.crt:ro
  valid_volumes:
    - /certs/client
    - /etc/pki/tls/certs/ca-bundle.crt
    - /etc/ssl/certs/ca-certificates.crt
    - /usr/local/share/ca-certificates/homelab-ca.crt
                        

2) Bring up the Compose

Setup verification

Make sure you have:

  • Set ROOT_URL=https://<your domain>
  • Used strong passwords for the DB
  • Completed step 1 (directory setup and config.yml creation)

Start

docker compose -f compose.yml up -d

Open https://<your domain> and finish the initial setup.

Enable Actions

Instance-wide: Admin > Actions > Runners (or per org/repo in Settings > Actions > Runners).

Click Create new runner and copy the runner token (you can also do org/repo-scoped tokens).

docker exec -it forgejo-runner /bin/bash
cd /data
forgejo-runner register
# Instance URL: https://<your domain>
# Token: <paste the token you created>
# Runner name: <anything>
# Labels: e.g. ubuntu-24.04:docker://node:22-bookworm,ubuntu-22.04:docker://node:22-bookworm,ubuntu-18.04:docker://node:20-bookworm,docker:docker://node:20-bookworm
exit
docker restart forgejo-runner

Make builds reliable

Generate a config and enable auto-pull + Docker socket automount into job containers:

docker exec -it forgejo-runner /bin/sh -lc 'forgejo-runner generate-config > /data/config.yml'
docker exec -it forgejo-runner /bin/sh -lc "sed -i 's/^  docker_host:.*/  docker_host: automount/' /data/config.yml"
docker exec -it forgejo-runner /bin/sh -lc "sed -i 's/^  force_pull:.*/  force_pull: true/' /data/config.yml"

Make sure your runner starts with that config (in compose command, run forgejo-runner daemon --config /data/config.yml) and that the container user can access /var/run/docker.sock. If you see "permission denied", add the host docker group GID via group_add or (as a quick test) run the container as root.

3) CI: build & push to your Forgejo registry

Create .forgejo/workflows/docker-compose.yml in your repo:

name: Build & push to Forgejo Registry

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

env:
  # ---- change these to match your setup ----
  REGISTRY: git.example.com                 # your Forgejo domain (no scheme)
  NAMESPACE: youruser                       # your Forgejo user/org
  IMAGE_NAME: yourimage                     # repo/image name
  TAG: latest
  # -----------------------------------------
  FORGEJO_USER: ${{ secrets.FORGEJO_USER }}   # username (not email)
  FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} # Personal Access Token (PAT) with read:package + write:package

jobs:
  build:
    # Use a runner label that has Docker available (common label is 'docker')
    runs-on: docker

    steps:
      - uses: actions/checkout@v4

      # Install the Docker CLI inside the job container so we can build/push
      - name: Install Docker CLI (Debian)
        run: |
          apt-get update
          apt-get install -y docker.io ca-certificates
          docker info

      # Build (tag with full registry path)
      - name: Build
        run: docker build -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG} .

      # Log in (only on push; secrets aren't exposed on PRs from forks)
      - name: Login to registry (push only)
        if: github.event_name == 'push'
        run: |
          docker logout "${REGISTRY}" || true
          printf '%s' "${FORGEJO_TOKEN}" | docker login "${REGISTRY}" -u "${FORGEJO_USER}" --password-stdin

      # Push (skip on PRs)
      - name: Push (push only)
        if: github.event_name == 'push'
        run: docker push ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}

Secrets needed in your Forgejo repo:

  • FORGEJO_USER = your username
  • FORGEJO_TOKEN = Personal Access Token with read:package + write:package. If pushing to an org namespace, make sure you're a member allowed to publish.

4) Optional: mirror to GitHub

Add .forgejo/workflows/mirror-to-github.yml:

name: Mirror to GitHub

on:
  push:
    branches: [ "master" ]
    tags: [ "*" ]           # mirror tags, too
  delete:
    branches: [ "*" ]
    tags: [ "*" ]

env:
  # ---- change this to your GitHub repo HTTPS URL (no token in it) ----
  GITHUB_MIRROR_URL: https://github.com/<user>/<repo>.git
  # -------------------------------------------------------------------

jobs:
  mirror:
    runs-on: docker
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history for --mirror

      # Configure Git auth header using Basic with a PAT (safer than embedding in URL)
      - name: Configure Git auth header
        run: |
          TOKEN="${{ secrets._GITHUB_MIRROR_TOKEN }}"
          B64="$(printf '%s' "x-access-token:${TOKEN}" | base64 | tr -d '\n')"
          git config --global http.https://github.com/.extraheader "Authorization: Basic ${B64}"
          git config --global user.email "ci@local"
          git config --global user.name "forgejo-ci"

      # Mirror everything (branches + tags; creates/prunes to keep remote identical)
      - name: Push mirror
        run: |
          git remote add mirror "${GITHUB_MIRROR_URL}" || git remote set-url mirror "${GITHUB_MIRROR_URL}"
          git push --mirror mirror

Secrets in Forgejo repo:

  • _GITHUB_MIRROR_TOKEN = GitHub PAT with repo scope (or granular "Contents: Read/Write")

5) Troubleshooting notes (the gotchas I hit)

  • Install page loop: your Forgejo container can't write /data or can't reach DB. Fix volume ownership (1000:1000) and DB creds.
  • Runner says .runner: EOF: you "touched" the file but didn't register. Exec into the runner, cd /data, run forgejo-runner register, then restart.
  • "cannot ping the docker daemon": the runner user can't access /var/run/docker.sock. Add the socket's GID via group_add, or run container as root (quick test).
  • docker: command not found in jobs: install the Docker CLI inside the job container, and set container.docker_host: automount in the runner config.
  • Registry 401 (authGroup.Verify): use username + PAT (not email), token must have read/write:package; only attempt login on push events.
  • Docker Hub pulls time out (regional/CDN block): pre-pull via mirror.gcr.io and retag, or route CI pulls via a VPN-namespaced Docker daemon (gluetun + dind).

6) One-liner you'll run during runner registration

docker exec -it forgejo-runner /bin/bash
# then inside:
cd /data
forgejo-runner register
# Instance: https://<your domain>
# Token: (Settings > Actions > Runners at instance/org/repo scope)
# Labels: ubuntu-24.04:docker://node:22-bookworm,ubuntu-22.04:docker://node:22-bookworm,ubuntu-18.04:docker://node:20-bookworm,docker:docker://node:20-bookworm
exit
docker restart forgejo-runner