Forgejo

Git forge and CI/CD platform. Primary source of truth for blumeops (mirrored to GitHub).

Built from source on indri, managed via Ansible + mcquack LaunchAgent. The build pulls from the forge mirror (origin); Codeberg is the upstream remote (codeberg). To upgrade, see upgrade-forgejo.

Quick Reference

PropertyValue
URL (public)https://forge.eblu.me
URL (internal)https://forge.ops.eblu.me
SSHssh://forgejo@forge.ops.eblu.me:2222
Local Ports3001 (HTTP), 2200 (SSH)
Configansible/roles/forgejo/templates/app.ini.j2
Binary~/code/3rd/forgejo/forgejo (source-built)
Data~/forgejo
LaunchAgentmcquack.eblume.forgejo
Source~/code/3rd/forgejo (origin = forge mirror, codeberg = upstream)

Building from Source

Forgejo is built from source on indri, matching the pattern used by zot, caddy, and alloy.

Remotes: originhttps://forge.ops.eblu.me/mirrors/forgejo.git (the build source), codeberghttps://codeberg.org/forgejo/forgejo.git (upstream). The original clone was from Codeberg to avoid a circular dependency with the forge; the mirror was later promoted to origin.

Version is declared in the Ansible role, not built ad-hoc. forgejo_version (plus forgejo_go_version/forgejo_node_version/forgejo_build_tags) in ansible/roles/forgejo/defaults/main.yml pins the deployed tag. On provision-indri --tags forgejo the role fetches from the mirror, checks out the tag, rebuilds only when the running binary doesn’t match, links ./forgejo, and restarts. Bumping forgejo_version in a PR is therefore the whole upgrade — reproducible and DR-safe. See upgrade-forgejo for the full procedure (DB backup, breaking changes, verification, rollback).

WARNING: Do NOT use make forgejo directly — it rebuilds with empty TAGS, stripping SQLite support. The role passes TAGS explicitly to make build and ln -f gitea forgejo afterwards.

Build tags (forgejo_build_tags): bindata (embed assets), timetzdata (embed timezone data), sqlite sqlite_unlock_notify (SQLite support).

The repo’s local mise.toml (mise run build) is untracked and pins go 1.25.8 — it fails on v15+. The role builds with mise x go@{{ forgejo_go_version }} instead; use that form for manual builds too.

Repositories

The forge has three namespaces:

  • eblume/ — first-party repos (blumeops, talos, hephaestus, myeve, gamedev, …). The forge is the primary source of truth for blumeops.
  • agents/ — the agents-forgejo-bot’s forks (blumeops, agents, horkos); the push target for repos where the human gate is the point.
  • mirrors/ — pull mirrors of external repos (alloy, tesla_auth, …) for supply chain control; see manage-forgejo-mirrors.

CI/CD (Forgejo Actions)

Runners:

RunnerHostLabelsPurpose
indri-runnerindri (native, host-mode)indriDefault jobs; Dagger CLI talks to the Docker Desktop engine
ringtail-nix-builderringtail (NixOS)nix-container-builderNix container builds via nix-build + skopeo
ringtail-priv-runnerringtail (NixOS, sandboxed DynamicUser)privWarrant-gated, dispatch-only privileged jobs (warrant-approval-gated-runs)

Workflows in .forgejo/workflows/:

WorkflowTriggerRunnerPurpose
agent-repo-accesspush/PR/dispatchindriReconcile the agents bot’s collaborations + labels against repos.json
argocd-deploydispatchprivWarrant-gated ArgoCD deploy of a single app
branch-cleanupcron/dispatchindriDelete stale branches
build-blumeopsdispatchindriDocs build + release
build-containerdispatchindrinix-container-builderNix container image builds; classify on indri, build on the nix builder (build-container-image)
cv-deploydispatchindriDeploy the CV package
deploy-flydispatchprivWarrant-gated deploy of the Fly.io proxy (flyio-proxy)
docs-checksPR/pushindriDocs + changelog validation
flake-updatedispatchindriRingtail flake nixpkgs update
lintPR/pushindriRepo lint (prek hooks)
run-scriptdispatchprivWarrant-gated one-off script run
warrant-bot-driftcron/push/dispatchindriWeekly drift check on the warrant-bot’s grants

(Until retire-minikube a k8s runner was a minikube DinD pod that also built Dockerfile/Dagger containers; that path is retired.)

Secrets (Forgejo Config)

Server configuration secrets managed via 1Password → Ansible (fetched in the indri playbook pre_tasks):

  • lfs-jwt-secret, internal-token, oauth2-jwt-secret — Forgejo server tokens (rendered into app.ini)
  • runner_reg — instance-global runner registration token, written to /etc/forgejo-runner/token.env for the two ringtail runners

Per-runner identity and job-credential secrets live on the runner card (Credentials).

Forgejo Actions Secrets

Repository-level Actions secrets are synced from 1Password to Forgejo by the forgejo_actions_secrets Ansible role (human-run from gilbert under biometric op — the role authenticates with the admin PAT and the step never leaves a human):

mise run provision-indri -- --tags forgejo_actions_secrets
RepoSecretsPurpose
eblume/blumeopsFORGE_REPO_WRITE_TOKEN, BLUMEOPS_CI_OP_TOKENagent-repo-access reconcile + warrant-bot-drift reads (write:repository,read:user eblume PAT); job-time op read of blumeops-ci items
eblume/talos, eblume/horkosBLUMEOPS_CI_OP_TOKEN, RELEASE_FORGE_TOKENAuto-release CI: job-time zot push key; warrant-bot PAT pushes the pin-bump branch + opens the PR on blumeops
eblume/cvFORGE_TOKENCV deploy workflow

The per-purpose secrets the role used to sync (argocd token, fly deploy token, zot CI key, main-push PAT) are no longer Forgejo secrets: workflows op read the blumeops-ci items at job time with BLUMEOPS_CI_OP_TOKEN — see blumeops-ci-item-migration.

These secrets are injected as ${{ secrets.SECRET_NAME }} in workflow files.

API Token Setup (Manual, One-Time)

The Ansible role authenticates to the Forgejo API using a Personal Access Token (PAT). This PAT must be created manually:

  1. Go to https://forge.eblu.me/user/settings/applications
  2. Create a new all-scopes admin token (this role is the only consumer of the admin PAT; CI uses the scoped FORGE_REPO_WRITE_TOKEN instead)
  3. Store it in 1Password → “Forgejo Secrets” item → api-token field

This is a bootstrapping requirement - the PAT enables IaC for all other secrets.

Consumers — update all of these when rotating the token:

  • The Ansible role (reads from 1Password at provision time)
  • mise run runner-logs (reads from 1Password at run time)
  • The tea CLI (copies the token into ~/.config/tea/config.yml; re-paste it there after rotation). tea switched to this PAT 2026-07-04 after its OAuth token expired and tea 0.14.2 broke httpsign auth (tea#1046 — fixed in go-sdk but unreleased; httpsign can be re-enabled in tea’s config once 0.14.3 ships).

Identity Provider

authentik is the BlumeOps OIDC identity provider and source of truth for user identity. Forgejo authenticates against Authentik as an OIDC client.

Configuration:

  • OAuth2 provider and application defined in Authentik blueprints (argocd/manifests/authentik/configmap-blueprint.yaml)
  • Auth source created via forgejo admin auth add-oauth with --skip-local-2fa (lives in Forgejo’s SQLite database, not app.ini)
  • [oauth2_client] section in app.ini.j2 controls auto-registration and account linking behavior

MFA: SSO logins skip Forgejo’s local 2FA (--skip-local-2fa on the auth source) — Authentik enforces MFA instead. Local password logins still require Forgejo’s own TOTP. Note: the --skip-local-2fa CLI flag has a known bug where it doesn’t persist via update-oauth; it was set directly in the login_source.cfg JSON (SkipLocalTwoFA: true).

Account linking: ACCOUNT_LINKING = login — when an Authentik user’s email matches an existing local account, Forgejo prompts for the local password (and local MFA) to confirm the link. This is a one-time operation that preserves existing accounts, API tokens, SSH keys, and repository ownership.

Group-based admin: The admins group in Authentik maps to Forgejo admin status via --admin-group admins on the auth source. Manage admin access in Authentik, not Forgejo.

Break-glass: Local password login always works (with local MFA). Authentik SSO is additive — if Authentik is down, log in with local credentials.

Public Access

Forgejo is publicly accessible at https://forge.eblu.me via flyio-proxy. This is the first dynamic, authenticated service exposed publicly.

Access MethodURLReachable From
HTTPS (public)https://forge.eblu.mePublic internet
HTTPS (internal)https://forge.ops.eblu.meTailnet only
SSHssh://forgejo@forge.ops.eblu.me:2222Tailnet only

The UI shows forge.eblu.me for HTTPS clone URLs and forge.ops.eblu.me for SSH clone URLs.

Security Controls

  • Registration: Local registration disabled; only authentik SSO login allowed (ALLOW_ONLY_EXTERNAL_REGISTRATION = true)
  • Reverse proxy trust: REVERSE_PROXY_LIMIT = 2, REVERSE_PROXY_TRUSTED_PROXIES = * — Forgejo logs the real client IP from X-Real-IP header, not the proxy’s Tailscale IP
  • Rate limiting: nginx rate limits login/signup/forgot-password endpoints (3r/s per client IP via Fly-Client-IP header)
  • fail2ban: Runs in the Fly.io container; bans IPs after 5 failed logins in 10 minutes via nginx deny list (ephemeral across deploys)
  • Swagger: Blocked at the proxy (/swagger returns 403); use forge.ops.eblu.me for API access
  • Archive redirect: Archive endpoints (/*/archive/*) are 302-redirected to forge.ops.eblu.me — prevents unauthenticated crawlers from triggering unbounded git bundle generation (known DoS vector, see Crawler Mitigation)
  • robots.txt: Blocks crawlers from /mirrors/, /user/, /users/, /*/archive/, /*/releases/download/
  • OAuth dead-end: “Sign in with Authentik” redirects to the (tailnet-only) Authentik URL — SSO only works from the tailnet

Break-glass

mise run fly-shutoff stops all public traffic immediately. forge.ops.eblu.me continues to work from the tailnet. See Break-glass shutoff.

Monitoring

Forgejo exposes a Prometheus /metrics endpoint (enabled via [metrics] in app.ini). Alloy on indri scrapes it at localhost:3001/metrics. Metrics are mostly Go runtime stats and repo counters (no per-request latency histogram).

Request latency is measured at the Fly.io proxy layer via the flyio_nginx_upstream_response_time_seconds histogram, visible on the Forgejo Grafana dashboard under “Forgejo: Upstream Response Time”.

Archive Cleanup

The [cron.archive_cleanup] section is enabled with OLDER_THAN = 2h and RUN_AT_START = true. This prevents the repo-archive/ directory from growing unboundedly when crawlers or users trigger archive downloads. Without this, the directory grew to 54GB in 2 days during a crawler incident in April 2026.

Mirrors

Forgejo hosts pull mirrors of external repositories (GitHub, etc.) for supply chain control. Mirrors live in the mirrors/ org and sync on a configurable interval. See manage-forgejo-mirrors for operations.

  • upgrade-forgejo - Version upgrade procedure (DB backup, breaking changes, rollback)
  • forgejo-runner - CI/CD runners (indri + ringtail instances, credentials)
  • agents-forgejo-bot - The bot identity behind the agents/ namespace
  • argocd - Uses Forgejo as git source
  • authentik - OIDC identity provider
  • zot - Container registry for built images