Vous avez développé une application Nuxt 4 qui tourne parfaitement en local. Maintenant vient la vraie question : comment la mettre en production de façon fiable, reproductible et scalable ? La réponse courte : Docker + Nginx sur un VPS. La réponse longue, c'est cet article.

Pourquoi cette stack ?

Déployer directement son code sur un serveur Linux à coups de git pull et pm2 restart, ça marche. Jusqu'au jour où ça ne marche plus, et où vous ne savez plus pourquoi — version de Node incompatible, dépendances qui ont changé, configuration qui diffère entre votre machine et le serveur.

Docker résout ce problème à la racine. Vous empaquetez votre application avec exactement l'environnement dont elle a besoin. L'image qui tourne sur votre machine est identique à celle en production. Point final.

Nginx, lui, joue le rôle de vigile devant votre application : il gère le HTTPS, distribue le trafic, sert les fichiers statiques, et expose votre app au monde sans exposer directement Node.

Le VPS vous donne le contrôle total sur votre infrastructure — indispensable dès qu'on veut des configurations personnalisées, un contrôle sur les coûts ou héberger plusieurs projets sur un même serveur.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Un VPS Linux (Ubuntu 22.04 LTS recommandé), avec au minimum 1 vCPU et 1 GB de RAM
  • Docker et Docker Compose installés sur le VPS
  • Git installé
  • Un nom de domaine pointant vers l'IP de votre VPS (optionnel, mais conseillé pour le HTTPS)
  • Des notions de base en ligne de commande Linux

Pour installer Docker sur Ubuntu :

# Update package index
sudo apt update

# Install dependencies
sudo apt install -y ca-certificates curl gnupg

# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Add Docker repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine and Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Allow running Docker without sudo
sudo usermod -aG docker $USER
newgrp docker

Préparer l'application Nuxt 4

Nuxt 4 dispose d'un mode serveur (SSR) géré par Nitro, le moteur interne de Nuxt. En production, Nitro génère un serveur Node.js autonome dans le dossier .output/. C'est ce dossier que l'on va embarquer dans notre image Docker.

Configuration nuxt.config.ts

Assurez-vous que votre configuration cible bien le preset Node :

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    // 'node-server' est le preset par défaut pour un déploiement Node.js classique
    preset: 'node-server',
  },
})

Build de l'application

# Install dependencies
pnpm install

# Build for production — generates .output/ directory
pnpm build

Le dossier .output/ contient tout ce dont Nuxt a besoin pour tourner : le serveur Nitro, les assets statiques et le code client. Vous n'avez besoin de rien d'autre à l'exécution.

Structure attendue du projet

mon-projet/
├── .output/           ← généré par pnpm build (ne pas committer)
├── Dockerfile
├── docker-compose.yml
├── nginx/
│   └── default.conf
├── .env.example
└── ...

Créer l'image Docker

Le Dockerfile est la recette de votre image. Nous allons utiliser un multi-stage build : une première étape pour builder l'application, une seconde pour l'exécuter. Résultat : une image finale légère, sans les dépendances de développement.

# ─── Stage 1: base ────────────────────────────────────────────────────────────
# Shared base image with pnpm enabled
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate

# ─── Stage 2: deps ────────────────────────────────────────────────────────────
# Install all dependencies (dev included) with build tools for native modules
# (better-sqlite3, sharp need python3 / make / g++ at compile time)
FROM base AS deps
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# ─── Stage 3: builder ─────────────────────────────────────────────────────────
# Build the Nuxt SSR app — produces a self-contained .output/ directory
FROM base AS builder
WORKDIR /app

# Copy installed node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules

# Copy source
COPY . .

RUN pnpm build

# ─── Stage 4: runner ──────────────────────────────────────────────────────────
# Minimal production image — only the Nitro output bundle is copied
FROM node:22-alpine AS runner

# Runtime deps for native .node binaries bundled inside .output/
# (better-sqlite3 compiled with musl works on Alpine without extras;
#  sharp ships pre-built binaries that need libvips at runtime)
RUN apk add --no-cache vips

WORKDIR /app

# Copy only the self-contained Nitro bundle
COPY --from=builder /app/.output ./.output

# Drop root privileges
RUN addgroup -S nuxt && adduser -S nuxt -G nuxt

# Switch to non-root user
USER nuxt

# Nitro listens on port 3000 by default
EXPOSE 3000

# HOST=0.0.0.0 is required so the server binds to all interfaces inside Docker
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NODE_ENV=production

CMD ["node", "server/index.mjs"]

Quelques points importants ici :

  • node:22-alpine est une image légère (~50 MB contre ~300 MB pour la version Debian).
  • Le multi-stage fait que seul .output/ se retrouve dans l'image finale — pas vos sources, pas vos node_modules de dev.
  • L'utilisateur nuxt non-root est une bonne pratique de sécurité élémentaire.
  • HOST=0.0.0.0 est indispensable : sans ça, Nitro écoute sur localhost à l'intérieur du conteneur et Nginx ne peut pas l'atteindre.

Lancer les conteneurs avec Docker Compose

Docker Compose permet de décrire et d'orchestrer vos services en un seul fichier. Ici on aura deux services : l'application Nuxt et Nginx.

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: nuxt_app
    restart: unless-stopped      # Restart automatically unless manually stopped
    environment:
      - NODE_ENV=production
    env_file:
      - .env                     # Load your runtime variables (API keys, DB url, etc.)
    expose:
      - "3000"                   # Expose port only to other containers, not to the host
    networks:
      - web

  nginx:
    image: nginx:alpine
    container_name: nginx_proxy
    restart: unless-stopped
    ports:
      - "80:80"                  # HTTP
      - "443:443"                # HTTPS
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro          # SSL certificates
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - app
    networks:
      - web

networks:
  web:
    driver: bridge

Le service app n'expose pas son port 3000 vers l'extérieur (expose vs ports). Seul Nginx y a accès via le réseau Docker interne web. C'est exactement ce qu'on veut.

Configurer Nginx comme reverse proxy

Créez le fichier nginx/default.conf :

# nginx/default.conf

# Redirect all HTTP traffic to HTTPS
server {
    listen 80;
    server_name votredomain.com www.votredomain.com;

    # Required for Let's Encrypt certificate renewal
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS — main server block
server {
    listen 443 ssl;
    server_name votredomain.com www.votredomain.com;

    # SSL certificates (generated by Certbot)
    ssl_certificate     /etc/letsencrypt/live/votredomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/votredomain.com/privkey.pem;

    # Modern SSL configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header X-Frame-Options       "SAMEORIGIN"   always;
    add_header X-Content-Type-Options "nosniff"      always;
    add_header Referrer-Policy       "strict-origin" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;

    # Proxy requests to the Nuxt app container
    location / {
        proxy_pass         http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade    $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host       $host;
        proxy_set_header   X-Real-IP  $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

proxy_pass http://app:3000 — notez que app est le nom du service Docker Compose. Docker résout automatiquement ce nom en adresse IP interne. Pas besoin de connaître l'IP réelle du conteneur.

Déployer sur le VPS

Première mise en production

# Connect to your VPS
ssh user@your-vps-ip

# Clone your repository
git clone https://github.com/votre-org/votre-projet.git
cd votre-projet

# Copy and configure your environment variables
cp .env.example .env
nano .env   # Fill in your production values

# Obtain SSL certificate with Certbot (first time only)
docker run --rm -v ./certbot/conf:/etc/letsencrypt \
  -v ./certbot/www:/var/www/certbot \
  certbot/certbot certonly --webroot \
  --webroot-path=/var/www/certbot \
  -d votredomain.com -d www.votredomain.com \
  --email contact@votredomain.com --agree-tos --no-eff-email

# Build and start all services in detached mode
docker compose up --build -d

# Verify everything is running
docker compose ps
docker compose logs app --tail=50

Mises à jour suivantes

# Pull latest changes
git pull origin main

# Rebuild and restart only the app container (zero-downtime for Nginx)
docker compose up --build -d app

# Clean up old unused images
docker image prune -f

Bonnes pratiques pour la production

Variables d'environnement. Ne committez jamais votre .env dans Git. Utilisez .env.example avec des valeurs vides comme documentation, et gérez le vrai .env directement sur le serveur.

Logs. Docker stocke les logs de vos conteneurs. Configurez une rotation pour éviter que les logs ne saturent votre disque :

# Dans docker-compose.yml, sur le service app
logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

Redémarrage automatique. restart: unless-stopped garantit que vos conteneurs redémarrent après un reboot du serveur ou un crash. Si vous voulez encore plus de contrôle, utilisez restart: always.

Renouvellement SSL automatique. Les certificats Let's Encrypt expirent tous les 90 jours. Automatisez le renouvellement avec un cron :

# Ajouter dans crontab (crontab -e)
0 3 * * 1 docker run --rm -v /path/to/certbot/conf:/etc/letsencrypt \
  -v /path/to/certbot/www:/var/www/certbot \
  certbot/certbot renew --quiet \
  && docker exec nginx_proxy nginx -s reload

Monitoring basique. Pour surveiller vos conteneurs sans infrastructure lourde, docker stats suffit pour commencer :

# Live CPU/memory usage for all containers
docker stats --no-stream

Pour aller plus loin, Uptime Kuma est une excellente solution open-source légère que vous pouvez auto-héberger sur le même VPS pour monitorer vos services et recevoir des alertes.

Conclusion

En combinant Nuxt 4, Docker et Nginx, vous obtenez une architecture de déploiement solide, reproductible et professionnelle. Ce que vous avez mis en place ici — multi-stage build, reverse proxy, HTTPS automatique, redémarrage automatique — c'est exactement ce que font les équipes DevOps en production dans des entreprises bien plus grandes.

L'étape suivante naturelle est l'automatisation complète via un pipeline CI/CD avec GitHub Actions : à chaque push sur main, votre application se build, se teste et se déploie automatiquement sur votre VPS. Ça fera l'objet d'un prochain article.

En attendant, votre application tourne. Et cette fois, elle tourne pour de vrai.