Skip to main content

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-runner connects with a registration token from a SOPS-encrypted secret, so CI lives entirely on cluster.

Alternatives considered

OptionHostedSelf-hostedWhy not
GitHubSelf-sovereignty, want code to live on owned hardware
GitLab.com / GitLab CEHeavy resource footprint relative to Gitea
CodebergGreat org — used as a mirror — but not a primary host
ForgejoFriendly 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 Cluster for Postgres, with logical backups via the backups component.
  • Repository storage on Longhorn. A ReadWriteOnce PVC against the longhorn-encrypted storage class — see the storage resource. Encrypted at rest via SOPS-managed keys.
  • HTTP via HTTPRoute, SSH via TCPRoute. Both reference the same Service (different ports). The SSH TCPRoute is what makes git push over 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-tcp listener, 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-runner pods 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.ini rather than environment variables (Gitea's convention). The relevant section in app.ini references 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.
  • TCPRoute instead of HTTPRoute for SSH traffic — Gitea's SSH listener is L4; an HTTPRoute would 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.
Kubernetes Metadata
  • 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