J'en avais marre de déployer mes apps à la main. Chaque fois qu'on push sur GitHub, il fallait SSH dans le serveur, pull le code, rebuild l'image, redéployer... C'était devenu insupportable.

Alors j'ai décidé de construire mon propre système de déploiement automatique. Un peu comme Vercel ou Railway, mais dans mon homelab. Push to deploy, tout simplement.

L'objectif

Le flow que je voulais :

git push origin main
    ↓
Webhook GitHub → Tom Lab Console
    ↓
Clone repo → Build image → Push registry
    ↓
Deploy K8s → HTTPS automatique
    ↓
App live en production 🚀

Et surtout, je voulais deux modes de build :

  1. Dockerfile : Pour les projets qui ont déjà un Dockerfile
  2. Auto-detect : Comme Nixpacks, détecter le langage et générer le Dockerfile automatiquement

L'architecture

Voici les composants principaux :

GitHub AppWEBHOOKS + API ACCESSTom Lab APIPROJECTS · BUILDS · DEPLOYSBuild JobKANIKO (IN-CLUSTER)Container RegistryHARBOR / 192.168.1.100:30500K8s DeployDEPLOYMENT + SERVICE + NPM PROXY

La GitHub App

Premier gros morceau : créer une GitHub App. C'est mieux qu'un Personal Access Token parce que :

  • Permissions granulaires par repo/installation
  • Tokens éphémères (plus sécurisé)
  • Webhooks intégrés
  • Pas de rate limiting utilisateur

Configuration

Dans GitHub Settings → Developer Settings → GitHub Apps, créer une app avec :

Permissions :

  • Contents: Read (pour cloner)
  • Metadata: Read
  • Webhooks: Pour recevoir les push events

Webhook URL : https://console.votredomaine.fr/api/v1/webhooks/github

Webhook Secret : Un secret fort pour valider les signatures

Authentification JWT

L'authentification GitHub App, c'est un peu complexe. On génère un JWT signé avec la clé privée de l'app, puis on l'échange contre un token d'installation :

func (s *GitHubService) generateJWT() (string, error) {
    now := time.Now()
    claims := jwt.MapClaims{
        "iat": now.Add(-60 * time.Second).Unix(), // 1 minute dans le passé
        "exp": now.Add(10 * time.Minute).Unix(),  // Expire dans 10 min
        "iss": s.appID,
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    return token.SignedString(s.privateKey)
}

func (s *GitHubService) GetInstallationToken(ctx context.Context, installationID int64) (string, error) {
    jwt, _ := s.generateJWT()

    req, _ := http.NewRequest("POST",
        fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID),
        nil)
    req.Header.Set("Authorization", "Bearer "+jwt)
    req.Header.Set("Accept", "application/vnd.github+json")

    // ... response handling
    return tokenResponse.Token, nil
}

Le token obtenu est valide 1 heure et permet d'accéder aux repos de cette installation.

Validation des Webhooks

Crucial pour la sécurité : valider que le webhook vient bien de GitHub :

func (s *GitHubService) ValidateWebhook(payload []byte, signature string) bool {
    mac := hmac.New(sha256.New, []byte(s.webhookSecret))
    mac.Write(payload)
    expectedSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expectedSig), []byte(signature))
}

Le système de Projects

Un Project dans ma console, c'est la configuration complète d'une app :

type Project struct {
    ID              string
    RepoFullName    string            // "tomarcourt/mon-app"
    BuildMethod     string            // "docker" ou "nixpacks"
    DockerfilePath  string            // "Dockerfile" par défaut
    AutoDeploy      bool              // Push → Deploy automatique
    DeployBranches  []string          // ["main", "master"]
    Port            int               // Port de l'app (ex: 3000)
    ExposeHTTPS     bool              // Créer un proxy HTTPS
    Subdomain       string            // "mon-app" → mon-app.sortium.fr
    EnvVars         map[string]string // Variables d'environnement
    Replicas        int32             // Nombre de pods
}

L'interface permet de configurer tout ça :

  • Sélection du repo GitHub
  • Choix de la méthode de build
  • Configuration réseau (port, HTTPS)
  • Variables d'environnement
  • Ressources (CPU/RAM)
  • Volumes persistants

Le Build System

Deux méthodes de build

1. Docker (Kaniko)

Pour les projets avec un Dockerfile, on utilise Kaniko. C'est un builder Docker qui tourne dans Kubernetes sans avoir besoin de Docker-in-Docker ni de privilèges root.

Le Job K8s créé :

containers:
- name: clone
  image: alpine/git
  command: ["git", "clone", "--depth=1", "--branch", "main", "https://..."]

- name: kaniko
  image: gcr.io/kaniko-project/executor:latest
  args:
    - --dockerfile=/workspace/Dockerfile
    - --context=/workspace
    - --destination=192.168.1.100:30500/mon-app:abc1234
    - --insecure  # Registry self-signed

2. Auto-detect (Nixpacks-like)

Pour les projets sans Dockerfile, j'ai implémenté un système de détection automatique inspiré de Nixpacks. Un script shell analyse le projet et génère un Dockerfile adapté :

# Détection du type de projet
if [ -f "package.json" ]; then
    if grep -q '"next"' package.json; then
        echo "Detected: Next.js"
        # Génère Dockerfile multi-stage optimisé pour Next.js
    elif grep -q '"vite"' package.json || grep -q '"react"' package.json; then
        echo "Detected: React/Vite"
        # Génère Dockerfile avec nginx pour les SPA
    fi
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
    if grep -q "fastapi" requirements.txt; then
        echo "Detected: FastAPI"
    elif grep -q "flask" requirements.txt; then
        echo "Detected: Flask"
    fi
elif [ -f "go.mod" ]; then
    echo "Detected: Go"
fi

Langages supportés :

  • Node.js (Next.js, React/Vite, Express)
  • Python (FastAPI, Flask, Django)
  • Go
  • Ruby (Rails)
  • PHP (Laravel, Symfony)
  • Java (Maven, Gradle)
  • .NET
  • Static HTML (nginx)

File d'attente des builds

Un problème classique : que se passe-t-il si on push 3 fois rapidement ?

On ne veut pas 3 builds en parallèle qui se marchent dessus. J'ai donc implémenté une queue par projet :

func (s *BuildService) TriggerBuild(ctx context.Context, config *BuildConfig) (*Build, error) {
    // Vérifier si un build tourne déjà
    runningBuild, _ := s.store.GetRunningBuildForProject(ctx, config.ProjectID)

    // Créer le build record (toujours en status "pending")
    build, _ := s.store.Create(ctx, config)

    if runningBuild != nil {
        // Un build tourne → on reste en pending
        log.Printf("Build %s queued (build %s is running)", build.ID, runningBuild.ID)
        return build, nil
    }

    // Pas de build en cours → on démarre
    return s.startBuild(ctx, build, config)
}

Quand un build termine (succès ou échec), on démarre automatiquement le prochain pending :

func (s *BuildService) watchBuild(ctx context.Context, build *Build) {
    // ... attendre la fin du Job K8s ...

    // Mettre à jour le status
    s.store.UpdateStatus(ctx, build.ID, status, errorMsg)

    // Démarrer le prochain build en attente
    go s.startNextPendingBuild(build.ProjectID)
}

Logs en temps réel

La console affiche les logs de build en temps réel. Le frontend poll toutes les 3 secondes :

useEffect(() => {
  if (build.status !== 'running' && build.status !== 'pending') return

  const interval = setInterval(async () => {
    const response = await axios.get(`/api/v1/builds/${build.id}/logs`)
    setLogs(response.data.logs)
  }, 3000)

  return () => clearInterval(interval)
}, [build.status])

Côté backend, on agrège les logs de tous les containers :

func (s *BuildService) GetBuildLogs(ctx context.Context, buildID string) (string, error) {
    var allLogs strings.Builder

    // Logs du clone
    cloneLogs, _ := s.kaniko.GetJobLogs(ctx, build.JobName, "clone")
    allLogs.WriteString("=== Clone ===\n" + cloneLogs)

    // Logs de la détection (si nixpacks)
    if build.BuildMethod == BuildMethodNixpacks {
        detectLogs, _ := s.nixpacks.GetJobLogs(ctx, build.JobName, "detect")
        allLogs.WriteString("\n=== Detection ===\n" + detectLogs)
    }

    // Logs du build Kaniko
    kanikoLogs, _ := s.kaniko.GetJobLogs(ctx, build.JobName, "kaniko")
    allLogs.WriteString("\n=== Build ===\n" + kanikoLogs)

    return allLogs.String(), nil
}

Le Déploiement Automatique

Quand le build réussit et que AutoDeploy est activé, on déclenche automatiquement le déploiement :

func (s *BuildService) triggerAutoDeploy(build *Build) {
    // Récupérer la config de déploiement (stockée dans Redis au moment du trigger)
    configJSON, _ := s.redis.Get(ctx, fmt.Sprintf("build:deployconfig:%s", build.ID)).Bytes()

    var deployConfig DeployAfterBuildConfig
    json.Unmarshal(configJSON, &deployConfig)

    // Utiliser l'image buildée
    deployConfig.ImageTag = fmt.Sprintf("%s/%s:%s", build.Registry, build.ProjectName, build.ImageTag)

    // Déclencher le déploiement
    s.deployTrigger.TriggerDeployAfterBuild(ctx, build.ID, &deployConfig)
}

Le déploiement crée :

  1. Namespace K8s dédié au projet
  2. Deployment avec l'image, les env vars, les resources, les volumes
  3. Service (ClusterIP) pour l'accès interne
  4. NPM Proxy (si HTTPS activé) pour l'accès externe
func (s *DeploymentService) Deploy(ctx context.Context, config *DeployConfig) (*Deployment, error) {
    // 1. Créer le namespace
    s.ensureNamespace(ctx, config.Namespace)

    // 2. Créer/update le Deployment K8s
    deployment := s.buildDeploymentSpec(config)
    s.clientset.AppsV1().Deployments(config.Namespace).Create(ctx, deployment, ...)

    // 3. Créer le Service
    service := s.buildServiceSpec(config)
    s.clientset.CoreV1().Services(config.Namespace).Create(ctx, service, ...)

    // 4. Créer le proxy HTTPS si demandé
    if config.ExposeHTTPS {
        s.npmClient.CreateProxyHost(config.Subdomain+".sortium.fr", serviceIP, config.Port)
    }

    // 5. Attendre que les pods soient ready
    s.waitForReady(ctx, config.Namespace, config.DeploymentName)

    return deployment, nil
}

L'interface utilisateur

Liste des projets

La page Projects affiche tous les projets configurés avec :

  • Nom du repo et owner
  • Branche par défaut
  • Status auto-deploy (badge vert/gris)
  • Boutons Deploy et Delete

Détail d'un projet

La page de détail a trois onglets :

Builds :

  • Historique des builds avec status (success/failed/running/pending)
  • Commit SHA, message, auteur
  • Durée du build
  • Bouton "Voir logs" avec affichage expandable
  • Bouton "Annuler" pour les builds running/pending

Deployments :

  • Liste des déploiements actifs
  • Status des replicas (2/2 ready)
  • URL externe (HTTPS) et interne (ClusterIP)
  • Bouton supprimer

Settings :

  • Configuration du build (Dockerfile path, context)
  • Configuration réseau (port, HTTPS, subdomain)
  • Auto-deploy toggle + branches
  • Éditeur de variables d'environnement
  • Resources (CPU/RAM requests/limits)
  • Volumes persistants
  • Nombre de replicas

Le flow complet

Récapitulons le flow complet d'un push à un déploiement :

1. Developer: git push origin main

2. GitHub envoie webhook POST /api/v1/webhooks/github
   - Header: X-Hub-Signature-256 (signature HMAC)
   - Body: { ref: "refs/heads/main", repository: {...}, commits: [...] }

3. Webhook Handler:
   - Valide la signature
   - Parse le payload
   - Trouve le Project par repo_full_name
   - Vérifie auto_deploy == true && "main" in deploy_branches

4. Build Service:
   - Crée Build record (status: pending)
   - Vérifie pas de build running
   - Crée K8s Job dans namespace tom-lab-builds

5. K8s Job:
   - Container 1 (clone): git clone --depth=1 --branch main https://x-token@github.com/...
   - Container 2 (detect, si nixpacks): Analyse projet, génère Dockerfile
   - Container 3 (kaniko): Build image, push vers registry

6. Build Service:
   - Poll le Job jusqu'à completion
   - Récupère les logs
   - Met à jour Build status: success
   - Si AutoDeploy → appelle DeploymentService

7. Deployment Service:
   - Crée Namespace K8s
   - Crée Deployment avec image buildée
   - Crée Service ClusterIP
   - Crée Proxy NPM (si HTTPS)

8. Result:
   - App live sur https://mon-app.sortium.fr
   - Temps total: ~2-5 minutes selon la taille du projet

Ce que j'ai appris

1. Kaniko est génial pour builder dans K8s sans Docker daemon. Pas besoin de privilèges, pas de security risk.

2. La détection automatique c'est dur. J'ai passé beaucoup de temps à peaufiner les Dockerfiles générés pour chaque framework. Next.js avec son output standalone, React/Vite avec nginx SPA routing, Python avec les dépendances natives...

3. Les queues de build sont essentielles. Sans ça, les push rapides créent un chaos de builds concurrents.

4. Les logs temps réel améliorent énormément l'UX. Voir le build progresser, c'est rassurant.

5. GitHub App > Personal Access Token. Plus sécurisé, plus de contrôle, meilleure expérience.


Avec ce système, je peux maintenant déployer n'importe quelle app en quelques minutes :

  1. Créer un projet dans la console
  2. Lier au repo GitHub
  3. Push du code
  4. L'app est live

C'est mon mini Vercel personnel, et franchement, ça change la vie pour le développement sur mon homelab.

Le prochain défi ? Ajouter les preview deployments pour les Pull Requests. Chaque PR aurait son propre environnement éphémère. Mais ça, c'est pour un autre article...


Update avril 2026 — Gitea aussi, et pour de vrai cette fois

Quelques mois après avoir écrit cet article, j'ai fait un truc logique : virer GitHub de l'équation pour mes projets perso. Pas par idéologie, par cohérence — si j'ai un homelab, autant y héberger mon code aussi. J'ai donc monté un Gitea sur le cluster (gitea.sortium.fr) et étendu le système de build/deploy pour le supporter.

Ce qui a changé :

  • Nouveau webhook POST /api/v1/gitea/webhook à côté de celui de GitHub. Gitea envoie un format d'event légèrement différent (ref, repository, commits), donc un parser dédié. Le reste du pipeline — Build Service, Kaniko Job, Deploy Service — est identique.
  • Authentification par token applicatif plutôt que GitHub App. Gitea n'a pas le concept d'App avec JWT + installation token. C'est plus simple : un token d'accès avec les scopes read:repository sur les repos, stocké en secret K8s, utilisé pour le git clone dans le container Kaniko.
  • Validation du webhook par signature HMAC-SHA256, exactement comme GitHub. Le secret est configuré côté Gitea dans les settings du webhook, et côté backend dans la config du projet.
  • Pas de preview deployments sur les PR Gitea pour l'instant — le modèle de branches/PR est identique à GitHub, mais j'attends de stabiliser les previews avant de dupliquer.

Le code du webhook Gitea est quasi copier-coller du handler GitHub :

func (h *GiteaHandler) HandleWebhook(c *gin.Context) {
    signature := c.GetHeader("X-Gitea-Signature")
    payload, _ := io.ReadAll(c.Request.Body)

    if !h.validateSignature(payload, signature) {
        c.JSON(401, gin.H{"error": "invalid signature"})
        return
    }

    var event GiteaPushEvent
    json.Unmarshal(payload, &event)

    project, _ := h.projects.FindByRepo(event.Repository.FullName, "gitea")
    if project == nil || !project.AutoDeploy {
        c.JSON(200, gin.H{"status": "ignored"})
        return
    }

    // Même flow qu'avec GitHub : créer un Build, déclencher Kaniko, auto-deploy
    h.builds.TriggerBuild(c.Request.Context(), &BuildConfig{
        ProjectID: project.ID,
        Ref:       event.Ref,
        CommitSHA: event.After,
        Provider:  "gitea",
    })
}

Le Project a maintenant un champ Provider string qui vaut github ou gitea. Le BuildService passe le bon provider au container clone pour qu'il utilise le bon token et la bonne URL de repo.

Résultat concret : le blog que tu es en train de lire vit sur ce système. Ce site est dans gitea.sortium.fr/tomlab/mon-blog, chaque git push déclenche un webhook, le build Kaniko tourne, l'image se push dans le registry interne, le Deployment se met à jour, et la nouvelle version est live en ~2 minutes. Sans moi. Sans intermédiaire. Sans Vercel.

C'est le genre de moment où on se dit que ça vaut la peine d'avoir passé trois weekends dessus.