Leaving Vercel for Dokploy
8 minutes read
Le jour où un train de merges a bloqué ma prod pour 24 h, et comment douze projets Vercel sont devenus un VPS, deux Dockerfiles et un fichier JSON.
Le 2 octobre 2026, j’ai fusionné treize pull requests dans la journée. Rien d’extraordinaire : du nettoyage, des petites apps, un train de merges comme on en fait quand on range. Le lendemain, plus rien ne se déployait. Pas une app en erreur, toutes : Vercel avait coupé le compte pour vingt-quatre heures.
Cet article raconte comment je suis passé de douze projets Vercel à un seul VPS piloté par Dokploy, en quelques jours, pour un monorepo d’une vingtaine de petites apps SvelteKit. Ce n’est pas un tutoriel, c’est un retour d’expérience : ce que j’ai choisi, ce qui a cassé dans les quarante-huit heures qui ont suivi, et ce que j’aurais fait autrement.
Le quota qu’on ne voit pas venir
Mon monorepo rama-apps héberge mes expérimentations : un portfolio, une galerie photo, des démos WebGL, un deck d’entretien, le site d’un collectif citoyen… Chaque app avait son projet Vercel, douze en tout, sur un compte Hobby. Le plan Hobby autorise cent déploiements par jour pour tout le compte, pas par projet. Jusque-là ça passait, parce que je ne poussais pas souvent.
Le 2 octobre : vingt-quatre pushes et treize merges. Cinq projets Vercel étaient alors reliés au dépôt et déployaient chaque push de chaque branche. À peu près cent quatre-vingt-cinq déploiements en ont résulté (le chiffre est approximatif, je ne l’ai pas recompté). Vercel a limité le compte et bloqué les déploiements de production pour vingt-quatre heures. Un push de correctif ne servait plus à rien : il attendait derrière le quota, lui aussi.
Première réaction, le lendemain : rendre les previews opt-in. Chaque vercel.json a reçu un git.deploymentEnabled qui ne déploie que main et les branches nommées *-preview. Ça règle le symptôme. Mais le vrai problème restait : la plateforme décidait de ce que je pouvais livrer, et le compteur était partagé entre des projets qui n’ont rien à voir.
Ce que je voulais à la place
Trois contraintes. Un seul endroit où déployer, un seul compteur, ou plutôt pas de compteur du tout. Garder le flux « merge sur main = en prod », parce que c’est ce qui me fait avancer. Et ne pas réécrire les apps : elles sont prérendues avec adapter-static, deux d’entre elles, à l’époque, tournaient en Node avec adapter-node, et je n’avais aucune envie d’y toucher.
Le choix s’est porté sur Dokploy, un panneau open source qui pilote Docker Swarm et Traefik, installé sur un VPS chez OVH à Strasbourg. Traefik termine le TLS avec des certificats Let’s Encrypt, un service Docker par app, et le DNS pointe sur le VPS. La pull request de migration a été ouverte un lundi soir, un peu avant 22 h, et fusionnée vingt-six minutes plus tard : le gros du travail s’était joué avant, sur le serveur et dans les Dockerfiles.
Deux Dockerfiles pour vingt apps
La décision qui a tout simplifié : ne pas écrire un Dockerfile par app. Les apps statiques se ressemblent toutes (un build SvelteKit, un dossier build/ à servir) et les apps Node aussi. Deux fichiers partagés, dans docker/, avec un seul argument de build, le nom de l’app.
Le filtre pnpm "app..." n’installe que l’app et ses dépendances de workspace. À l’arrivée, une image statique pèse entre 24 et 30 Mo : Caddy et des fichiers HTML. Caddy est configuré pour reproduire ce que Vercel faisait sans qu’on y pense : cache immuable sur /_app/immutable/*, revalidation des pages HTML, redirection 308 des slashs finaux, une page 404 qui est une vraie 404. Le TLS est désactivé dans Caddy (auto_https off) puisque Traefik s’en charge devant.
Les apps Node suivent le même principe avec adapter-node : un petit server.mjs commun, un utilisateur non-root, un arrêt propre sur SIGTERM pour que Swarm puisse remplacer un conteneur sans couper une requête en cours.
Un manifeste plutôt que des clics
Dokploy a une interface où tout peut se faire à la main. Je n’ai pas voulu : avec vingt services, une semaine plus tard, personne ne sait plus pourquoi tel service surveille tel chemin et pas tel autre. Tout est donc décrit dans un fichier docker/apps.json, versionné avec le code.
Un script lit ce manifeste et parle à l’API de Dokploy : il crée ou met à jour chaque service, ses domaines, ses variables, ses chemins surveillés. Trois règles que je ne regrette pas : il tourne en dry-run par défaut et affiche ce qu’il ferait ; il ne supprime jamais rien ; il n’affiche jamais un secret, les secrets vivant dans un fichier hors du dépôt. Résultat : ajouter une app, c’est une entrée dans le JSON et une pull request, pas une session de clics.
Les « chemins surveillés » méritent un mot. Dokploy redéploie un service quand un commit touche l’un de ses chemins. Pour un monorepo, c’est ce qui évite de reconstruire vingt images à chaque merge : apps/vortex/** ne redéploie que vortex. Quelques chemins sont partagés par tous, docker/** en tête : toucher au Dockerfile commun redéploie tout, et c’est voulu.
Ce qui a cassé dans les quarante-huit heures
La bascule elle-même a tenu en une soirée. Les deux jours qui ont suivi ont été une suite de petites pull requests, chacune pour un truc que je n’avais pas vu.
- Le
.dockerignoreexcluait un dossier d’app entier. Le premierCOPYéchouait, l’image ne se construisait pas. Deux lignes. - Le port 22. Une de mes apps est un portfolio accessible en SSH. Sur le VPS, le port 22 est celui du serveur lui-même :
ssh terminal.rama.apptombait sur le vrai sshd et répondaitPermission denied (publickey). L’app a déménagé sur le 2222. - Les 404 qui répondaient 200. Une app en mode SPA servait sa page d’accueil pour n’importe quelle URL, avec un code 200. Vercel masquait ça. Les routes sont maintenant prérendues et l’inconnu renvoie une vraie 404.
- Le DNS, domaine par domaine. Plutôt que de tout basculer d’un coup, chaque service a d’abord reçu un domaine temporaire en
sslip.io, puis son vrai domaine une fois le DNS propagé, avec un drapeaulivedans le manifeste. Ennuyeux, mais zéro coupure. - Le disque plein. Le plus instructif. Au bout de quelques jours, Docker occupait 43 Go, dont 28 Go de cache de build. La cause : mon premier Dockerfile faisait
COPY . .avantpnpm install. Chaque commit invalidait donc la couche des dépendances, et chaque build stockait unnode_modulescomplet de plus. L’ordre que vous voyez plus haut, manifestes d’abord, sources ensuite, est la correction. Les images sont passées à 24–114 Mo selon l’app, et Dokploy sait planifier un nettoyage Docker quotidien pour ce qui reste. - Le lockfile. J’avais mis
pnpm-lock.yamldans les chemins surveillés de tous les services. Ajouter une dépendance à une app redéployait les quatorze autres. Retiré ; en contrepartie, une mise à jour qui ne touche que le lockfile ne déploie rien, et il faut le savoir. - Les chemins relatifs. Une page SvelteKit montée sous un préfixe d’un autre domaine (
stats.rama.app/traffic) restait blanche sans slash final : ses./_appse résolvaient sur le mauvais site.paths.relative = false. - La page de statut qui se sondait elle-même. J’ai ajouté une page qui vérifie chaque site toutes les minutes. Elle vérifiait aussi son propre domaine, en se bloquant elle-même. Elle sonde maintenant sa propre route de santé.
- Une pull request perdue. Une branche empilée sur une autre a été fusionnée dans sa base après que cette base ait été squashée dans
main: son contenu n’a jamais atteint la prod. Il a fallu la rejouer. Leçon de git plus que de Dokploy.
Ce qu’on perd, ce qu’on gagne
Perdu : les previews par branche. Je les utilisais peu, mais « peu » n’est pas « jamais », et je n’ai pas de remplaçant. Perdu aussi : la parallélisation des builds. Le VPS construit les images une par une ; après plusieurs merges d’affilée, le dernier attend cinq à dix minutes. Pour un projet perso c’est acceptable ; pour une équipe, ce serait le premier point à régler.
Gagné : plus de compteur. Un train de treize merges construit treize images, dans l’ordre, et c’est tout. Gagné : un seul endroit où regarder, et un manifeste qui dit ce qui est déployé. Gagné, par ricochet : @vercel/analytics a laissé la place à une instance Umami auto-hébergée, et la page de statut n’a besoin d’aucune clé pour fonctionner, elle fait des requêtes GET.
Le coût ? Je n’ai pas fait le calcul sérieusement : un petit VPS d’un côté, un compte Hobby gratuit de l’autre, donc sur le papier je paie plus qu’avant. Ce que j’ai acheté, c’est de ne plus dépendre d’un quota que je ne vois pas.
Ce que je referais autrement
Mettre l’ordre install avant COPY dès le premier jour : c’est la seule erreur qui aurait pu m’immobiliser, et elle est connue. Écrire le manifeste avant de toucher à l’interface. Basculer le DNS domaine par domaine, comme je l’ai fait, mais avec le drapeau live dès le départ plutôt qu’après coup. Et prévoir dès le départ une redirection depuis les anciennes adresses *.vercel.app : le site du collectif a été partagé sous la sienne, et ces liens-là ne meurent pas le jour de la migration.
Si vous avez un monorepo de petites apps et un plan Hobby, vous n’avez probablement pas besoin de tout ça aujourd’hui. Le jour où un train de merges vous coupe la prod pour vingt-quatre heures, vous saurez que l’option existe, et qu’elle tient en deux Dockerfiles et un fichier JSON.