Mon propre Vercel - GitHub Push → Build → Deploy


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 App    │  ← Webhooks + API access
└────────┬────────┘

┌─────────────────┐
│  Tom Lab API    │  ← Go backend
│  (Projects,     │
│   Builds,       │
│   Deploys)      │
└────────┬────────┘

┌─────────────────┐
│   Build Job     │  ← Kaniko (in-cluster)
│   (K8s Job)     │
└────────┬────────┘

┌─────────────────┐
│ Container       │  ← Harbor/Registry
│ Registry        │
└────────┬────────┘

┌─────────────────┐
│  K8s Deploy     │  ← Deployment + Service
│  + NPM Proxy    │  ← HTTPS via Nginx Proxy Manager
└─────────────────┘

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…