🔄 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 :
- Miroir local de GitHub : les 23 dépôts GitHub sont synchronisés automatiquement toutes les 8h. Disponibilité locale même sans internet.
- Registre d’images Docker : Gitea inclut un registre OCI pour les images custom.
- CI/CD local : compatible avec la syntaxe GitHub Actions.
- 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/userpour l’auto-détection du usernamewrite:repository— requis par/api/v1/repos/migratepour 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
git push y dépose le code, puis deux flux divergent —
le miroir pull toutes les 8h alimente Gitea, et ArgoCD déploie sur k3s
dès qu'un manifest change.
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.