❯ 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
🇬🇧 Read this article in English

🔄 Mise à jour — juin 2026. Gitea n’est plus un miroir de GitHub : depuis la migration, Gitea est la source de vérité du homelab (code, GitOps et CI). Les dépôts ont été convertis en repos primaires, ArgoCD lit Gitea en interne, et GitHub n’est plus qu’un backup. Le mirroring pull décrit ci-dessous appartient à l’ancienne architecture (il s’était d’ailleurs cassé silencieusement). Voir Migrer un homelab GitOps de GitHub vers Gitea.

Le script de mirroring bulk a tourné, a affiché ✓ mirrored: 1, puis s’est arrêté sans erreur visible — 22 dépôts sur 23 manquants, et rien dans les logs qui explique pourquoi. La cause, découverte plus tard : une combinaison de set -e et ((success++)) en bash, un piège classique détaillé plus loin dans cet article. Mais avant d’en arriver là, il fallait déjà déployer Gitea lui-même.

Pourquoi héberger son propre Git ?

Gitea remplit plusieurs rôles dans ce homelab :

  1. Miroir local de GitHub : les 23 dépôts GitHub sont synchronisés automatiquement toutes les 8h. Disponibilité locale même sans internet.
  2. Registre d’images Docker : Gitea inclut un registre OCI pour les images custom.
  3. CI/CD local : compatible avec la syntaxe GitHub Actions.
  4. Apprentissage : comprendre une forge Git en la déployant soi-même.

Architecture Kubernetes

Gitea nécessite l’application principale et une base de données PostgreSQL.

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

Particularité : s6-overlay

Gitea utilise s6-overlay comme système de supervision de processus (PID 1). s6-overlay doit démarrer en tant que root pour initialiser les services, puis bascule vers l’utilisateur git (UID 1000).

Ajouter runAsNonRoot: true ou runAsUser: 1000 au securityContext du container empêche s6-overlay de s’initialiser :

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

Configuration correcte : ne pas définir runAsUser au niveau du container. Utiliser uniquement fsGroup: 1000 au niveau du pod.

spec:
  securityContext:
    fsGroup: 1000        # ✅ /data appartient au groupe git
  containers:
  - name: gitea
    # Pas de securityContext container-level

Piège : permissions SSH au démarrage

Au premier démarrage sur un volume vierge, Gitea échoue avec :

permission denied (publickey)  # lors des opérations SSH git

Le répertoire /data/git/.ssh n’existe pas encore sur le PVC et doit appartenir à UID 1000. Un InitContainer s’en charge avant que Gitea démarre :

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 requis pour chown
  volumeMounts:
  - name: data
    mountPath: /data

Configuration via variables d’environnement

Gitea expose toute sa configuration via 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 et REQUIRE_SIGNIN_VIEW sont critiques — un Gitea exposé sur internet sans ces restrictions est une cible immédiate.

Mirroring bulk depuis GitHub

Créer 23 miroirs à la main via l’interface web prendrait une heure. L’API Gitea expose un endpoint /api/v1/repos/migrate qui permet de tout automatiser en un script bash.

Le script mirror-github-repos.sh

#!/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-détection du username Gitea depuis le token (évite les problèmes de casse)
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'])")

# Pagination GitHub : récupérer tous les repos privés
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}')

  # Vérification idempotente : skip si déjà présent
  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

Le script est idempotent : re-lancer après une interruption saute les miroirs déjà créés.

Obstacle : Cloudflare Access bloque l’API

Gitea est protégé par Cloudflare Access (Zero Trust). Depuis l’extérieur ou depuis une session sans cookie CF, toute requête vers gitea.olig-homelab-2025.win est redirigée vers la page de login Cloudflare :

$ curl -s https://gitea.olig-homelab-2025.win/api/v1/user -H "Authorization: token xxx"
# → 302 redirect vers cloudflareaccess.com/cdn-cgi/access/login/...
# curl -f retourne exit code 22, Python reçoit une chaîne vide → JSONDecodeError

Solution pour le réseau local : ajouter une entrée dans /etc/hosts (Linux/Mac) ou C:\Windows\System32\drivers\etc\hosts (Windows) pointant directement sur l’IP de Traefik :

10.0.0.22  gitea.olig-homelab-2025.win

Sur le LAN, le DNS résout vers Traefik directement — Cloudflare n’est jamais impliqué. Lancer le script avec GITEA_URL=http://gitea.olig-homelab-2025.win (HTTP, l’ingress ne termine pas TLS localement).

Portée du token Gitea

Le token API doit avoir les deux scopes :

  • read:user — requis par /api/v1/user pour l’auto-détection du username
  • write:repository — requis par /api/v1/repos/migrate pour créer les miroirs

Un token write:repository seul retourne HTTP 403 sur /api/v1/user. Avec le flag -f de curl, ce 403 produit une sortie vide → JSONDecodeError en Python.

Pièges bash rencontrés

CRLF sur Windows : Git sur Windows convertit par défaut les fins de ligne LF → CRLF. Un script bash avec des CRLF échoue silencieusement ou avec des erreurs cryptiques. Solution : ajouter un .gitattributes à la racine du repo :

*.sh text eol=lf
* text=auto

Puis renormaliser les fichiers existants :

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

set -e + ((counter++)) = exit silencieux : En bash, (( expression )) retourne un exit code de 1 quand l’expression s’évalue à 0. Avec set -e, ((success++)) quand success=0 tue le script après le premier succès — exactement ce qui s’est passé : seul le premier repo a été mirroré.

# ❌ Tue le script quand le compteur est à 0
((success++))

# ✅ Toujours sûr
success=$((success + 1))

Workflow de développement synchronisé

Voici comment les trois environnements (PC local, Gitea, GitHub) restent synchronisés :

⚠️ Diagramme historique — architecture pré-migration, obsolète depuis le 2026-06-18. Voir la mise à jour en tête d’article.

┌──────────────────────────────────────────────────────┐
│                   PC Local                           │
│  git push origin → GitHub (remote "origin")          │
│  git push gitea  → Gitea  (remote "gitea", optionnel)│
│                                                      │
│  Accès direct (LAN) via hosts file :                 │
│  10.0.0.22 → gitea.olig-homelab-2025.win             │
└──────────────┬───────────────────────────────────────┘
               │ git push
               ▼
┌──────────────────────────┐
│         GitHub           │
│  (source de vérité)      │
│                          │
│  GitHub Actions :        │
│  push → build Docker     │
│  → ghcr.io/yellowbeanie/ │
└─────────────┬────────────┘
              │ mirror pull (toutes les 8h)
              ▼
┌──────────────────────────┐     ┌─────────────────────┐
│         Gitea            │     │       ArgoCD        │
│  gitea.olig-homelab-..   │     │                     │
│  23 repos mirrorés       │     │  surveille GitHub   │
│  Cloudflare Access       │     │  déploie sur k3s    │
│  (externe) ou            │     │  dès qu'un manifest │
│  Traefik direct (LAN)    │     │  change             │
└──────────────────────────┘     └─────────────────────┘

Pour les applications (ex: blog) le flux CI/CD est :

⚠️ Diagramme historique — architecture pré-migration, obsolète depuis le 2026-06-18. Voir la mise à jour en tête d’article.

git push actual-state
    │
    ▼ GitHub Actions (blog.yml)
    │  - QEMU + Buildx (linux/arm64)
    │  - docker build → ghcr.io/yellowbeanie/homelab-blog:latest
    ▼
ArgoCD détecte le changement d'image (polling 3min)
    │
    ▼
kubectl rollout → blog pod sur k3s
    │
    ▼
blog.olig-homelab-2025.win (Traefik → pod)

Pour les manifests Kubernetes (ex: nouveau service) :

⚠️ Diagramme historique — architecture pré-migration, obsolète depuis le 2026-06-18. Voir la mise à jour en tête d’article.

Modifier gitea/deploy-gitea.yaml
    │
    ▼ git push
    │
ArgoCD sync automatique (ou manuel)
    │
    ▼
kubectl apply → k3s

Piège : PostgreSQL

PostgreSQL doit démarrer en root pour initdb. Ne pas définir de securityContext sur le container postgres — l’image postgres:16-alpine gère elle-même la transition.

Si le pod postgres a crashé plusieurs fois avant l’initialisation complète, le hash du mot de passe peut être incohérent :

FATAL: password authentication failed for user "gitea"

Solution via kubectl exec :

kubectl exec -n gitea deployment/postgres -- \
  psql -U gitea -d gitea -c \
  "ALTER USER gitea WITH PASSWORD 'nouveau-mot-de-passe';"

Puis mettre à jour le SealedSecret correspondant et redémarrer Gitea.

Mise à jour 2026-05-12 — Headers proxy derrière Traefik

Le problème : Gitea, derrière Traefik (qui termine TLS via Cloudflare Tunnel), servait des URLs en http:// au lieu de https:// dans ses notifications, ses redirects, et l’URL git clone affichée dans l’UI. Symptômes concrets : les clones HTTPS échouaient parce que Gitea redirigait vers le port HTTP, et les webhooks étaient générés en http://.

Root cause : Gitea ne faisait pas confiance aux headers X-Forwarded-Proto: https envoyés par Traefik. Ces headers arrivent d’une IP dans le pod CIDR (10.42.0.0/16), pas d’une IP explicitement déclarée comme trusted dans la config Gitea — donc ils étaient silencieusement ignorés.

Fix (commit 379a887 fix(gitea): trust pod CIDR proxy headers to fix HTTPS scheme detection) : déclarer le pod CIDR comme proxy de confiance. Concrètement, dans la ConfigMap gitea-config (ou 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

La leçon : tout service qui construit des URLs absolues doit savoir reconnaître le scheme du proxy en amont. C’est le même piège qu’avec WordPress, GitLab ou Mattermost — penser systématiquement à REVERSE_PROXY_TRUSTED_PROXIES (ou son équivalent : trusted_proxies chez WP, nginx_listen_port chez GitLab) dès qu’un reverse proxy termine TLS.

Leçons apprises

Toujours debugger curl manuellement avant d’écrire un script : un curl -sv URL révèle en 10 secondes si c’est un problème de DNS, de TLS, d’auth, ou de contenu. Le flag -f masque les erreurs HTTP — ne jamais l’utiliser sans capturer le corps de la réponse en parallèle.

Tester la portée des tokens : les APIs modernes utilisent des scopes granulaires. Vérifier manuellement chaque endpoint avec curl -w "%{http_code}" avant d’intégrer dans un script.

set -euo pipefail est ton ami… et ton ennemi : ce set est indispensable pour des scripts robustes, mais il révèle des bugs silencieux dans des constructions bash courantes ((( ++ )), pipelines, sous-shells). Toujours tester le script avec des compteurs à 0.

Les fichiers .gitattributes sauvent des heures : sur une équipe mixte Linux/Windows, ou dès qu’un script bash est dans un repo cloné sur Windows, .gitattributes avec *.sh text eol=lf évite des heures de débogage de \r: command not found.

k3s-clustergiteaselfhostk8sgit