Aller au contenu principal
TechProjet

Quitter Vercel pour mon homelab : retour de migration

Pourquoi et comment j'ai migré dubus.pro de Vercel vers une stack self-hosted (Coolify + Hetzner CX23 + Cloudflare Tunnel). Coût, perf, automatisation et les pièges rencontrés.

VercelSelf-HostingCoolifyHetznerCloudflare TunnelNext.jsDevOpsCost Optimization
Quitter Vercel pour mon homelab : retour de migration

Pourquoi quitter Vercel

Vercel est excellent pour démarrer. Edge functions, preview deployments, image optimization, tout marche au premier push. Mais quand on commence à vouloir comprendre ce qui tourne où, comment scale, et combien ça coûte vraiment, on touche les limites de la magie.

Mes raisons concrètes pour migrer :

  1. Pricing flou : edge functions facturées à l'invocation, image optimization à la transformation, bandwidth après le free tier. Pour un portfolio qui prend 5k vues par mois ce n'est rien, mais pour des sites clients avec du traffic réel, on arrive vite à des factures imprédictibles.

  2. Pas d'accès aux logs bruts : les logs Vercel sont dans leur dashboard. On peut les téléverser vers un service tiers (Logtail, Datadog), mais c'est en sus. Moi je voulais ingérer dans ma propre stack Loki.

  3. Build runtime locked : impossible de spawner des sub-processes pendant le build, pas de FFmpeg pour transcoder une vidéo en SSG, pas de Puppeteer pour pré-render. Tout doit passer par les abstractions Vercel.

  4. Cas client futur : je voulais un setup qui me serve aussi pour héberger les sites de mes clients freelance. Sur Vercel, chaque client = un projet payant. En self-hosted, c'est du marginal cost.

  5. Apprentissage pur : comprendre ce qui se passe entre git push et 200 OK côté client. Vercel cache trop pour qu'on apprenne quelque chose en l'utilisant.

Le stack cible

GitHub repo (push main)
    │
    │  webhook
    v
[Coolify on Hetzner CX23] ───► clone + build + spawn container
    │
    │  Traefik internal routes
    v
[App container :3000]
    │
    │  cloudflared egress tunnel
    v
[Cloudflare Edge] ───► TLS termination + DDoS + cache
    │
    v
[Browser]

Stack choisie pour des raisons précises :

  • Hetzner CX23 (4,79€/mois) : cheapest VPS qui tient un Next.js prod sans broncher (2 vCPU x86, 4 GB RAM, 40 GB SSD, 20 TB bandwidth)
  • Coolify v4 : PaaS open source self-hosted, Heroku-like UX. Push to main = build + deploy. Healthchecks, rollbacks, env vars, le confort Vercel sans le vendor lock.
  • Cloudflare Tunnel : exposition zero-trust. Le serveur Hetzner n'a aucun port ouvert vers Internet. Le tunnel sort en outbound vers CF, qui termine le TLS au edge. DDoS protection gratuite, et si je veux mettre du CF Access devant un service admin, c'est 2 clics.
  • GitHub App pour les webhooks signed (HMAC SHA-256), pas du polling.

Coolify tourne sur ma VM locale (Proxmox VM 300). Le node Hetzner est managé "à distance" via SSH par cette VM. Avantage : si Coolify lui-même crash, ma prod reste up sur Hetzner, je redéploie après quand l'UI revient.

La migration en pratique

Étape 1 : Coolify managed local

VM Ubuntu 24.04 cloud-init sur Proxmox. Installation Coolify v4 par le script officiel. Total ~10 minutes. Setup admin via UI sur première visite.

Pour exposer l'UI Coolify localement (sur LAN seulement), config Traefik LXC 103 avec wildcard *.didoulab.com qui pointe vers la VM Coolify :

http:
  routers:
    coolify:
      rule: "Host(`coolify.didoulab.com`)"
      entryPoints:
        - websecure
      service: coolify
      tls:
        certResolver: cloudflare
        domains:
          - main: didoulab.com
            sans:
              - "*.didoulab.com"

  services:
    coolify:
      loadBalancer:
        servers:
          - url: "http://192.168.68.252:8000"

Étape 2 : webhook GitHub → Coolify

Coolify a besoin de recevoir les webhooks GitHub publiquement. Solution : un cloudflared container sur la VM Coolify qui expose uniquement le path coolify.didoulab.com/webhooks/* :

Public Hostnames (CF Zero Trust) :
  coolify.didoulab.com/webhooks/*  →  http://localhost:8000  (Bypass policy)
  coolify.didoulab.com/app/*       →  http://localhost:6001  (CF Access protected)
  coolify.didoulab.com/apps/*      →  http://localhost:6001  (CF Access protected)

Le reste de Coolify (UI, API admin, console) reste invisible depuis Internet. La Bypass policy CF Access laisse passer les webhooks GitHub sans challenge (vérifié par signature HMAC côté Coolify), tout le reste exige Email OTP + 2FA.

Étape 3 : provisioning Hetzner via Coolify

Coolify v4 a une intégration Hetzner Cloud native. Token API Hetzner, choix de la région (Falkenstein), choix du type de serveur (CX23), Coolify provisionne le VPS, installe Docker, configure le SSH key, et le serveur apparaît comme "destination" dans l'UI.

Total : ~3 minutes. Côté Hetzner, j'ai ensuite :

  • Activé le firewall Cloud Hetzner (block all in, allow seulement le SSH depuis l'IP de Coolify VM)
  • Le tunnel cloudflared installé par Coolify lors du premier deploy

Étape 4 : déploiement de l'app

Coolify UI → New Resource → Public Repository → https://github.com/breaching/my-portfolio → branch main, base /frontend, Dockerfile detection auto.

Variables d'env injectées via Coolify UI : NEXT_PUBLIC_FORMSPREE_ID, NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, etc. Le build les inline dans le bundle (NEXT_PUBLIC_* Next.js convention).

Le Dockerfile est multi-stage :

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG NEXT_PUBLIC_FORMSPREE_ID
ENV NEXT_PUBLIC_FORMSPREE_ID=$NEXT_PUBLIC_FORMSPREE_ID
# ... autres ARG/ENV
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
ENV PORT=3000 HOSTNAME="0.0.0.0"
EXPOSE 3000
CMD ["node", "server.js"]

Output Next.js standalone (~85 MB image vs 400 MB sans), démarrage container en 1.5s.

Étape 5 : DNS bascule

Le piège classique : pendant 6 jours j'ai gardé dubus.pro sur Vercel ET déployé en parallèle sur prod-test.dubus.pro côté Hetzner pour valider. Tests Lighthouse, Formspree, navigation, builds reproductibles, healthchecks Coolify.

Le 7 mai, basculement final :

  1. CF Zero Trust → tunnel Hetzner → ajout public hostnames dubus.pro apex et www.dubus.pro (CF flatten le CNAME apex automatiquement)
  2. CF DNS → suppression du record A dubus.pro 216.198.79.1 (Vercel)
  3. Coolify UI → app prod → champ Domains : remplacement de prod-test.dubus.pro par dubus.pro,www.dubus.pro puis Save + Redeploy
  4. Verify HTTP 200 + titre correct via curl

Total downtime : 0. CF Tunnel servait déjà prod-test.dubus.pro, le passage de prod-test vers dubus.pro apex s'est fait en parallèle de Vercel jusqu'à la suppression du A record. Browser users qui visitent dubus.pro pendant la fenêtre de propagation DNS atterrissent encore sur Vercel ou déjà sur Hetzner, dans les deux cas un site fonctionnel.

Coût comparé sur 12 mois

Voilà le détail honnête :

PosteVercel FreeVercel ProSelf-hosted (cette stack)
Hosting computegratuit (limites strictes)20$/mois4,79€/mois (Hetzner CX23)
Bandwidth100 GB inclus1 TB inclus20 TB inclus (Hetzner)
Build minutes6000/mois24000/moisillimité
Image optimization1000/mois freeinclusgéré par Next.js + Sharp localement
Edge functions100k inv/jour1M inv/jouraucune limite (Node.js)
Logs1h retention1 jour retention30 jours (Loki + ISM policy)
Total annuel0€240$/an~57€/an + l'électricité de mon homelab Coolify

Pour un portfolio à faible traffic, Vercel Free est gratuit et suffit. La motivation n'est pas le coût ici, c'est le contrôle. Mais quand on commence à monter à plusieurs sites clients, Vercel Pro à 20$ par projet vs Hetzner CX23 mutualisable à l'infini, ça devient un argument économique.

Performance comparée

Tests Lighthouse mobile depuis la France (CDG) sur 5 runs moyennés :

MetricVercelSelf-hosted
TTFB95 ms110 ms
FCP1.0 s1.1 s
LCP1.8 s2.0 s
CLS00
TBT80 ms90 ms
Score perf mobile9997

Petite régression de ~10% sur le TTFB, attendue : Vercel a leur edge network avec PoPs partout, moi j'ai un seul serveur à Falkenstein. Pour un visiteur français, le serveur Hetzner allemand est très proche. Pour un visiteur USA, c'est plus loin. Mais Cloudflare devant fait du caching agressif (Cache-Everything sur les pages statiques), donc le TTFB réel pour les visites cachées est ~30 ms (CF edge proche du visiteur).

Ce qui a cassé pendant la migration

Pour les futurs migrants, voilà les vrais pièges :

Coolify v4 ne régénère pas le compose au domain change

Le bug le plus stressant. Tu changes la FQDN dans l'UI Coolify, tu cliques Save, tu cliques Redeploy. La page recharge avec la nouvelle valeur. Le redeploy "réussit". Le site renvoie 404.

Investigation : le docker-compose.yaml côté serveur cible n'est pas régénéré. Coolify utilise le compose existant tel quel, donc les Traefik labels conservent l'ancien Host rule.

Fix : clear COMPLETEMENT le champ Domains, save (Coolify enregistre "vide"), re-paste, save, redeploy. Le double-save flag le state comme dirty et force la régen.

DNS-over-HTTPS du browser bypass mon AdGuard local

J'utilise Brave avec DoH activé. Quand je tape coolify.didoulab.com en LAN, Brave query Cloudflare DNS direct (DoH) au lieu de mon AdGuard 192.168.68.246. Cloudflare retourne le CNAME du tunnel public (créé pour les webhooks), je tombe sur CF Access challenge alors que je voulais juste taper sur mon Traefik LAN.

Fix : disable DoH dans Brave, ou utiliser direct IP http://192.168.68.252:8000 pour bypass DNS entièrement.

Cert self-signed dans Coolify-proxy interne

Coolify-proxy (Traefik interne à Coolify) génère ses propres certs self-signed pour communiquer entre containers. Quand on bypass via Traefik externe (LXC 103), il faut serversTransport: insecure avec insecureSkipVerify: true :

serversTransports:
  insecure:
    insecureSkipVerify: true

Sans ça, Traefik externe refuse de relayer vers Coolify (TLS handshake fail).

Healthcheck Coolify bloqué par mon proxy.ts

Mon middleware Next.js blacklist les User-Agents curl|wget (anti-scanner). Le healthcheck Coolify utilise wget en interne. Résultat : healthcheck systématiquement 403, container marqué unhealthy en boucle.

Fix : utiliser curl --user-agent "Mozilla/5.0 (compatible; coolify-healthcheck)" dans le healthcheck Docker, ou whitelister l'IP du container coolify-proxy dans le middleware.

Ce que j'ai appris

  1. Coolify v4 est un excellent compromis entre Heroku-like UX et control complet. Pas autant de magie que Vercel, mais quand quelque chose casse on sait où regarder.

  2. Cloudflare Tunnel est la killer feature pour qui ne veut pas exposer de ports. Zero-trust networking pour les nuls, et la doc CF est très bien faite.

  3. Le pricing self-hosted ne se mesure pas en €/mois mais en h/mois investies à la maintenance. Sur 2 mois de prod : ~3 heures de troubleshooting (les 4 bugs ci-dessus). Pour une équipe avec un budget DevOps zéro, c'est un coût caché à intégrer.

  4. Les preview deployments me manquent. Sur Vercel chaque PR avait une URL unique pour valider en revue. Coolify v4 a ça en preview, j'attends que ça stabilise.

  5. Edge functions deviennent middleware Node.js. Pas grave, c'est juste un autre runtime. Mes routes API Next.js tournent en Node sur le container, pas sur l'edge. Un peu plus de latence pour les routes API distantes, mais pour 99% des cas c'est invisible.

  6. Avoir l'observabilité avant de migrer change tout. J'avais Loki + Grafana + Wazuh sur mon homelab avant la bascule. Le jour de la migration, je voyais les events HTTP en temps réel sur les deux backends, ce qui m'a rassuré sur le moment de switch DNS.


Architecture self-host complète documentée sur le repo didoulab-homelab (privé). Pour la stack sécurité observabilité spécifiquement, voir le post sur le mini-SOC homelab. Pour la vue d'ensemble du homelab Proxmox, voir le post infrastructure 16 services. Pour héberger ton propre site avec un setup similaire, demande un devis.

Besoin d'un site vitrine professionnel ?

Je crée des sites sur mesure pour les entreprises à Caen et partout en France. Devis gratuit, réponse sous 24h.