← Blogue

Déployer Nuxt sur Hetzner

Railyard

12 septembre 2026 · 10 min de lecture

Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte

Vous avez une application Nuxt et un compte Hetzner, et vous voulez que l’application réponde sur votre propre domaine en HTTPS, sans confier le tout à une plateforme qui facture à la requête. Voici la version entièrement manuelle : un vrai serveur, un Dockerfile qui produit une petite image de production, et Caddy devant pour les certificats. À la fin, vous aurez le serveur Nitro de Nuxt qui tourne dans un conteneur, servi en HTTPS, avec une façon répétable de reconstruire et redémarrer.

Ce qu’il vous faut

  • Un compte Hetzner Cloud et un jeton API.
  • Un serveur : un CX23 (2 vCPU, 4 Go de RAM, 40 Go NVMe, 5,99 €/mois hors TVA) fait tourner une application Nuxt petite à moyenne sans problème en exécution ; si le build lui-même manque de mémoire sur une grosse application, redimensionnez temporairement vers un CX33 (4 vCPU, 8 Go de RAM, 8,99 €/mois) pour le build, puis redescendez ensuite, ou construisez l’image sur une autre machine et faites juste tourner le résultat sur le CX23.
  • Un domaine ou sous-domaine avec un enregistrement A que vous pouvez pointer vers le serveur.
  • Une application Nuxt avec un script build dans package.json, sur une version Nuxt actuelle (4.6.2 au moment d’écrire ces lignes), qui demande au minimum Node 20.19+ ou 22.12+.
  • Un accès SSH au serveur.

Étape 1 : créer le serveur

Depuis la console Hetzner Cloud, créez un serveur de type cx23 (ou votre choix redimensionné) sur Ubuntu 24.04, ajoutez votre clé SSH à la création, et notez l’IP publique une fois qu’il tourne. Installez Docker et le plugin Compose par SSH :

curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Étape 2 : pointer le DNS vers le serveur

Créez un enregistrement A pour votre domaine ou sous-domaine pointant vers l’IP du serveur, par exemple app.votredomaine.com. Laissez quelques minutes pour la propagation, puis confirmez que ça résout correctement avant l’étape 6, puisque Caddy en a besoin pour demander un certificat :

dig +short app.votredomaine.com

Étape 3 : la sortie de build de Nuxt

Exécuter nuxt build (ou nuxi build) produit un répertoire .output avec deux parties : .output/public pour les ressources statiques, et .output/server/index.mjs comme point d’entrée Node. Ce point d’entrée est le préréglage « node-server » de Nitro, qui est le préréglage par défaut de Nuxt tant que vous n’avez pas configuré une autre cible de déploiement, donc il n’y a pas d’équivalent de l’activation explicite output: "standalone" de Next.js ici, un simple nuxt build vous donne déjà un serveur Node autonome à exécuter.

Cela dit, « par défaut » veut dire que ça peut changer si les valeurs par défaut de Nuxt changent un jour. Pour un déploiement en production, ça vaut la peine de le fixer explicitement dans nuxt.config.ts pour qu’une future mise à jour de Nuxt ne change pas silencieusement votre cible de déploiement :

export default defineNuxtConfig({
  nitro: {
    preset: "node-server",
  },
})

Exécuter node .output/server/index.mjs démarre directement le serveur de production, exactement ce que fait le Dockerfile ci-dessous.

Étape 4 : écrire le Dockerfile

ARG NODE_VERSION=24-alpine

FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci; \
    elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \
    elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
    else echo "No lockfile found" && exit 1; fi

FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

C’est plus simple que l’équivalent pour Next.js, où la sortie standalone a besoin de son node_modules tracé plus les dossiers public/ et .next/static copiés séparément. Le .output/server de Nitro regroupe déjà son propre node_modules minimal à l’intérieur de lui-même dans le cadre du build, donc l’étape runner n’a qu’à copier le seul répertoire .output, rien d’autre de l’étape builder n’est nécessaire à l’exécution. node:24-alpine est une image Node.js LTS actuelle, bien au-dessus des minimums 20.19/22.12 que Nuxt 4 documente aujourd’hui, et la base Alpine plus légère garde l’image finale mince puisqu’il n’y a aucune étape de build natif dans cette étape. USER node fait tourner le processus sous l’utilisateur non root node déjà présent dans les images Node officielles plutôt que sous root.

Étape 5 : écrire le fichier Compose et le Caddyfile

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
      HOST: "0.0.0.0"
      PORT: "3000"
    expose:
      - "3000"

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - web

volumes:
  caddy-data:
  caddy-config:
app.votredomaine.com {
  reverse_proxy web:3000
}

HOST et PORT (ou leurs équivalents NITRO_HOST / NITRO_PORT) sont ce que le node-server de Nitro lit dans l’environnement pour décider où se lier. Fixer HOST=0.0.0.0 explicitement, plutôt que de se fier à ce que le défaut propre de Nitro se trouve être, c’est ce qui rend le serveur accessible depuis le conteneur caddy juste à côté, le même raisonnement que le HOSTNAME=0.0.0.0 du guide Next.js, juste sous les noms de variables propres à Nitro. Toute valeur lue à l’exécution avec useRuntimeConfig() va dans ce bloc environment: ; seules les variables préfixées NUXT_PUBLIC_ sont intégrées dans le bundle client pendant le build, tout le reste reste côté serveur uniquement et peut changer sans reconstruire l’image.

Avant de déployer, validez les deux fichiers localement. Pour le fichier Compose :

docker compose -f compose.yml config

Et pour le Caddyfile :

docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Les deux devraient signaler un succès avant que vous les copiiez sur le serveur.

Étape 6 : déployer

docker compose up -d --build

La première exécution construit l’image, ce qui est la partie la plus lente, puis démarre les deux conteneurs et Caddy demande son certificat. Suivez les journaux pour confirmer que le build et l’émission du certificat ont tous deux réussi :

docker compose logs -f

Vous devriez voir Nitro inscrire une ligne du genre Listening on http://0.0.0.0:3000, et le journal de Caddy inclure certificate obtained successfully pour votre domaine. Visitez https://app.votredomaine.com et confirmez que l’application se charge en HTTPS avec un certificat valide, ou vérifiez directement depuis le serveur :

curl -I https://app.votredomaine.com

Pour un changement de code ultérieur, la même commande reconstruit seulement l’image qui a changé et recrée ce seul conteneur :

docker compose up -d --build web

Ce n’est pas vraiment sans interruption, Compose arrête l’ancien conteneur web avant de démarrer le nouveau, donc il y a un écart mesuré par le temps que Nitro prend pour démarrer et répondre à sa première requête. Si cet écart compte pour votre trafic, la solution standard est de faire tourner deux conteneurs d’application derrière l’équilibrage de charge propre de Caddy et de les remplacer un à la fois, ce qui est plus d’infrastructure que ce guide ne couvre pour une configuration à serveur unique.

Le raccourci : faites-le avec Railyard

Nuxt n’a pas sa propre entrée de framework dédiée dans le système de guides de Railyard, seul le « Node.js » générique l’a, parce que la détection sous-jacente n’a pas besoin de savoir qu’il s’agit spécifiquement de Nuxt. Elle lit package.json, choisit votre gestionnaire de paquets d’après le fichier de verrouillage présent (npm, pnpm, yarn ou bun), et exécute votre script start comme processus web une fois le build terminé, tout ça sans que vous écriviez de Dockerfile, le même chemin qu’emprunte un dépôt Nuxt que n’importe quel autre dépôt Node.js. Si le dépôt fournit son propre Dockerfile, comme celui de l’étape 4, celui-ci est utilisé directement à la place du chemin automatique. Dans les deux cas, l’application doit écouter sur le $PORT que Railyard assigne, la même exigence que la gestion explicite de HOST/PORT de l’étape 4 satisfait à la main.

Déployez directement : /fr/deployer/nodejs-sur-hetzner. Voir /fr/docs/node pour la référence du framework Node.js, qui couvre comment la détection du build et du démarrage fonctionne pour n’importe quel dépôt basé sur Node, Nuxt inclus.

Le plan gratuit de Railyard couvre 1 serveur plus un serveur de base de données et 3 applications sans carte requise, le plan Pro est à 19 $/mois par équipe, et le plan Team à 49 $/mois par équipe.

Dépannage

Le build échoue avec le processus tué en plein milieu, sans erreur claire. C’est presque toujours un manque de mémoire pendant le build sur un petit serveur ; redimensionnez temporairement comme mentionné dans « Ce qu’il vous faut », ou ajoutez du swap comme solution de secours moins chère (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).

L’application se construit et démarre, mais la page ne charge jamais, Caddy expire. Vérifiez que HOST=0.0.0.0 est bel et bien passé dans le conteneur en cours d’exécution (docker compose exec web env) ; un serveur qui écoute seulement sur localhost dans son propre espace de noms réseau de conteneur est inaccessible depuis le service caddy même si les deux conteneurs tournent. C’est l’erreur la plus courante avec un déploiement node-server de Nitro en particulier, puisque les valeurs par défaut de Nitro ne garantissent pas de se lier à toutes les interfaces.

Le HTTPS ne s’émet jamais, Caddy continue d’inscrire un délai d’attente dépassé contre Let’s Encrypt. Confirmez que l’enregistrement DNS A de l’étape 2 résout bel et bien vers l’IP de ce serveur depuis l’extérieur de votre réseau, et que les ports 80 et 443 sont ouverts dans le pare-feu Hetzner ainsi que dans les règles ufw du serveur lui-même, les deux doivent autoriser le trafic entrant pour que le défi ACME se complète.

Le port 3000 (ou 80) est déjà utilisé sur l’hôte. Quelque chose d’autre y est déjà lié, souvent une installation nginx par défaut laissée par une image de base, ou un conteneur oublié d’une tentative précédente. sudo ss -tlnp | grep ':80\|:3000' montre ce qui le retient ; le fichier Compose de ce guide ne publie pas du tout le port 3000 vers l’hôte (il est seulement exposed vers le service caddy), donc un conflit ici pointe habituellement plutôt vers le port 80 ou 443.

Une variable d’environnement affiche l’ancienne valeur après que vous l’ayez changée. Tout ce qui est lu avec useRuntimeConfig() est résolu au démarrage du conteneur, donc la changer puis redémarrer le conteneur suffit, aucune reconstruction nécessaire. Tout ce qui est préfixé NUXT_PUBLIC_, par contre, est intégré dans le JavaScript côté client pendant le build lui-même, donc changer une de ces variables demande docker compose up -d --build web à nouveau, un simple redémarrage ne fait rien pour elle.

FAQ

Dois-je fixer explicitement nitro: { preset: "node-server" }, ou le défaut suffit-il ? Le défaut fonctionne très bien, node-server est déjà ce que vous obtenez sans aucune configuration. Le fixer explicitement est une petite assurance pour la production pour qu’une future version de Nuxt changeant son préréglage par défaut ne change pas aussi votre cible de déploiement.

Puis-je utiliser pnpm, yarn ou bun au lieu de npm ? Oui, le Dockerfile de l’étape 4 branche déjà selon le fichier de verrouillage présent, aucun changement nécessaire au-delà d’avoir ce fichier de verrouillage commité. (Ajoutez une branche bun.lockb si vous utilisez Bun spécifiquement, l’exemple ci-dessus couvre les trois fichiers de verrouillage les plus courants.)

Qu’en est-il des routes serveur et des routes API de Nuxt, tournent-elles dans ce même conteneur ? Oui, tout ce qui se trouve sous server/api ou server/routes dans votre application Nuxt est regroupé dans le même .output/server/index.mjs et tourne dans le même processus que vos pages, il n’y a pas de backend séparé à déployer à moins que vous en ayez délibérément séparé un.

Est-ce sûr de faire tourner plusieurs instances derrière un équilibreur de charge plus tard ? En grande partie, le node-server de Nitro est sans état par défaut, donc faire tourner plusieurs conteneurs derrière l’équilibrage de charge de Caddy fonctionne sans configuration particulière. La mise en garde habituelle s’applique si votre application garde un état en mémoire entre les requêtes (limitation de débit, mise en cache en processus), ce qui devrait alors être déplacé vers quelque chose de partagé comme Redis une fois que vous faites tourner plus d’une instance.

En quoi est-ce différent de déployer sur Vercel ou Netlify ? Fonctionnellement, votre application exécute le même code dans les deux cas, la sortie de build de Nuxt est portable par conception. Ce que vous échangez, c’est un pipeline de build géré et un réseau en périphérie contre la propriété du serveur, du Dockerfile et du certificat vous-même, ce qui coûte moins cher à trafic stable et demande plus de travail aux extrémités (mise à l’échelle, hébergement d’images, environnements de prévisualisation).

Le Dockerfile et le fichier Compose ici ne changent pas beaucoup d’un projet Nuxt à l’autre, ce qui varie vraiment, c’est ce que votre application lit dans l’environnement à l’exécution, ça vaut la peine de le revérifier par rapport à cette liste avant le premier vrai déploiement.

Pourquoi les gens choisissent Railyard pour ça

  • Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
  • Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
  • Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
  • Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
  • Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.

Déployez ce dépôt sans le Dockerfile

Collez l'URL du dépôt dans Railyard et le build, les ports et les variables d'environnement sont détectés pour vous sur un serveur Hetzner.

Offre gratuite, sans carte, et 14 jours de Pro pour chaque nouvelle équipe. Prix fixe par équipe.