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 :
- Dockerfile : Pour les projets qui ont déjà un Dockerfile
- 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 :
- Namespace K8s dédié au projet
- Deployment avec l’image, les env vars, les resources, les volumes
- Service (ClusterIP) pour l’accès interne
- 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 :
- Créer un projet dans la console
- Lier au repo GitHub
- Push du code
- 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…