Gitea
Gitea is a lightweight, self-hosted Git service. It bundles repository hosting, issues, pull requests, a package registry, and CI/CD via Gitea Actions into a single Go binary that comfortably fits the resources of a homelab node.
Why Gitea
Gitea is the source of truth for the homelab itself — this very Docusaurus site, the Flux GitOps repo, and a handful of other personal projects all live there. The criteria that pushed it ahead of the alternatives:
- Self-sovereignty. No per-seat pricing, no telemetry, no vendor migration to plan around.
- Resource footprint. Runs comfortably alongside everything else on the cluster; the database and the app together use a fraction of what GitLab CE would.
- Drop-in
git/ GitHub-like workflow. Issues, PRs, branch protection, webhooks, REST + GraphQL API — most tooling that talks to GitHub talks to Gitea. - Native Actions runners.
gitea-runnerconnects with a registration token from a SOPS-encrypted secret, so CI lives entirely on cluster.
Alternatives considered
| Option | Hosted | Self-hosted | Why not |
|---|---|---|---|
| GitHub | ✓ | Self-sovereignty, want code to live on owned hardware | |
| GitLab.com / GitLab CE | ✓ | ✓ | Heavy resource footprint relative to Gitea |
| Codeberg | ✓ | Great org — used as a mirror — but not a primary host | |
| Forgejo | ✓ | Friendly fork; tracked, but Gitea's release cadence is fine here |
Installation
The general shape of a Gitea deployment in this homelab:
- Custom manifests, not the upstream Helm chart. The chart bundles a stack (Postgres, in-cluster TLS, etc.) that conflicts with the homelab's own primitives — CNPG for Postgres, Envoy Gateway for ingress, cert-manager for TLS. A small set of hand-rolled manifests composes those primitives cleanly.
- Database via cnpg component. A per-app CNPG
Clusterfor Postgres, with logical backups via the backups component. - Repository storage on Longhorn. A
ReadWriteOncePVC against thelonghorn-encryptedstorage class — see the storage resource. Encrypted at rest via SOPS-managed keys. - HTTP via
HTTPRoute, SSH viaTCPRoute. Both reference the sameService(different ports). The SSH TCPRoute is what makesgit pushover SSH preserve the real client IP — see Topics → Real client IPs across the chain for the wiring. - PROXY-protocol-v2 between the edge and production Envoys on the
gitea-tcplistener, so the SSH connection carries the original client IP through the chain.
Administration
- Rotating the runner registration token. Generate a new token in Site Administration → Actions → Runners, update the SOPS-encrypted Secret, commit. Existing
gitea-runnerpods need to be re-registered after the token rotates — easiest to drain and re-deploy them. See apps/gitea-runner. - OIDC / SSO is configured in
app.inirather than environment variables (Gitea's convention). The relevant section inapp.inireferences Keycloak via standard OIDC. Mounted via a SOPS-encrypted Secret in a ConfigMap-style block. - Audit log. Site Administration → Monitoring → Logs. With PPv2 wired through correctly, the audit log shows the real client IP, not the edge Envoy's. If audit entries are coming through as the edge IP, something in the PPv2 chain is broken.
- Upgrades. Renovate-pinned image digest; auto-merge on patch / digest only — major bumps wait for a human read.
Usage
Gitea hosts the repositories, issues, and pull requests for everything in the homelab. Day-to-day interactions:
- Git over HTTPS to
gitea.web.kueber.eu/<owner>/<repo>.git— the standard PR-and-push flow. - Git over SSH to
git@gitea.web.kueber.eu— pushes that exercise the edge → production TCP chain. - Gitea Actions runs CI on each push via the connected
gitea-runner. Renovate runs as a scheduled job on the same runner; that's the GitOps flow starting point. - REST + GraphQL API for the few automations that touch Gitea programmatically (issue creators, status pingers, etc.).
- Mirror to Codeberg. Each repo can mirror itself to Codeberg on a schedule — a small disaster-recovery hedge in case the local Gitea is unreachable. See Topics → DR drill for how this fits into the full recovery story.
Cluster Deployment
Gitea — Talos cluster
Cluster-specific notes only. General product info, "why we use it", and alternatives live in docusaurus/docs/apps/gitea.mdx.
Deviations from defaults
Defaults live in docusaurus/docs/apps/gitea.mdx. The Talos deployment differs in the following ways:
runAsUser: 0,runAsNonRoot: false— Gitea's bundled OpenSSH server requires root to bind privileged ports for the SSH listener. The Pod Security Standards override is namespace-scoped (label patched in this app's workspace component consumption).- No upstream Helm chart — the manifests are hand-rolled here. The chart's bundled Postgres + ingress conflict with CNPG and Envoy Gateway, respectively. Tracked manually rather than via Renovate's chart manager.
TCPRouteinstead ofHTTPRoutefor SSH traffic — Gitea's SSH listener is L4; anHTTPRoutewould terminate TLS where there is none. The TCPRoute carries PROXY-protocol-v2 so the audit log records the real client IP, not the edge Envoy.
- Image:
gitea/gitea:1.26.2-rootless@sha256:c5c21a7705a16f2b2369384a3b7d67c5ed761a818bbb0a55187b5cf98cdc2e68
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app: gitea
name: gitea
namespace: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
homepage: active
ingress: public
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app: gitea
homepage: active
ingress: public
spec:
containers:
- env:
- name: GITEA__database__DB_TYPE
value: postgres
- name: GITEA__database__HOST
valueFrom:
secretKeyRef:
key: host
name: cnpg-app
- name: GITEA__database__NAME
valueFrom:
secretKeyRef:
key: dbname
name: cnpg-app
- name: GITEA__database__USER
valueFrom:
secretKeyRef:
key: user
name: cnpg-app
- name: GITEA__database__PASSWD
valueFrom:
secretKeyRef:
key: password
name: cnpg-app
envFrom:
- secretRef:
name: gitea
image: gitea/gitea:1.26.2-rootless@sha256:c5c21a7705a16f2b2369384a3b7d67c5ed761a818bbb0a55187b5cf98cdc2e68
livenessProbe:
failureThreshold: 5
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
name: gitea
ports:
- containerPort: 3000
name: http
protocol: TCP
- containerPort: 2222
name: git
protocol: TCP
readinessProbe:
failureThreshold: 5
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
volumeMounts:
- mountPath: /data
name: gitea-data
- mountPath: /etc/gitea/signing
name: signing-key
readOnly: true
- mountPath: /tmp
name: tmp
securityContext:
fsGroup: 1000
runAsGroup: 1000
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 60
volumes:
- name: gitea-data
persistentVolumeClaim:
claimName: gitea-data
- name: signing-key
secret:
defaultMode: 384
secretName: gitea-signing-key
- emptyDir: {}
name: tmp