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¶
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:
- 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.
- 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¶
- Deploy the compose (by hand the very first time, since Komodo has nothing to pull from yet; see Rebuild from scratch).
- Open
https://git.<YOUR_DOMAIN>/, finish the install page (SQLite is pre-selected), create the admin user. - Create the infra repo, e.g.
<YOUR_USER>/homelab, private. -
Create the bot users from the CLI (run Forgejo's CLI as the
gituser 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-passwordGive 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 needsread:repository. Store both tokens in Komodo, never in the repo. -
Add the collaborators:
komodowith Read,renovatewith 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'sKOMODO_WEBHOOK_SECRET: Komodo'sgithublistener 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 onceKOMODO_HOSTis 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:
- Read the release notes (majors can include database migrations).
- Run a backup of
/mnt/appdata/forgejo. - Merge. After the redeploy run
docker exec -u git forgejo forgejo doctor check --alland 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.