---
title: "Self-hosting Gitea on Kubernetes: Identities, Protection, Webhooks, Backup"
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."
url: https://agent-zone.ai/knowledge/cicd/self-hosting-gitea-on-kubernetes/
section: knowledge
date: 2026-05-07
categories: ["cicd"]
tags: ["gitea","kubernetes","helm","branch-protection","webhooks","backup","self-hosted-forge"]
skills: ["gitea-deployment","branch-protection-as-code","scoped-token-management","forge-backup"]
tools: ["gitea","helm","kubectl","curl"]
levels: ["intermediate"]
word_count: 2522
formats:
  json: https://agent-zone.ai/knowledge/cicd/self-hosting-gitea-on-kubernetes/index.json
  html: https://agent-zone.ai/knowledge/cicd/self-hosting-gitea-on-kubernetes/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Self-hosting+Gitea+on+Kubernetes%3A+Identities%2C+Protection%2C+Webhooks%2C+Backup
---


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](../gitea-collaborator-trap/) covers the narrow operational gotcha of `official=false` reviews; this article is the broader runbook for running the forge well.

## Helm 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:

```yaml
image:
  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: <secret>
      SSL_MODE: disable
    server:
      ROOT_URL: "http://localhost:3000"
      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: <admin-user>
    password: <admin-password>
    email: admin@example.local
persistence:
  enabled: true
  size: 5Gi
resources:
  requests: { cpu: "100m", memory: "128Mi" }
  limits:   { memory: "512Mi" }
```

Three of these values do real work and aren't obvious from the chart README:

- `image.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's default pattern: `gitea-http.<namespace>.svc.cluster.local:3000`. CI pods in the same cluster reach the forge over this DNS without ingress.

For 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.

## Per-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.

| Gitea 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:

```bash
curl -u <admin-user>:<admin-password> \
  -X POST https://gitea-host/api/v1/admin/users \
  -H "Content-Type: application/json" \
  -d '{
    "username": "review-bot-a",
    "email": "review-bot-a@local",
    "password": "<initial-password>",
    "must_change_password": false
  }'
```

Token issuance is a separate call against the user's namespace:

```bash
curl -u <admin-user>:<admin-password> \
  -X POST https://gitea-host/api/v1/users/review-bot-a/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "name": "daemon",
    "scopes": ["write:repository", "write:issue", "read:user"]
  }'
# Response: {"sha1": "<token>", ...}
```

The user account survives token revocation. Rotating a token means issuing a new one under the same `name`; the user's collaborator grants on every repo stay intact. Tokens get stored under predictable Secret keys so pods can fetch them at startup:

```bash
kubectl get secret hub-secrets \
  -o jsonpath='{.data.gitea-token-review-bot-a}' | base64 -d
```

Production deployments lead with token-auth (`Authorization: token <sha1>`) 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's password.

The trade-off is explicit: one shared admin token would be simpler to wire, but it erases attribution (every commit, review, and merge attributed to "admin"), 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 "who pushed this."

## Branch 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.

The Gitea API exposes two endpoints:

- `POST /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`:

```json
{
  "branch_name": "main",
  "enable_push": true,
  "enable_push_whitelist": true,
  "push_whitelist_usernames": ["forge-admin"],
  "enable_merge_whitelist": false,
  "enable_status_check": true,
  "status_check_contexts": ["jenkins/pipeline"],
  "required_approvals": 1,
  "block_on_rejected_reviews": true,
  "dismiss_stale_approvals": true
}
```

Field-by-field semantics:

- `enable_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.

```bash
for repo in $(gitea_list_repos); do
  status=$(curl -s -o /dev/null -w '%{http_code}' \
    -u "$ADMIN_USER:$ADMIN_PASS" \
    -X POST "$GITEA/api/v1/repos/$OWNER/$repo/branch_protections" \
    -H "Content-Type: application/json" \
    -d "$PROTECTION_JSON")
  if [[ "$status" =~ ^(409|422)$ ]]; then
    curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
      -X PATCH "$GITEA/api/v1/repos/$OWNER/$repo/branch_protections/main" \
      -H "Content-Type: application/json" \
      -d "$PROTECTION_JSON"
  fi
done
```

The 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.

A 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:

```bash
SKIP_REPOS="platform-bootstrap shared-lib jenkins-shared-library"
case " $SKIP_REPOS " in *" $repo "*) continue ;; esac
```

Versioning 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.

## The 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's `required_approvals` counts only `official: true` reviews, so a non-collaborator's APPROVE doesn'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.

This 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](../gitea-collaborator-trap/). That article walks the API responses, the retroactive-flip rule, the diagnostic ladder, and an idempotent audit script that prevents recurrence.

## Webhook 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.

Endpoints:

- List 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:

```json
{
  "type": "gitea",
  "config": {
    "url": "http://ci.example.svc.cluster.local:8080/gitea-webhook/post",
    "content_type": "json",
    "secret": "<shared-secret>"
  },
  "events": ["push", "pull_request"],
  "active": true
}
```

Event scope is the load-bearing choice:

- `push` — 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:

```bash
existing=$(curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
  "$GITEA/api/v1/repos/$OWNER/$repo/hooks" | jq -r '.[].config.url')
if echo "$existing" | grep -q "$CI_HOST"; then
  echo "skip: $repo already wired"
  continue
fi
curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
  -X POST "$GITEA/api/v1/repos/$OWNER/$repo/hooks" \
  -H "Content-Type: application/json" \
  -d "$HOOK_JSON"
```

**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:

```
"AllowedUntrustedInternalConnections":
  "ci.example.svc.cluster.local *.example.svc.cluster.local 10.0.0.0/8 192.168.0.0/16 172.16.0.0/12"
```

The same pattern applies to Gitea's own outgoing webhooks (notifications, mirroring) — `webhook.ALLOWED_HOST_LIST` in `app.ini` governs which destinations the forge will POST to. The default is "external only," and cluster-internal CI hosts must be added explicitly.

## Admin 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:

```bash
# Create user
curl -u <admin-user>:<admin-password> \
  -X POST https://gitea-host/api/v1/admin/users \
  -H "Content-Type: application/json" \
  -d '{"username":"new-bot","email":"new-bot@local","password":"<pw>","must_change_password":false}'

# Issue token for that user
curl -u <admin-user>:<admin-password> \
  -X POST https://gitea-host/api/v1/users/new-bot/tokens \
  -H "Content-Type: application/json" \
  -d '{"name":"daemon","scopes":["write:repository","write:issue","read:user"]}'

# Add collaborator with write
curl -u <admin-user>:<admin-password> \
  -X PUT https://gitea-host/api/v1/repos/{owner}/{repo}/collaborators/new-bot \
  -H "Content-Type: application/json" \
  -d '{"permission":"write"}'

# Enumerate repos owned by a user (paginate page=1..N until empty)
curl -u <admin-user>:<admin-password> \
  "https://gitea-host/api/v1/user/repos?limit=50&page=1"

# Enumerate repos by owner (works for users and orgs)
curl -u <admin-user>:<admin-password> \
  "https://gitea-host/api/v1/repos/search?owner={owner}&limit=50&page=1"
```

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:

- User-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's the only endpoint that works regardless of whether the owner is a user or an org.

## Backup 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.

```bash
DATE=$(date +%Y-%m-%d)
DEST="$DEST_ROOT/$DATE"
mkdir -p "$DEST"
tmp=$(mktemp -d)
trap "rm -rf $tmp" EXIT

for repo in $(list_repos); do
  url="http://$ADMIN_USER:$ADMIN_PASS@gitea-host/$OWNER/$repo.git"
  git clone --quiet --mirror "$url" "$tmp/$repo.git"
  head_ref=$(cd "$tmp/$repo.git" && git symbolic-ref HEAD)
  head_commit=$(cd "$tmp/$repo.git" && git rev-parse HEAD)
  tar -C "$tmp" -czf "$DEST/$repo.tgz" "$repo.git"
  size=$(stat -f%z "$DEST/$repo.tgz" 2>/dev/null || stat -c%s "$DEST/$repo.tgz")
  sha=$(shasum -a 256 "$DEST/$repo.tgz" | awk '{print $1}')
  printf '%s\t%d\t%s\t%s\t%s\n' "$repo" "$size" "$sha" "$head_ref" "$head_commit" \
    >> "$DEST/MANIFEST.txt"
done

# Prune day-dirs older than RETENTION_DAYS
find "$DEST_ROOT" -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -exec rm -rf {} \;
```

Operational notes that catch real-world breakage:

- **Absolute paths to every binary** (`/usr/bin/git`, `/usr/bin/tar`, `/usr/bin/curl`, `/usr/local/bin/kubectl`) — cron's stripped `PATH` is the most common cause of silent backup failures.
- **Ephemeral `kubectl port-forward`** if Gitea isn't exposed over ingress, with a `trap cleanup EXIT` to kill the forward on script exit.
- **macOS Full Disk Access**: cron jobs touching `/Volumes/<your-backup-drive>/gitea-backups` need Full Disk Access granted to `/usr/sbin/cron` under System Settings → Privacy & Security. Without it, the cron job runs but fails silently on the volume write.
- **Manifest format**: `<repo>\t<size_bytes>\t<sha256>\t<HEAD_ref>\t<HEAD_commit>` — 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](../../sre/single-node-kubernetes-disaster-recovery/). The mirror-clone backup above is the repo-content slice; for "rebuild the forge from scratch" the DR runbook is the right entry point.

## Trade-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.

**Branch 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.

**Reviewer 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't gate the merge.

**External 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.

**Mirror-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.

## Debugging 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
"approvals_count": 0            — branch protection status when reviews are non-official
clone.err: Could not resolve host  — port-forward died mid-backup
```

Each 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'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.

## Quotable 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.

**Branch protection is configuration, not clickops** — render it from a script that walks every repo and PATCHes the rule into shape.

**Read collaborator permission is a footgun for review bots** — reviews need `write` to be official.

**Backups that aren't tested are aspirational** — mirror-clone tarballs with a manifest of HEAD commits give a restore path that can be dry-run.

**Cron'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.

