{"page":{"agent_metadata":{"content_type":"guide","outputs":["gitea-helm-deployment","per-identity-service-accounts","branch-protection-rules","webhook-wiring","nightly-mirror-backup"],"prerequisites":["kubernetes-basics","helm-basics","git-workflows"]},"categories":["cicd"],"content_plain":"A self-hosted Gitea forge running on Kubernetes covers four operational concerns that the upstream chart leaves to the operator: identity hygiene for bots and humans, branch protection rendered from code rather than clickops, webhook wiring to CI, and a backup story that survives a cluster wipe. The companion article Gitea Collaborator Grants and Review Officiality covers the narrow operational gotcha of official=false reviews; this article is the broader runbook for running the forge well.\nHelm chart deployment# The upstream chart is gitea-charts/gitea. A pinned version recently validated in the field is gitea-12.5.3. The values that actually matter for a small-team or single-node cluster:\nimage: rootless: true # required on macOS Docker Desktop / minikube hostPath PVs redis-cluster: { enabled: false } redis: { enabled: false } postgresql: { enabled: false } postgresql-ha: { enabled: false } gitea: config: database: DB_TYPE: postgres HOST: postgres:5432 NAME: gitea USER: gitea PASSWD: \u0026lt;secret\u0026gt; SSL_MODE: disable server: ROOT_URL: \u0026#34;http://localhost:3000\u0026#34; HTTP_PORT: 3000 DOMAIN: localhost SSH_DOMAIN: localhost service: DISABLE_REGISTRATION: false REQUIRE_SIGNIN_VIEW: false session: { PROVIDER: db } cache: { ADAPTER: memory } queue: { TYPE: level } admin: username: \u0026lt;admin-user\u0026gt; password: \u0026lt;admin-password\u0026gt; email: admin@example.local persistence: enabled: true size: 5Gi resources: requests: { cpu: \u0026#34;100m\u0026#34;, memory: \u0026#34;128Mi\u0026#34; } limits: { memory: \u0026#34;512Mi\u0026#34; }Three of these values do real work and aren\u0026rsquo;t obvious from the chart README:\nimage.rootless: true — on Docker Desktop and minikube with hostPath PVs, the non-rootless image hits volume-permission errors at first start. Rootless avoids the chmod dance. redis-cluster, redis, postgresql, postgresql-ha all set to enabled: false — the chart bundles its own stateful dependencies by default. For a single-replica forge sharing a cluster Postgres, all four bundles need to be off explicitly. Setting postgresql.enabled: false alone leaves the HA chart enabled. cache.ADAPTER: memory + queue.TYPE: level + session.PROVIDER: db — together these eliminate the Redis dependency. session.PROVIDER: db survives pod restarts without Redis; queue.TYPE: level uses an embedded LevelDB; cache.ADAPTER: memory is per-pod and acceptable at single-replica. The in-cluster service DNS follows the chart\u0026rsquo;s default pattern: gitea-http.\u0026lt;namespace\u0026gt;.svc.cluster.local:3000. CI pods in the same cluster reach the forge over this DNS without ingress.\nFor HA — multi-replica Gitea — Redis becomes mandatory and the cache/session/queue values must move to a shared backend. That configuration is out of scope here; the values above target the single-replica case that covers most self-hosted deployments.\nPer-identity service accounts# The pattern: every bot or automation identity gets its own Gitea user account, not a shared admin token. Tokens are issued by an admin and stored as Kubernetes Secrets. Each identity maps to a role with the minimum scopes needed.\nGitea user K8s Secret key Scopes Role review-bot-a gitea-token-review-bot-a write:repository, write:issue, read:user Files reviews, comments on PRs review-bot-b gitea-token-review-bot-b write:repository, write:issue, read:user Second reviewer merge-bot gitea-token-merge-bot write:repository, write:issue, read:user Merges dual-approved PRs User creation goes through the admin endpoint with HTTP basic-auth:\ncurl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ -X POST https://gitea-host/api/v1/admin/users \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;username\u0026#34;: \u0026#34;review-bot-a\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;review-bot-a@local\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;\u0026lt;initial-password\u0026gt;\u0026#34;, \u0026#34;must_change_password\u0026#34;: false }\u0026#39;Token issuance is a separate call against the user\u0026rsquo;s namespace:\ncurl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ -X POST https://gitea-host/api/v1/users/review-bot-a/tokens \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;name\u0026#34;: \u0026#34;daemon\u0026#34;, \u0026#34;scopes\u0026#34;: [\u0026#34;write:repository\u0026#34;, \u0026#34;write:issue\u0026#34;, \u0026#34;read:user\u0026#34;] }\u0026#39; # Response: {\u0026#34;sha1\u0026#34;: \u0026#34;\u0026lt;token\u0026gt;\u0026#34;, ...}The user account survives token revocation. Rotating a token means issuing a new one under the same name; the user\u0026rsquo;s collaborator grants on every repo stay intact. Tokens get stored under predictable Secret keys so pods can fetch them at startup:\nkubectl get secret hub-secrets \\ -o jsonpath=\u0026#39;{.data.gitea-token-review-bot-a}\u0026#39; | base64 -dProduction deployments lead with token-auth (Authorization: token \u0026lt;sha1\u0026gt;) on the wire and reserve basic-auth for the initial admin bootstrap. Token-auth is per-identity, scope-limited, and revocable without resetting any user\u0026rsquo;s password.\nThe trade-off is explicit: one shared admin token would be simpler to wire, but it erases attribution (every commit, review, and merge attributed to \u0026ldquo;admin\u0026rdquo;), expands the blast radius of a leak to the entire forge, and makes selective revocation impossible. Per-identity tokens cost an extra admin call per role at bootstrap and pay back every time a token rotates or an audit asks \u0026ldquo;who pushed this.\u0026rdquo;\nBranch protection as code# Branch protection should be rendered from a script in the bootstrap repo, not configured through the Gitea UI. Clickops protection drifts across repos, leaves no audit trail, and is trivially forgotten when a new repo is created.\nThe Gitea API exposes two endpoints:\nPOST /api/v1/repos/{owner}/{repo}/branch_protections — create a new protection rule. PATCH /api/v1/repos/{owner}/{repo}/branch_protections/{branch} — update an existing rule. A standard production payload for main:\n{ \u0026#34;branch_name\u0026#34;: \u0026#34;main\u0026#34;, \u0026#34;enable_push\u0026#34;: true, \u0026#34;enable_push_whitelist\u0026#34;: true, \u0026#34;push_whitelist_usernames\u0026#34;: [\u0026#34;forge-admin\u0026#34;], \u0026#34;enable_merge_whitelist\u0026#34;: false, \u0026#34;enable_status_check\u0026#34;: true, \u0026#34;status_check_contexts\u0026#34;: [\u0026#34;jenkins/pipeline\u0026#34;], \u0026#34;required_approvals\u0026#34;: 1, \u0026#34;block_on_rejected_reviews\u0026#34;: true, \u0026#34;dismiss_stale_approvals\u0026#34;: true }Field-by-field semantics:\nenable_push + enable_push_whitelist + push_whitelist_usernames — only listed users can git push directly to main. Everyone else is forced through a PR. The push whitelist is an escape hatch for emergency direct pushes; production deployments should set it empty and require everyone (admins included) to use PRs. status_check_contexts — array of CI status context strings (e.g., jenkins/pipeline, ci/build). The PR cannot merge until every listed context posts a success status to the head commit SHA. A missing context is treated as not-yet-passing, not as not-required. required_approvals — minimum number of approving reviews. Set to 2 for dual-approval gates. Counts only reviews where official: true (see the companion article). block_on_rejected_reviews: true — a single REJECT blocks the merge until dismissed or resolved, regardless of how many APPROVEs land afterward. dismiss_stale_approvals: true — pushing new commits after approval invalidates prior approvals. Reviewers must re-approve the new HEAD. Without this, an approval on commit A silently carries over to commit B even if B introduces unreviewed changes. enable_merge_whitelist: false — anyone with write collaborator status can hit the merge button if the other gates pass. Flip to true and add merge_whitelist_usernames: [...] to restrict who can merge (commonly used to funnel all merges through a merge-bot identity). The idempotent rollout pattern is POST-then-PATCH: try to create, fall back to update when the rule already exists.\nfor repo in $(gitea_list_repos); do status=$(curl -s -o /dev/null -w \u0026#39;%{http_code}\u0026#39; \\ -u \u0026#34;$ADMIN_USER:$ADMIN_PASS\u0026#34; \\ -X POST \u0026#34;$GITEA/api/v1/repos/$OWNER/$repo/branch_protections\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#34;$PROTECTION_JSON\u0026#34;) if [[ \u0026#34;$status\u0026#34; =~ ^(409|422)$ ]]; then curl -s -u \u0026#34;$ADMIN_USER:$ADMIN_PASS\u0026#34; \\ -X PATCH \u0026#34;$GITEA/api/v1/repos/$OWNER/$repo/branch_protections/main\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#34;$PROTECTION_JSON\u0026#34; fi doneThe script swallows HTTP 422 (rule already exists for that branch) and HTTP 409 (conflict on create) and routes them to PATCH. Both endpoints accept the same payload shape, so the JSON is reusable.\nA skip-list handles repos that should not be PR-gated — cluster bootstrap, shared libraries built only by humans, anything where a CI gate would be a footgun:\nSKIP_REPOS=\u0026#34;platform-bootstrap shared-lib jenkins-shared-library\u0026#34; case \u0026#34; $SKIP_REPOS \u0026#34; in *\u0026#34; $repo \u0026#34;*) continue ;; esacVersioning the script in the bootstrap repo means protection settings are PR-reviewable, reproducible after a Gitea reinstall, and easy to roll out to a new repo by re-running the script.\nThe collaborator-grant trap (in brief)# Reviews filed by a user who is not a write collaborator on the repo land with official: false. Branch protection\u0026rsquo;s required_approvals counts only official: true reviews, so a non-collaborator\u0026rsquo;s APPROVE doesn\u0026rsquo;t gate the merge. Adding the collaborator grant after the review was filed does not retroactively flip official; the reviewer must re-file. Read collaborator permission is not enough — official=true requires write or higher.\nThis is the most common operational failure mode for a Gitea forge with bot reviewers, and it deserves the full diagnostic + fix runbook in the companion article: Gitea Collaborator Grants and Review Officiality. That article walks the API responses, the retroactive-flip rule, the diagnostic ladder, and an idempotent audit script that prevents recurrence.\nWebhook configuration# Every repo needs a push + pull_request webhook pointing to CI. Idempotent setup uses substring-match on existing hook URLs to skip already-wired repos.\nEndpoints:\nList existing hooks: GET /api/v1/repos/{owner}/{repo}/hooks Create a hook: POST /api/v1/repos/{owner}/{repo}/hooks The standard payload for a Jenkins-style integration:\n{ \u0026#34;type\u0026#34;: \u0026#34;gitea\u0026#34;, \u0026#34;config\u0026#34;: { \u0026#34;url\u0026#34;: \u0026#34;http://ci.example.svc.cluster.local:8080/gitea-webhook/post\u0026#34;, \u0026#34;content_type\u0026#34;: \u0026#34;json\u0026#34;, \u0026#34;secret\u0026#34;: \u0026#34;\u0026lt;shared-secret\u0026gt;\u0026#34; }, \u0026#34;events\u0026#34;: [\u0026#34;push\u0026#34;, \u0026#34;pull_request\u0026#34;], \u0026#34;active\u0026#34;: true }Event scope is the load-bearing choice:\npush — triggers on every commit, including direct pushes to feature branches. CI uses this for branch builds and for posting status checks back to commit SHAs. pull_request — fires on open / synchronize / close / reopen / edit. Drives PR-level status posts back to the head commit, which feeds the status_check_contexts array in branch protection. pull_request_review and issue_comment — add these only if downstream bots need to react to review or comment events. Most CI pipelines do not. The idempotent setup loop fetches existing hooks, looks for a substring match on the CI URL, and skips create when already present:\nexisting=$(curl -s -u \u0026#34;$ADMIN_USER:$ADMIN_PASS\u0026#34; \\ \u0026#34;$GITEA/api/v1/repos/$OWNER/$repo/hooks\u0026#34; | jq -r \u0026#39;.[].config.url\u0026#39;) if echo \u0026#34;$existing\u0026#34; | grep -q \u0026#34;$CI_HOST\u0026#34;; then echo \u0026#34;skip: $repo already wired\u0026#34; continue fi curl -s -u \u0026#34;$ADMIN_USER:$ADMIN_PASS\u0026#34; \\ -X POST \u0026#34;$GITEA/api/v1/repos/$OWNER/$repo/hooks\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#34;$HOOK_JSON\u0026#34;Internal-DNS gotcha: many CI servers and chat platforms block outbound webhook delivery to RFC1918 / cluster-internal addresses by default. Mattermost, for example, refuses outgoing webhooks to internal hosts unless ServiceSettings.AllowedUntrustedInternalConnections lists the destination explicitly:\n\u0026#34;AllowedUntrustedInternalConnections\u0026#34;: \u0026#34;ci.example.svc.cluster.local *.example.svc.cluster.local 10.0.0.0/8 192.168.0.0/16 172.16.0.0/12\u0026#34;The same pattern applies to Gitea\u0026rsquo;s own outgoing webhooks (notifications, mirroring) — webhook.ALLOWED_HOST_LIST in app.ini governs which destinations the forge will POST to. The default is \u0026ldquo;external only,\u0026rdquo; and cluster-internal CI hosts must be added explicitly.\nAdmin via API# All admin operations are HTTP basic-auth with the admin account, or token-auth with a token carrying the sudo scope. The minimum useful set:\n# Create user curl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ -X POST https://gitea-host/api/v1/admin/users \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;username\u0026#34;:\u0026#34;new-bot\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;new-bot@local\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;\u0026lt;pw\u0026gt;\u0026#34;,\u0026#34;must_change_password\u0026#34;:false}\u0026#39; # Issue token for that user curl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ -X POST https://gitea-host/api/v1/users/new-bot/tokens \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;daemon\u0026#34;,\u0026#34;scopes\u0026#34;:[\u0026#34;write:repository\u0026#34;,\u0026#34;write:issue\u0026#34;,\u0026#34;read:user\u0026#34;]}\u0026#39; # Add collaborator with write curl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ -X PUT https://gitea-host/api/v1/repos/{owner}/{repo}/collaborators/new-bot \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;permission\u0026#34;:\u0026#34;write\u0026#34;}\u0026#39; # Enumerate repos owned by a user (paginate page=1..N until empty) curl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ \u0026#34;https://gitea-host/api/v1/user/repos?limit=50\u0026amp;page=1\u0026#34; # Enumerate repos by owner (works for users and orgs) curl -u \u0026lt;admin-user\u0026gt;:\u0026lt;admin-password\u0026gt; \\ \u0026#34;https://gitea-host/api/v1/repos/search?owner={owner}\u0026amp;limit=50\u0026amp;page=1\u0026#34;A subtle distinction trips up scripts that assume the forge admin owns an org: the admin can be a user, not an organization, and the repo-listing endpoint differs:\nUser-owned repos: GET /api/v1/user/repos — must authenticate as that user. Org repos: GET /api/v1/orgs/{org}/repos. Generic search: GET /api/v1/repos/search?owner={name} — works for both. Production scripts should use repos/search for portability; it\u0026rsquo;s the only endpoint that works regardless of whether the owner is a user or an org.\nBackup discipline# Repo content backs up cleanly with git clone --mirror against every repo, then tar-gzip into a dated directory with a manifest of HEAD refs and commits. The mirror clone preserves all refs, branches, tags, and remote tracking — restore is git clone --mirror from the tarball followed by git push --mirror to a fresh Gitea repo.\nDATE=$(date +%Y-%m-%d) DEST=\u0026#34;$DEST_ROOT/$DATE\u0026#34; mkdir -p \u0026#34;$DEST\u0026#34; tmp=$(mktemp -d) trap \u0026#34;rm -rf $tmp\u0026#34; EXIT for repo in $(list_repos); do url=\u0026#34;http://$ADMIN_USER:$ADMIN_PASS@gitea-host/$OWNER/$repo.git\u0026#34; git clone --quiet --mirror \u0026#34;$url\u0026#34; \u0026#34;$tmp/$repo.git\u0026#34; head_ref=$(cd \u0026#34;$tmp/$repo.git\u0026#34; \u0026amp;\u0026amp; git symbolic-ref HEAD) head_commit=$(cd \u0026#34;$tmp/$repo.git\u0026#34; \u0026amp;\u0026amp; git rev-parse HEAD) tar -C \u0026#34;$tmp\u0026#34; -czf \u0026#34;$DEST/$repo.tgz\u0026#34; \u0026#34;$repo.git\u0026#34; size=$(stat -f%z \u0026#34;$DEST/$repo.tgz\u0026#34; 2\u0026gt;/dev/null || stat -c%s \u0026#34;$DEST/$repo.tgz\u0026#34;) sha=$(shasum -a 256 \u0026#34;$DEST/$repo.tgz\u0026#34; | awk \u0026#39;{print $1}\u0026#39;) printf \u0026#39;%s\\t%d\\t%s\\t%s\\t%s\\n\u0026#39; \u0026#34;$repo\u0026#34; \u0026#34;$size\u0026#34; \u0026#34;$sha\u0026#34; \u0026#34;$head_ref\u0026#34; \u0026#34;$head_commit\u0026#34; \\ \u0026gt;\u0026gt; \u0026#34;$DEST/MANIFEST.txt\u0026#34; done # Prune day-dirs older than RETENTION_DAYS find \u0026#34;$DEST_ROOT\u0026#34; -maxdepth 1 -type d -mtime +\u0026#34;$RETENTION_DAYS\u0026#34; -exec rm -rf {} \\;Operational notes that catch real-world breakage:\nAbsolute paths to every binary (/usr/bin/git, /usr/bin/tar, /usr/bin/curl, /usr/local/bin/kubectl) — cron\u0026rsquo;s stripped PATH is the most common cause of silent backup failures. Ephemeral kubectl port-forward if Gitea isn\u0026rsquo;t exposed over ingress, with a trap cleanup EXIT to kill the forward on script exit. macOS Full Disk Access: cron jobs touching /Volumes/\u0026lt;your-backup-drive\u0026gt;/gitea-backups need Full Disk Access granted to /usr/sbin/cron under System Settings → Privacy \u0026amp; Security. Without it, the cron job runs but fails silently on the volume write. Manifest format: \u0026lt;repo\u0026gt;\\t\u0026lt;size_bytes\u0026gt;\\t\u0026lt;sha256\u0026gt;\\t\u0026lt;HEAD_ref\u0026gt;\\t\u0026lt;HEAD_commit\u0026gt; — recoverable from a single cat MANIFEST.txt and easy to grep against to spot a missing repo. Retention: 7 days of daily snapshots is a sensible default; logs retained 30 days separately. Non-zero exit on any failed clone — cron mail surfaces the failure. Silent backup failures are worse than no backups, because they hide behind a green dashboard. The full disaster-recovery story — including database backup, persistent-volume snapshots, and tested restore procedures across the whole cluster — is covered in Single-Node Kubernetes Disaster Recovery. The mirror-clone backup above is the repo-content slice; for \u0026ldquo;rebuild the forge from scratch\u0026rdquo; the DR runbook is the right entry point.\nTrade-offs worth restating# One Gitea user per bot identity, per-identity scoped token. The alternative — a single shared admin token — is simpler to wire but erases attribution, expands blast radius to the entire forge if the token leaks, and makes selective revocation impossible.\nBranch protection as code, not clickops. UI-managed protection drifts across repos and is forgotten on new repos. Script-rendered protection is idempotent, reproducible, and PR-reviewable.\nReviewer bots get write collaborator on every repo, not read. Gitea only counts official=true reviews toward required_approvals, and official requires write or higher. Read fails silently — the review is filed but doesn\u0026rsquo;t gate the merge.\nExternal Postgres over the bundled chart. A single shared Postgres for all platform services means one backup target and one operational story. The bundled postgresql.enabled: true is fine for evaluation; production deployments converge on cluster-shared Postgres.\nMirror-clone tarball backup, not gitea dump. The gitea dump admin command requires a running Gitea binary in the same context as the data dir, which is awkward in Kubernetes. Mirror-clone runs externally, captures all repo content, and restores cleanly to a fresh forge. For full state including issues and PRs, run both — but mirror-clone is the load-bearing one.\nDebugging signatures# HTTP 422 — branch_protections POST when a rule already exists for that branch HTTP 409 — collaborator grant conflict (already a collaborator at a different permission) HTTP 404 — GET /collaborators/{u}/permission when {u} is not a collaborator at all review.official == false — in GET /repos/{o}/{r}/pulls/{n}/reviews response \u0026#34;approvals_count\u0026#34;: 0 — branch protection status when reviews are non-official clone.err: Could not resolve host — port-forward died mid-backupEach of these maps to a specific section above. 422 and 409 route through the POST-then-PATCH branch-protection flow. 404 on the permission endpoint is the audit script\u0026rsquo;s signal that a reviewer needs a collaborator grant. official: false on an APPROVED review is the collaborator-trap (see companion). A failed clone mid-backup almost always means the ephemeral port-forward died; the trap cleanup EXIT handler should restart it on the next cron tick.\nQuotable lessons# Treat every bot as a first-class user with its own account, its own token, and its own scoped permissions — shared admin credentials erase the audit trail.\nBranch protection is configuration, not clickops — render it from a script that walks every repo and PATCHes the rule into shape.\nRead collaborator permission is a footgun for review bots — reviews need write to be official.\nBackups that aren\u0026rsquo;t tested are aspirational — mirror-clone tarballs with a manifest of HEAD commits give a restore path that can be dry-run.\nCron\u0026rsquo;s stripped PATH is the most common cause of silent backup failures on macOS — use absolute tool paths and tee everything to a log file.\n","date":"2026-05-07","description":"Operational patterns for running a self-hosted Gitea forge on Kubernetes — Helm chart wiring, per-identity service accounts with scoped tokens, branch protection as code, webhook configuration, and mirror-clone backup.","lastmod":"2026-05-07","levels":["intermediate"],"reading_time_minutes":12,"section":"knowledge","skills":["gitea-deployment","branch-protection-as-code","scoped-token-management","forge-backup"],"tags":["gitea","kubernetes","helm","branch-protection","webhooks","backup","self-hosted-forge"],"title":"Self-hosting Gitea on Kubernetes: Identities, Protection, Webhooks, Backup","tools":["gitea","helm","kubectl","curl"],"url":"https://agent-zone.ai/knowledge/cicd/self-hosting-gitea-on-kubernetes/","word_count":2522}}