Skip to content

GitOps — Forgejo + Renovate + CD

Date : 2026-05-07
Statut : ✅ Terminé — stabilisé après correctifs post-déploiement

Objectif

Mettre en place un système de mise à jour automatisé à l'échelle du serveur basé sur Git comme source de vérité :

  • Renovate détecte les nouvelles versions d'images Docker (toutes les 6h)
  • Forgejo héberge les compose files versionnés + crée des PRs automatiquement
  • Forgejo Actions déploie via SSH sur Proxmox à chaque merge sur main
  • Zot registry sert de proxy pull-through (Docker Hub, GHCR, lscr.io) pour tous les LXC, et résout l'absence d'internet sur LXC 103

Décisions arrêtées

Décision Choix Justification
Emplacement Forgejo Nouveau LXC 107 "forge" Isolation CI/CD, usage général futur
Registry scope Zot sur LXC 107, tous LXC Cache global + LXC 103 sans internet
CD engine SSH Proxmox → pct exec Volumes relatifs incompatibles avec Portainer Git-backed
Majors Review manuelle Breaking changes historiques sur *arr stack
Minor/patch Auto-merge après 3 jours stabilityDays: 3 Renovate
Secrets Git Exclus (.gitignore), stack.env Gitignored + backup séparé
LXC 103 / GHCR Image ref explicite Zot mirror Une seule image hors Docker Hub : Cup

Architecture déployée

Renovate (cron 06h00)
  → scanne homelab/infra sur git.ncls.ltd
  → PR avec bump version (+ changelog GitHub)

Review (manuelle pour majors, 3j stabilité pour minor/patch)
  → merge → push main

Forgejo Actions (deploy.yml)
  → SSH root@192.168.1.21 (Proxmox host)
  → git pull /mnt/lxc-data
  → pct exec NNN -- docker compose pull && up -d
    (pour LXC 103 : images via Zot 192.168.1.107:5000)

Zot registry (192.168.1.107:5000)
  → pull-through Docker Hub, GHCR, lscr.io onDemand
  → registry-mirrors sur daemon.json de chaque LXC

Structure monorepo /mnt/lxc-data (= homelab/infra)

/mnt/lxc-data/
├── .gitignore
├── .forgejo/workflows/
│   ├── renovate.yml         # cron 06h00 + workflow_dispatch
│   └── deploy.yml           # trigger push main → SSH deploy
├── renovate.json5           # majors=review, minor/patch=automerge 3j
├── 100-media/
│   ├── docker-compose.yml   # images épinglées
│   ├── stack.env            # GITIGNORED — PROWLARR_API_KEY
│   └── stack.env.example    # template versionné
├── 101-admin/
│   └── docker-compose.yaml
├── 103-network/
│   ├── docker-compose.yml   # Cup via Zot mirror pour GHCR
│   └── stack.env.example
├── 104-services/
│   └── docker-compose.yml
├── 105-web/
│   └── docker-compose.yml
├── 106-storage/
│   ├── docker-compose.yml
│   ├── stack.env            # GITIGNORED — SFTPGO_OIDC secrets
│   └── stack.env.example
└── 107-forge/
    ├── docker-compose.yml   # Forgejo + Runner + Zot
    └── zot/config.json

Phases d'implémentation

Phase 0 — Inventaire versions images

Relevé docker inspect sur chaque LXC pour remplacer :latest par tags sémantiques.

Phase 1 — LXC 107 "forge" : Forgejo + Zot

  • Création LXC 107 (Debian 12, 2GB RAM, 30GB, 192.168.1.107)
  • 107-forge/docker-compose.yml : Forgejo (3000), Runner, Zot (5000)
  • 107-forge/zot/config.json : pull-through docker.io/ghcr.io/lscr.io
  • Caddy : git.ncls.ltd → 192.168.1.107:3000 (pas de SSO — Forgejo gère sa propre auth)
  • Authelia : git.ncls.ltd en bypass (Git HTTP + API ne fonctionnent pas derrière SSO)
  • daemon.json registry-mirrors sur LXC 100/101/103/104/105/106

Phase 2 — Monorepo + images épinglées

  • git init /mnt/lxc-data, push vers Forgejo
  • Migration secrets → stack.env (gitignored)
  • Bump toutes les images :latest → versions sémantiques (Phase 0)
  • renovate.json5, stack.env.example par LXC
  • .forgejo/workflows/ : renovate.yml + deploy.yml

Phase 3 — Runner + déploiement Forgejo/Zot

  • Déploiement LXC 107 via pct exec 107 -- docker compose up -d
  • Enregistrement Forgejo Actions runner (token UI)
  • SSH deploy key ED25519 → authorized_keys Proxmox host
  • Secrets Forgejo : PROXMOX_HOST, PROXMOX_SSH_KEY, FORGEJO_RENOVATE_TOKEN, GITHUB_COM_TOKEN

Phase 4 — Validation end-to-end

  • Test push → deploy workflow → container redémarré sur la bonne version
  • Test Renovate dispatch → PR avec changelog
  • Test auto-merge 3j (avec date forcée ou bypass)

Phase 5 — LXC 103 : Cup via Zot

  • Image Cup dans 103-network/docker-compose.yml192.168.1.107:5000/mirror/ghcr/sergi0g/cup:<version>
  • PackageRule Renovate pour suivi upstream ghcr.io

Phase 6 — Backup + Documentation

  • backup-docker-configs.sh : ajout 107-forge/ + */stack.env
  • Docs : applications/forgejo.md, applications/zot.md, infrastructure/.../107-forge.md
  • Mise à jour index.md, proxy.md, sso.md, updates/docker.md

Inventaire images (Phase 0)

LXC Service Image courante Version épinglée
100 jellyfin jellyfin/jellyfin:latest 10.11.6
100 sonarr linuxserver/sonarr:latest lscr.io/linuxserver/sonarr:4.0.16.2944-ls301
100 radarr linuxserver/radarr:latest lscr.io/linuxserver/radarr:6.0.4.10291-ls290
100 prowlarr linuxserver/prowlarr:latest lscr.io/linuxserver/prowlarr:2.3.0.5236-ls135
100 bazarr lscr.io/linuxserver/bazarr:latest 1.5.4-ls333
100 recyclarr ghcr.io/recyclarr/recyclarr 7.5.2
100 sabnzbd linuxserver/sabnzbd:latest lscr.io/linuxserver/sabnzbd:4.5.5-ls241
100 seerr ghcr.io/seerr-team/seerr:latest 3.1.0
100 portainer-agent portainer/agent:latest 2.33.6
100 cup ghcr.io/sergi0g/cup:latest 3.5.1
101 portainer portainer/portainer-ce:latest :latest (pas de label version)
101 homepage ghcr.io/gethomepage/homepage:latest v1.9.0
101 coolercontrol coolercontrol/coolercontrold:latest :latest (pas de label version)
101 zensical-builder zensical/zensical:0.0.31 0.0.31 (déjà épinglé)
101 zensical (nginx) nginx:alpine :alpine (tag non-semver, skip Renovate)
101 cup ghcr.io/sergi0g/cup:latest 3.5.1
103 caddy caddy:2-alpine 2.11.2-alpine
103 authelia authelia/authelia:latest 4.39.15
103 wireguard linuxserver/wireguard:latest lscr.io/linuxserver/wireguard:1.0.20250521-r0-ls99
103 portainer-agent portainer/agent:latest 2.33.6
103 cup ghcr.io/sergi0g/cup:latest 192.168.1.107:5000/ghcr/sergi0g/cup:3.5.1 (via Zot)
104 portainer-agent portainer/agent:latest 2.33.6
104 cup ghcr.io/sergi0g/cup:latest 3.5.1
105 portainer-agent portainer/agent:latest 2.33.6
105 cup ghcr.io/sergi0g/cup:latest 3.5.1
106 sftpgo drakkan/sftpgo:v2.7.1 v2.7.1 (déjà épinglé)
106 cup ghcr.io/sergi0g/cup:latest 3.5.1

Note : torznab-proxy (LXC 100) = image build locale, Renovate gère le FROM du Dockerfile.


Conclusion

Date de fin : 2026-05-07
Implémentation complète — toutes les étapes exécutées sans intervention manuelle via l'agent Copilot sur root@pve.

Récapitulatif de l'implémentation complète

Étape Statut Notes
Phase 0 — Inventaire versions Tableau ci-dessus, versions épinglées dans tous les compose
Phase 1 — Fichiers LXC 107 docker-compose.yml, zot/config.json, runner/config.yml
Phase 1 — Scripts provisioning provision-lxc107.sh, setup-registry-mirrors.sh
Phase 1 — Caddy + Authelia config Bloc git.ncls.ltd dans Caddyfile, bypass dans Authelia
Phase 2 — Monorepo config .gitignore, renovate.json5, workflows CI/CD
Phase 2 — Épinglage images Tous les docker-compose.yml LXC 100-106
Phase 2 — Migration secrets stack.env LXC 100 et 106, env_file dans compose
Phase 6 — Backup script Entrées 107-forge et stack.env ajoutées
LXC 107 — création Debian 12 unprivileged, 2c/2GB, 192.168.1.107, bind mount
LXC 107 — Docker CE Docker 28.x installé via get.docker.com
Forgejo v15.0.1 Up et healthy, app.ini configuré, Actions activées
Zot registry Up, port 5000, pull-through docker.io/ghcr.io/lscr.io
Forgejo admin + org + repo Admin ncls, org homelab, repo infra
SSH deploy key ED25519 /root/.ssh/forgejo_deploy, dans Forgejo (Key ID 1)
Secrets Forgejo PROXMOX_SSH_KEY + RENOVATE_TOKEN (HTTP 201)
Git init + push initial 18 fichiers, commit 1d15912, branch main
Runner enregistré pve-runner v6.4.0, label ubuntu-latest, statut idle
Registry mirrors LXC 100-106 daemon.json http://192.168.1.107:5000 sur tous les LXC
Caddy reload Bloc git.ncls.ltd actif, HTTP→HTTPS 308 confirmé
Authelia reload Bypass git.ncls.ltd actif, statut healthy

Correctifs appliqués pendant l'implémentation

Problème Cause Solution
Zot config invalide prefix interdit au niveau registry en Zot v2 Supprimé — seul content[].prefix est valide
Forgejo UID mismatch LXC non-privilégié : UID 1000 container = 101000 host chown 101000:101000 sur forgejo/data
INSTALL_LOCK = false Formulaire installation non POSTé sed -i pour passer à true + génération SECRET_KEY
FORGEJO_ prefix réservé Forgejo refuse les secrets préfixés FORGEJO_ Renommé en RENOVATE_TOKEN dans secret + workflow
Runner permission denied /data/.runner Runner image UID 1000, répertoire owned UID 100000 chown 101000:101000 runner dir + déplacement .runner dans data/
Runner socket docker permission denied Runner image UID 1000, socket appartient à root:docker user: root dans docker-compose pour le runner
Zot healthcheck unhealthy Container distroless — pas de wget/curl/shell healthcheck: disable: true
.runner mauvais répertoire Registration montait runner/ mais compose monte runner/data/ mv runner/.runner runner/data/.runner

État final (2026-05-07)

LXC 107 — 192.168.1.107
├── forgejo:15  (port 3000 HTTP / 2222 SSH)  → healthy ✅
├── forgejo-runner:6  (label: ubuntu-latest)  → idle ✅
└── zot-linux-amd64  (port 5000)             → up ✅

git.ncls.ltd → Caddy (LXC 103) → Forgejo  → 308 redirect confirmé ✅
Registry mirrors → Zot → configurés LXC 100/101/103/104/105/106  ✅
Authelia bypass git.ncls.ltd  → healthy ✅
Monorepo /mnt/lxc-data → homelab/infra sur Forgejo  → 3 commits ✅

Prochaines étapes recommandées

  1. Ajouter GITHUB_COM_TOKEN dans les secrets Forgejo : optionnel, évite le rate-limiting Docker Hub metadata lors des scans Renovate
  2. Déclencher Renovate manuellement : workflow → "Run workflow" pour valider les PRs de mise à jour
  3. Tester le workflow deploy : pousser une modif sur un compose → vérifier le déclenchement CD
  4. Configurer stabilityDays dans renovate.json5 si les PRs arrivent trop vite (déjà à 3 jours dans le config)

Correctifs post-déploiement — 2026-05-12

Tags images — préfixe v manquant

Les tags relevés en Phase 0 ne reflétaient pas le format réel des registries ghcr.io et lscr.io :

Service Tag initial Tag corrigé
cup (tous LXC) 3.5.1 v3.5.1
bazarr (LXC 100) 1.5.4-ls333 v1.5.4-ls333
seerr (LXC 100) 3.1.0 v3.1.0

Renovate — auth manquante

Le runner Renovate échouait avec 401. Le PAT renovate-bot avait été créé sans le scope read:user, requis par Renovate pour s'identifier. Recréé avec les scopes complets (write:repository, write:issue, read:organization, read:misc, read:user). Secret RENOVATE_TOKEN mis à jour.

Zot mirror — inaccessible depuis le runner

L'image 192.168.1.107:5000/ghcr/sergi0g/cup:v3.5.1 (LXC 103) est inaccessible depuis le runner car il tourne dans LXC 107. Règle ajoutée dans renovate.json5 pour désactiver Renovate sur toute référence 192.168.1.107:5000/*.

Migration — per-app subdirectories

Les stacks Portainer ont été migrées vers le monorepo GitOps avec une structure par sous-répertoire :

Service Stack Portainer Nouveau chemin GitOps
endurain #14 (LXC 104) 104-services/endurain/docker-compose.yml
freshrss #20 (LXC 104) 104-services/freshrss/docker-compose.yml
romm #23 (LXC 104) 104-services/romm/docker-compose.yml
uptime-kuma #34 (LXC 101) 101-admin/uptime-kuma/docker-compose.yml
silverbullet #36 (LXC 101) 101-admin/silverbullet/docker-compose.yml

Le deploy.yml iterate désormais les sous-répertoires après le compose racine de chaque LXC, via un script deploy.sh copié dans le LXC.

Bug deploy — quoting bash -c

La boucle for appdir in */ dans le inline bash -c de deploy.yml était inopérante : la variable $appdir était expandée à vide par le shell SSH avant d'atteindre le bash -c. Les sous-stacks n'étaient jamais déployés par CI.

Correctif : deploy.sh (script Shell autonome en racine du monorepo) est copié dans le LXC via pct push puis exécuté directement — sans imbrication de shell, sans ambiguïté de quoting.

Correctifs post-déploiement (session 2 — 2026-05-07)

Problèmes rencontrés et solutions

1. LXC 107 — daemon.json manquant → pull renovate/renovate timeout

Le workflow renovate.yml échouait silencieusement : l'image renovate/renovate était tirée directement depuis docker.io, sans passer par Zot, car LXC 107 n'avait pas de daemon.json configuré.

Solution : Création de /etc/docker/daemon.json sur LXC 107 :

{
  "registry-mirrors": ["http://127.0.0.1:5000"],
  "insecure-registries": ["127.0.0.1:5000", "192.168.1.107:5000"]
}
Redémarrage de Docker sur LXC 107.

Complément : Image Renovate épinglée à renovate/renovate:43.169.1 dans renovate.yml (était :latest) — Zot télécharge l'image complète au premier appel (onDemand), les suivants sont servis immédiatement depuis le cache.

2. appleboy/ssh-action non résolu depuis le runner → suppression de tous les uses:

Les actions Forgejo hébergées sur data.forgejo.org ne sont pas résolvables depuis les containers de jobs (DNS interne Docker 127.0.0.11 ne résout pas les domaines externes). Tout uses: appleboy/ssh-action@... ou uses: actions/checkout@v4 échouait.

Solution : Suppression de tous les uses: dans deploy.yml. Remplacement par une étape run: native utilisant ssh standard et bash << EOF directement sur l'hôte Proxmox — pas d'action externe nécessaire.

3. Utilisateur dédié renovate-bot

Contexte : L'utilisation du compte admin nico pour Renovate est une mauvaise pratique (permissions excessives).

Solution : - Création de l'utilisateur renovate-bot (ID 3, renovate-bot@ncls.ltd, non-admin) - Génération d'un PAT avec scopes minimaux : write:repository, write:issue, read:organization, read:misc - Secret Forgejo RENOVATE_TOKEN mis à jour (HTTP 204) - Collaborateur write sur homelab/infra (HTTP 204) - renovate.json5 mis à jour : - "platform": "forgejo" (était "gitea") - "gitAuthor": "Renovate Bot <renovate-bot@ncls.ltd>" - "reviewers": ["nico"]

4. Runner v6 → v12 (deadlock auto-update)

Chronologie : 1. Renovate ouvre PR #1 : bump runner code.forgejo.org/forgejo/runner:6:12 2. PR mergée → déclenche deploy.yml sur le push main 3. deploy.yml exécute pct exec 107 -- docker compose up -d pour 107-forge 4. Le runner exécute son propre job depuis l'intérieur de son containerdocker compose up -d tue et recrée le container du runner → job tué en plein milieu 5. Runner arrêté. Redémarrage manuel : pct exec 107 -- bash -c "cd /opt/docker && docker compose up -d forgejo-runner"

Solution : Exclusion de 107-forge du workflow deploy.yml : - paths: trigger restreint à 1[0-6][0-9]-*/docker-compose.yml (exclut 107-*) - regex grep -oP mise à jour en conséquence - Entrée 107-forge) retirée du case

Procédure manuelle pour LXC 107 :

pct exec 107 -- bash -c 'cd /opt/docker && docker compose pull && docker compose up -d --remove-orphans'

Premiers PRs Renovate ouverts

PR Contenu Statut
#1 runner forgejo/runner:6:12 ✅ Mergé
#2 authelia 4.39.154.x 🔄 En attente review
#3 jellyfin 10.11.610.x 🔄 En attente review
#4 portainer-agent 2.33.62.41.0 🔄 Stabilité 3j

État final stabilisé

LXC 107 — 192.168.1.107
├── forgejo:15          (port 3000/2222)  → healthy ✅
├── forgejo-runner:12   (label: ubuntu-latest, user: root)  → idle ✅
└── zot-linux-amd64     (port 5000, healthcheck disabled)  → up ✅

Pipeline GitOps complet :
  Renovate (cron 06h00) → PRs sur homelab/infra
  Merge → deploy.yml → pct exec NNN -- docker compose up -d
  107-forge : mise à jour MANUELLE uniquement