Skip to content

Forgejo (self-hosted git)

Forgejo is a community-run fork of Gitea: a lightweight git server with a web UI, pull requests, issues and webhooks. It holds the infra repo every stack deploys from, Renovate's pull requests, and the issue tracker I use instead of a kanban board.

It is a single container with SQLite. For one user and a handful of repos there is no reason to run Postgres next to it.


Compose

hosts/control-host/forgejo/compose.yaml
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:16.0.6   # LTS line; Renovate PRs for it are manual-review
    container_name: forgejo
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=sqlite3
      - FORGEJO__server__DOMAIN=git.<YOUR_DOMAIN>
      - FORGEJO__server__ROOT_URL=https://git.<YOUR_DOMAIN>/
      - FORGEJO__server__SSH_DOMAIN=git.<YOUR_DOMAIN>
      - FORGEJO__server__SSH_PORT=2222
      - FORGEJO__server__HTTP_PORT=3000
      - TZ=<TZ>
      # Trust the local CA so outgoing webhooks to https://komodo.<YOUR_LOCAL_DOMAIN> verify.
      # Forgejo is Go: it reads SSL_CERT_FILE, not the system store from update-ca-certificates.
      - SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt
    volumes:
      # Absolute path on the appdata disk, NOT ./data (see "Where the data lives")
      - /mnt/appdata/forgejo/data:/data
      - ./ca-bundle.crt:/etc/ssl/certs/ca-bundle.crt:ro
    ports:
      - "3030:3000"   # web UI / API (put the reverse proxy in front)
      - "2222:22"     # git over SSH
    networks:
      - proxy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

networks:
  proxy:
    external: true

No .env is needed: Forgejo keeps its own secrets in /data/gitea/conf/app.ini, generated on first start.

Every FORGEJO__section__KEY variable is written into app.ini at startup, so the settings in compose win over anything edited in the file. The full list is in the config cheat sheet.

ca-bundle.crt is the system CA bundle with your local root CA appended (for example the mkcert root that signs your *.<YOUR_LOCAL_DOMAIN> certificates):

cat /etc/ssl/certs/ca-certificates.crt /path/to/rootCA.pem > hosts/control-host/forgejo/ca-bundle.crt

Without it, every webhook to an internal HTTPS name fails with x509: certificate signed by unknown authority. The push itself still works, so this fails silently: the only trace is the delivery log under the repo's Settings → Webhooks → (hook) → Recent Deliveries. Add ca-bundle.crt to the stack's tracked config files in Komodo so a change to it triggers a redeploy (see Komodo).

Set ROOT_URL to the name people actually use

ROOT_URL is what Forgejo puts in clone URLs, links in emails and webhook payloads. If it is a raw IP over HTTP, every generated link is wrong for anyone not on that exact IP. Point it at the HTTPS name behind your reverse proxy.


Where the data lives

/data holds everything: repositories, gitea.db (SQLite), app.ini, SSH host keys, avatars, attachments.

Never use a relative bind mount for Forgejo under Komodo

Komodo runs docker compose from its own clone of the repo (/etc/komodo/repos/... or /etc/komodo/stacks/<stack>/...). A relative mount like ./data:/data therefore points inside Komodo's git checkout. Two bad things follow:

  1. If the stack's clone is reset, re-cloned or the stack is renamed, the data directory moves or vanishes, and Forgejo starts as a fresh, empty instance. That's the git server every deploy depends on.
  2. When you switch a stack to a different clone (for example to a shared linked Repo), the relative path resolves somewhere else and you get the same empty instance.

Use an absolute path on the appdata disk (/mnt/appdata/forgejo/data). The same rule applies to every stack, but Forgejo is the one where it hurts most: it is the source of the rollback too.

Backups: back up /mnt/appdata/forgejo with your normal host backup, plus a consistent SQLite dump of /mnt/appdata/forgejo/data/gitea/gitea.db (borgmatic's sqlite_databases hook does this). Repos are also on every workstation clone, but issues, PRs, webhooks and users are only in the database.

To move existing data: stop the container, take a cold tarball, rsync -a to the new path, compare with rsync -anc --delete (expect no output), switch the bind mount, start, check /api/healthz.


First-time setup

  1. Deploy the compose (by hand the very first time, since Komodo has nothing to pull from yet; see Rebuild from scratch).
  2. Open https://git.<YOUR_DOMAIN>/, finish the install page (SQLite is pre-selected), create the admin user.
  3. Create the infra repo, e.g. <YOUR_USER>/homelab, private.
  4. Create the bot users from the CLI (run Forgejo's CLI as the git user inside the container):

    # Komodo: read-only access to clone/pull
    docker exec -u git forgejo forgejo admin user create \
      --username komodo --email komodo@<YOUR_LOCAL_DOMAIN> --random-password
    # Renovate: write collaborator on the infra repo (opens branches + PRs)
    docker exec -u git forgejo forgejo admin user create \
      --username renovate --email renovate@<YOUR_LOCAL_DOMAIN> --random-password
    

    Give each a repo-scoped access token (Settings → Applications while logged in as the bot, or forgejo admin user generate-access-token --username <bot> --scopes ...). Scopes for Renovate are on the Renovate page. Komodo only needs read:repository. Store both tokens in Komodo, never in the repo.

  5. Add the collaborators: komodo with Read, renovate with Write.


Webhooks

Repository → Settings → Webhooks → Add webhook:

Hook Type Target URL Trigger Branch filter
Deploy Gitea https://komodo.<YOUR_LOCAL_DOMAIN>/listener/github/procedure/<PROCEDURE_ID>/main Push events main
Push notice Forgejo https://ntfy.<YOUR_DOMAIN>/updates?template=... Push events main
PR notice Forgejo https://ntfy.<YOUR_DOMAIN>/updates?template=... Pull request events *
  • Deploy hook: content type application/json. Set the Secret to Komodo's KOMODO_WEBHOOK_SECRET: Komodo's github listener checks the HMAC signature header, and a Gitea-type hook sends a compatible one. Copy the exact URL from the procedure's page in Komodo (it shows the webhook URL once KOMODO_HOST is set).
  • ntfy hooks: ntfy can turn a JSON webhook into a readable message with message templates. Authenticate with a write-only ntfy token in the hook's Authorization header field (Forgejo stores it encrypted), not in the URL.
  • Use Test Delivery after creating each hook and check the response code in Recent Deliveries.

Webhooks to LAN addresses

Forgejo only lets webhooks call hosts allowed by [webhook] ALLOWED_HOST_LIST (default external). If Komodo or ntfy resolve to a private IP, add FORGEJO__webhook__ALLOWED_HOST_LIST=private (or the specific host names) to the environment, or the hook is refused.


Forgejo Actions

Forgejo has a built-in CI system (Actions) that runs GitHub-Actions-style workflows, but it needs a separate runner registered on some host. This setup doesn't use it: Komodo does the deploying, Renovate does the updating, so there is nothing for a runner to do. Workflow files in a repo with no runner just sit there queued.

If you add a runner later, use a rootless or Docker-in-Docker runner. Mounting the host's Docker socket into a runner gives every workflow root on that host.


Upgrades

Forgejo is manual-review in Renovate: if a release breaks it, every deploy stops, including the deploy that would roll it back. Before merging a Forgejo PR:

  1. Read the release notes (majors can include database migrations).
  2. Run a backup of /mnt/appdata/forgejo.
  3. Merge. After the redeploy run docker exec -u git forgejo forgejo doctor check --all and open /api/healthz.

If the new version won't start, fix forward on the host (docker compose in the stack's run directory with the old tag) and then revert the commit, since the webhook can't reach a Forgejo that's down.

Hardening worth doing: protect main (no force-push, no deletion), enable 2FA for your user, disable open registration (FORGEJO__service__DISABLE_REGISTRATION=true) and give bots the smallest token scopes that work.


Sources & further reading