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.
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:
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-haall set toenabled: 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. Settingpostgresql.enabled: falsealone leaves the HA chart enabled.cache.ADAPTER: memory+queue.TYPE: level+session.PROVIDER: db— together these eliminate the Redis dependency.session.PROVIDER: dbsurvives pod restarts without Redis;queue.TYPE: leveluses an embedded LevelDB;cache.ADAPTER: memoryis 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:
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:
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:
kubectl get secret hub-secrets \
-o jsonpath='{.data.gitea-token-review-bot-a}' | base64 -dProduction 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:
{
"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 cangit pushdirectly tomain. 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 asuccessstatus 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 to2for dual-approval gates. Counts only reviews whereofficial: 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 withwritecollaborator status can hit the merge button if the other gates pass. Flip totrueand addmerge_whitelist_usernames: [...]to restrict who can merge (commonly used to funnel all merges through amerge-botidentity).
The idempotent rollout pattern is POST-then-PATCH: try to create, fall back to update when the rule already exists.
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
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.
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:
SKIP_REPOS="platform-bootstrap shared-lib jenkins-shared-library"
case " $SKIP_REPOS " in *" $repo "*) 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.
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. 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:
{
"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 thestatus_check_contextsarray in branch protection.pull_request_reviewandissue_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:
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:
# 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.
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 strippedPATHis the most common cause of silent backup failures. - Ephemeral
kubectl port-forwardif Gitea isn’t exposed over ingress, with atrap cleanup EXITto kill the forward on script exit. - macOS Full Disk Access: cron jobs touching
/Volumes/<your-backup-drive>/gitea-backupsneed Full Disk Access granted to/usr/sbin/cronunder 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 singlecat MANIFEST.txtand 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 “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-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’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.