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
App URLs
Depends on
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:*-rootlesswithrunAsNonRoot: 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'sgitlistener 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.
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.
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