❯ apps · Running

Forge Git auto-hébergée avec Gitea Self-Hosted Git Forge with Gitea

Déploiement de Gitea sur Kubernetes avec PostgreSQL — particularités de s6-overlay, mirroring bulk depuis GitHub, Cloudflare Access en réseau local, et les pièges bash qui coûtent des heures de débogage. Deploying Gitea on Kubernetes with a PostgreSQL backend — s6-overlay quirks, bulk GitHub mirroring, Cloudflare Access on LAN, and the bash traps that cost hours of debugging.

· 14 min de lecture 14 min read ·
k3s-clustergiteaselfhostk8sgit
🇫🇷 Lire cet article en français

🔄 Update — June 2026. Gitea is no longer a mirror of GitHub: since the migration, Gitea is the homelab’s source of truth (code, GitOps and CI). The repositories have been converted into primary repos, ArgoCD reads Gitea internally, and GitHub is nothing more than a backup. The pull mirroring described below belongs to the old architecture (which, incidentally, had broken silently). See Migrating a GitOps homelab from GitHub to Gitea.

The bulk mirroring script ran, printed ✓ mirrored: 1, then stopped with no visible error — 22 repositories out of 23 missing, and nothing in the logs to explain why. The cause, found later: a combination of set -e and ((success++)) in bash, a classic trap detailed further down this article. But before getting there, Gitea itself had to be deployed.

Why host your own Git?

Gitea fills several roles in this homelab:

  1. Local mirror of GitHub: the 23 GitHub repositories are synchronised automatically every 8h. Locally available even without internet.
  2. Docker image registry: Gitea includes an OCI registry for custom images.
  3. Local CI/CD: compatible with the GitHub Actions syntax.
  4. Learning: understanding a Git forge by deploying one yourself.

Kubernetes architecture

Gitea needs the main application plus a PostgreSQL database.

Ingress (traefik)
    │
    ▼
Service gitea :3000
    │
    ▼
InitContainer (busybox, root)        Deployment gitea (s6-overlay)
  └─ chown /data/git/.ssh ──────────▶  git user (UID 1000)
    │
    │  DB connection
    ▼
Service postgres :5432
    │
    ▼
Deployment postgres
    │  PVC (Longhorn, 5Gi)
    ▼
PersistentVolume

Quirk: s6-overlay

Gitea uses s6-overlay as its process supervision system (PID 1). s6-overlay must start as root to initialise the services, then drops to the git user (UID 1000).

Adding runAsNonRoot: true or runAsUser: 1000 to the container securityContext prevents s6-overlay from initialising:

s6-svscan: fatal: unable to chdir to /etc/s6: Permission denied

Correct configuration: do not set runAsUser at container level. Use only fsGroup: 1000 at pod level.

spec:
  securityContext:
    fsGroup: 1000        # ✅ /data belongs to the git group
  containers:
  - name: gitea
    # No container-level securityContext

Trap: SSH permissions at startup

On the first start against an empty volume, Gitea fails with:

permission denied (publickey)  # during git SSH operations

The /data/git/.ssh directory does not exist yet on the PVC and must be owned by UID 1000. An InitContainer takes care of it before Gitea starts:

initContainers:
- name: fix-ssh-permissions
  image: busybox
  command:
    - sh
    - -c
    - mkdir -p /data/git/.ssh && chown -R 1000:1000 /data/git/.ssh && chmod 700 /data/git/.ssh
  securityContext:
    runAsUser: 0          # root required for chown
  volumeMounts:
  - name: data
    mountPath: /data

Configuration through environment variables

Gitea exposes its entire configuration through GITEA__<section>__<key>:

env:
- name: GITEA__database__DB_TYPE
  value: postgres
- name: GITEA__database__HOST
  value: postgres.gitea.svc.cluster.local:5432
- name: GITEA__server__DOMAIN
  value: gitea.olig-homelab-2025.win
- name: GITEA__server__ROOT_URL
  value: https://gitea.olig-homelab-2025.win/
- name: GITEA__mirror__ENABLED
  value: "true"
- name: GITEA__mirror__DEFAULT_INTERVAL
  value: "8h"
- name: GITEA__service__DISABLE_REGISTRATION
  value: "true"
- name: GITEA__service__REQUIRE_SIGNIN_VIEW
  value: "true"

DISABLE_REGISTRATION and REQUIRE_SIGNIN_VIEW are critical — a Gitea exposed on the internet without these restrictions is an immediate target.

Bulk mirroring from GitHub

Creating 23 mirrors by hand through the web interface would take an hour. The Gitea API exposes a /api/v1/repos/migrate endpoint that makes it possible to automate everything in a bash script.

The mirror-github-repos.sh script

#!/usr/bin/env bash
set -euo pipefail

GITHUB_USER="${GITHUB_USER:-YellowBeanie}"
GITEA_URL="${GITEA_URL:-https://gitea.olig-homelab-2025.win}"
MIRROR_INTERVAL="${MIRROR_INTERVAL:-8h}"

: "${GITHUB_TOKEN:?GITHUB_TOKEN env var is required}"
: "${GITEA_TOKEN:?GITEA_TOKEN env var is required}"

# Auto-detect the Gitea username from the token (avoids casing issues)
GITEA_USER=$(curl -sf \
  -H "Authorization: token $GITEA_TOKEN" \
  "$GITEA_URL/api/v1/user" | python3 -c "import sys,json; print(json.load(sys.stdin)['login'])")

# GitHub pagination: fetch every private repo
page=1; repos=()
while true; do
  batch=$(curl -sf \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    "https://api.github.com/user/repos?per_page=100&page=$page&affiliation=owner")
  count=$(echo "$batch" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
  [ "$count" -eq 0 ] && break
  while IFS= read -r repo; do repos+=("$repo")
  done < <(echo "$batch" | python3 -c "
import sys, json
for r in json.load(sys.stdin):
    print(r['clone_url'], r['name'])")
  ((page++))
done

success=0; skipped=0; failed=0

for entry in "${repos[@]}"; do
  clone_url=$(echo "$entry" | awk '{print $1}')
  repo_name=$(echo "$entry" | awk '{print $2}')

  # Idempotency check: skip if already present
  http_check=$(curl -so /dev/null -w "%{http_code}" \
    -H "Authorization: token $GITEA_TOKEN" \
    "$GITEA_URL/api/v1/repos/$GITEA_USER/$repo_name")

  if [ "$http_check" = "200" ]; then
    echo "↩  $repo_name — already exists, skipping"
    skipped=$((skipped + 1))
    continue
  fi

  http_code=$(curl -s -o /tmp/gitea_response.json -w "%{http_code}" \
    -X POST "$GITEA_URL/api/v1/repos/migrate" \
    -H "Authorization: token $GITEA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"clone_addr\":\"$clone_url\",\"auth_token\":\"$GITHUB_TOKEN\",
         \"repo_name\":\"$repo_name\",\"repo_owner\":\"$GITEA_USER\",
         \"mirror\":true,\"mirror_interval\":\"$MIRROR_INTERVAL\",
         \"private\":true,\"service\":\"github\"}")

  if [ "$http_code" = "201" ]; then
    echo "✓  $repo_name"
    success=$((success + 1))
  else
    msg=$(python3 -c "import sys,json; d=json.load(open('/tmp/gitea_response.json')); \
      print(d.get('message','unknown error'))" 2>/dev/null || echo "unknown error")
    echo "✗  $repo_name — HTTP $http_code: $msg"
    failed=$((failed + 1))
  fi
done

echo "✓ mirrored: $success | ↩ skipped: $skipped | ✗ failed: $failed"
[ "$failed" -gt 0 ] && exit 1 || exit 0

The script is idempotent: re-running it after an interruption skips the mirrors already created.

Obstacle: Cloudflare Access blocks the API

Gitea is protected by Cloudflare Access (Zero Trust). From the outside, or from a session without a CF cookie, every request to gitea.olig-homelab-2025.win is redirected to the Cloudflare login page:

$ curl -s https://gitea.olig-homelab-2025.win/api/v1/user -H "Authorization: token xxx"
# → 302 redirect to cloudflareaccess.com/cdn-cgi/access/login/...
# curl -f returns exit code 22, Python receives an empty string → JSONDecodeError

Solution for the local network: add an entry to /etc/hosts (Linux/Mac) or C:\Windows\System32\drivers\etc\hosts (Windows) pointing straight at Traefik’s IP:

10.0.0.22  gitea.olig-homelab-2025.win

On the LAN, DNS resolves to Traefik directly — Cloudflare is never involved. Run the script with GITEA_URL=http://gitea.olig-homelab-2025.win (HTTP, the ingress does not terminate TLS locally).

Gitea token scopes

The API token needs both scopes:

  • read:user — required by /api/v1/user for username auto-detection
  • write:repository — required by /api/v1/repos/migrate to create the mirrors

A write:repository-only token returns HTTP 403 on /api/v1/user. With curl’s -f flag, that 403 produces empty output → JSONDecodeError in Python.

Bash traps encountered

CRLF on Windows: Git on Windows converts line endings LF → CRLF by default. A bash script with CRLF fails silently or with cryptic errors. Fix: add a .gitattributes at the root of the repo:

*.sh text eol=lf
* text=auto

Then renormalise the existing files:

git add --renormalize .
git add gitea/mirror-github-repos.sh
git commit -m "chore: re-normalize shell scripts to LF"

set -e + ((counter++)) = silent exit: in bash, (( expression )) returns exit code 1 when the expression evaluates to 0. With set -e, ((success++)) when success=0 kills the script after the first success — exactly what happened: only the first repo got mirrored.

# ❌ Kills the script when the counter is 0
((success++))

# ✅ Always safe
success=$((success + 1))

Synchronised development workflow

Here is how the three environments (local PC, Gitea, GitHub) stay in sync:

⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.

┌──────────────────────────────────────────────────────┐
│                   Local PC                           │
│  git push origin → GitHub (remote "origin")          │
│  git push gitea  → Gitea  (remote "gitea", optional) │
│                                                      │
│  Direct access (LAN) through the hosts file:         │
│  10.0.0.22 → gitea.olig-homelab-2025.win             │
└──────────────┬───────────────────────────────────────┘
               │ git push
               ▼
┌──────────────────────────┐
│         GitHub           │
│  (source of truth)       │
│                          │
│  GitHub Actions:         │
│  push → build Docker     │
│  → ghcr.io/yellowbeanie/ │
└─────────────┬────────────┘
              │ mirror pull (every 8h)
              ▼
┌──────────────────────────┐     ┌─────────────────────┐
│         Gitea            │     │       ArgoCD        │
│  gitea.olig-homelab-..   │     │                     │
│  23 mirrored repos       │     │  watches GitHub     │
│  Cloudflare Access       │     │  deploys to k3s     │
│  (external) or           │     │  when a manifest    │
│  Traefik direct (LAN)    │     │  changes            │
└──────────────────────────┘     └─────────────────────┘

For applications (e.g. the blog) the CI/CD flow is:

⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.

git push actual-state
    │
    ▼ GitHub Actions (blog.yml)
    │  - QEMU + Buildx (linux/arm64)
    │  - docker build → ghcr.io/yellowbeanie/homelab-blog:latest
    ▼
ArgoCD detects the image change (3min polling)
    │
    ▼
kubectl rollout → blog pod on k3s
    │
    ▼
blog.olig-homelab-2025.win (Traefik → pod)

For Kubernetes manifests (e.g. a new service):

⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.

Edit gitea/deploy-gitea.yaml
    │
    ▼ git push
    │
ArgoCD automatic sync (or manual)
    │
    ▼
kubectl apply → k3s

Trap: PostgreSQL

PostgreSQL must start as root for initdb. Do not set a securityContext on the postgres container — the postgres:16-alpine image handles the transition itself.

If the postgres pod crashed several times before initialisation completed, the password hash can end up inconsistent:

FATAL: password authentication failed for user "gitea"

Fix through kubectl exec:

kubectl exec -n gitea deployment/postgres -- \
  psql -U gitea -d gitea -c \
  "ALTER USER gitea WITH PASSWORD 'new-password';"

Then update the matching SealedSecret and restart Gitea.

Update 2026-05-12 — Proxy headers behind Traefik

The problem: Gitea, sitting behind Traefik (which terminates TLS through the Cloudflare Tunnel), was serving http:// URLs instead of https:// in its notifications, its redirects, and the git clone URL displayed in the UI. Concrete symptoms: HTTPS clones failed because Gitea redirected to the HTTP port, and webhooks were generated as http://.

Root cause: Gitea did not trust the X-Forwarded-Proto: https headers sent by Traefik. Those headers arrive from an IP in the pod CIDR (10.42.0.0/16), not from an IP explicitly declared as trusted in the Gitea configuration — so they were silently ignored.

Fix (commit 379a887 fix(gitea): trust pod CIDR proxy headers to fix HTTPS scheme detection): declare the pod CIDR as a trusted proxy. Concretely, in the gitea-config ConfigMap (or app.ini):

[server]
PROTOCOL = http
DOMAIN   = gitea.olig-homelab-2025.win
ROOT_URL = https://gitea.olig-homelab-2025.win/
REVERSE_PROXY_TRUSTED_PROXIES = 10.42.0.0/16
REVERSE_PROXY_AUTHENTICATION_HEADER = X-Forwarded-User
REVERSE_PROXY_LIMIT = 1

The lesson: any service that builds absolute URLs must be able to recognise the scheme of the proxy in front of it. It is the same trap as with WordPress, GitLab or Mattermost — always think about REVERSE_PROXY_TRUSTED_PROXIES (or its equivalent: trusted_proxies in WP, nginx_listen_port in GitLab) as soon as a reverse proxy terminates TLS.

Lessons learned

Always debug curl manually before writing a script: a curl -sv URL reveals in 10 seconds whether the problem is DNS, TLS, auth, or content. The -f flag hides HTTP errors — never use it without capturing the response body alongside.

Test token scopes: modern APIs use granular scopes. Check every endpoint manually with curl -w "%{http_code}" before wiring it into a script.

set -euo pipefail is your friend… and your enemy: this set is essential for robust scripts, but it exposes silent bugs in common bash constructs ((( ++ )), pipelines, subshells). Always test the script with counters at 0.

.gitattributes files save hours: on a mixed Linux/Windows team, or as soon as a bash script lives in a repo cloned on Windows, a .gitattributes with *.sh text eol=lf avoids hours of debugging \r: command not found.

k3s-clustergiteaselfhostk8sgit