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

Deviations from defaults​

Defaults live in docusaurus/docs/apps/gitea.mdx. The Talos deployment differs in the following ways:

  • Rootless image, uid 1000 — gitea/gitea:*-rootless with runAsNonRoot: true, a read-only root filesystem and all capabilities dropped. SSH is Gitea's built-in server on port 2222 (no OpenSSH, no privileged port); the gateway's git listener maps it for clients.
  • 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:28.1.0-rootless@sha256:b1a90409ae9afcd5a5725404c3639da7245248848b99e35332968891290b953e
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: gitea
name: gitea
namespace: gitea
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: gitea
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: gitea
spec:
automountServiceAccountToken: false
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
- name: GITEA__mailer__ENABLED
value: 'true'
- name: GITEA__mailer__PROTOCOL
value: smtp+starttls
- name: GITEA__mailer__SMTP_ADDR
valueFrom:
secretKeyRef:
key: host
name: allinkl-smtp-credentials
- name: GITEA__mailer__SMTP_PORT
valueFrom:
secretKeyRef:
key: port
name: allinkl-smtp-credentials
- name: GITEA__mailer__USER
valueFrom:
secretKeyRef:
key: username
name: allinkl-smtp-credentials
- name: GITEA__mailer__PASSWD
valueFrom:
secretKeyRef:
key: password
name: allinkl-smtp-credentials
- name: GITEA__mailer__FROM
valueFrom:
secretKeyRef:
key: from
name: allinkl-smtp-credentials
envFrom:
- secretRef:
name: gitea
image: gitea/gitea:28.1.0-rootless@sha256:b1a90409ae9afcd5a5725404c3639da7245248848b99e35332968891290b953e
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
resources:
limits:
memory: 768Mi
requests:
cpu: 100m
memory: 768Mi
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
priorityClassName: tier-1
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