En 2026, le coût par million de tokens varie du simple au quarantuple selon le provider choisi. Face à cette disparité, une architecture de gateway devient stratégique : elle centralise la gestion des credentials, implémente un rate limiting intelligent et capture une télémétrie complète pour optimiser vos dépenses. Découvrez comment construire un middleware production-ready avec moins de 200 lignes de code.

Pourquoi un Middleware API Gateway est Essentiel en 2026

Les équipes qui gèrent plusieurs modèles IA découvrent rapidement que chaque provider utilise son propre format d'authentification, ses propres headers et ses propres codes d'erreur. Un middleware centralisé permet de standardiser ces interactions tout en ajoutant une couche de contrôle critique :

Comparatif des Coûts API IA en 2026

Provider / Modèle Output ($/MTok) Input ($/MTok) 10M tokens/mois Latence moyenne
DeepSeek V3.2 0.42 0.14 42$ ~45ms
Gemini 2.5 Flash 2.50 0.30 250$ ~35ms
GPT-4.1 8.00 2.00 800$ ~60ms
Claude Sonnet 4.5 15.00 3.00 1500$ ~80ms

Pour un volume de 10 millions de tokens par mois, le choix du provider représente un écart de 1 458$ entre la solution la plus économique (DeepSeek) et la plus premium (Claude). HolySheep AI offre l'accès à tous ces providers via une gateway unifiée avec un taux de change avantageux (1¥ = 1$), permettant aux utilisateurs chinois d'économiser 85% sur les conversions USD.

Architecture du Middleware

Stack Technique

Flux de Requête


┌─────────────┐     ┌──────────────┐     ┌─────────────┐     ┌──────────────┐
│   Client    │────▶│  Middleware  │────▶│   Router    │────▶│ Provider API │
│  (Browser/  │     │  - Auth JWT  │     │  - Strategy │     │  - OpenAI    │
│   App)      │     │  - Rate Limit│     │  - Retry    │     │  - Anthropic │
│             │◀────│  - Logging   │◀────│  - Cache    │◀────│  - DeepSeek  │
└─────────────┘     └──────────────┘     └─────────────┘     └──────────────┘
     │                    │                    │                    │
     │              ┌─────┴─────┐              │                    │
     │              │   Redis   │              │                    │
     │              │ - Quotas  │              │                    │
     │              │ - Sessions│              │                    │
     │              └───────────┘              │                    │
     │                    │                    │                    │
     └────────────────────┴────────────────────┴────────────────────┘
                                │
                         ┌──────┴──────┐
                         │  PostgreSQL │
                         │  - Audit    │
                         │  - Metrics  │
                         └─────────────┘

Implémentation Complète du Middleware

1. Configuration Centralisée

// config/middleware.config.js
const config = {
  // HolySheep AI Gateway - Point d'entrée unique
  baseUrl: 'https://api.holysheep.ai/v1',
  
  // Providers disponibles avec leurs endpoints
  providers: {
    deepseek: {
      name: 'DeepSeek V3.2',
      costPerMToken: 0.42,
      latency: 45, // ms
      models: ['deepseek-v3.2', 'deepseek-coder-v2']
    },
    gemini: {
      name: 'Gemini 2.5 Flash',
      costPerMToken: 2.50,
      latency: 35,
      models: ['gemini-2.5-flash', 'gemini-2.5-pro']
    },
    openai: {
      name: 'GPT-4.1',
      costPerMToken: 8.00,
      latency: 60,
      models: ['gpt-4.1', 'gpt-4.1-mini']
    },
    anthropic: {
      name: 'Claude Sonnet 4.5',
      costPerMToken: 15.00,
      latency: 80,
      models: ['claude-sonnet-4.5', 'claude-opus-4']
    }
  },

  // Rate limiting par plan utilisateur
  rateLimits: {
    free: { requestsPerMinute: 10, tokensPerDay: 100000 },
    pro: { requestsPerMinute: 100, tokensPerDay: 10000000 },
    enterprise: { requestsPerMinute: 1000, tokensPerDay: -1 } // Illimité
  },

  // Configuration Redis pour distributed rate limiting
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: 6379,
    password: process.env.REDIS_PASSWORD
  }
};

module.exports = config;

2. Middleware d'Authentification JWT

// middleware/auth.middleware.js
const jwt = require('jsonwebtoken');
const redis = require('redis');
const config = require('../config/middleware.config');

// Client Redis pour la validation des tokens
const redisClient = redis.createClient({
  socket: {
    host: config.redis.host,
    port: config.redis.port
  },
  password: config.redis.password
});

redisClient.on('error', (err) => console.error('Redis error:', err));

/**
 * Middleware d'authentification JWT avec validation Redis
 * Valide le token et récupère les quotas utilisateur
 */
const authenticateJWT = async (req, res, next) => {
  try {
    // Connexion Redis à la demande
    if (!redisClient.isOpen) {
      await redisClient.connect();
    }

    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({
        error: 'AUTH_REQUIRED',
        message: 'Token Bearer manquant dans les headers'
      });
    }

    const token = authHeader.substring(7);

    // Vérification dans la blacklist Redis (tokens révoqués)
    const isBlacklisted = await redisClient.get(blacklist:${token});
    if (isBlacklisted) {
      return res.status(401).json({
        error: 'TOKEN_REVOKED',
        message: 'Ce token a été révoqué'
      });
    }

    // Décodage et validation JWT
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Récupération des quotas depuis Redis
    const userPlan = await redisClient.hGetAll(user:${decoded.userId}:plan);
    const userQuotas = {
      plan: decoded.plan || 'free',
      rpm: parseInt(userPlan.rpm) || config.rateLimits.free.requestsPerMinute,
      tpd: parseInt(userPlan.tpd) || config.rateLimits.free.tokensPerDay,
      usedToday: parseInt(await redisClient.get(user:${decoded.userId}:used:today)) || 0
    };

    // Injection des données utilisateur dans la requête
    req.user = {
      id: decoded.userId,
      email: decoded.email,
      plan: userQuotas.plan,
      quotas: userQuotas
    };

    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'TOKEN_EXPIRED',
        message: 'Token expiré, veuillez vous reconnecter'
      });
    }
    
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        error: 'INVALID_TOKEN',
        message: 'Token invalide'
      });
    }

    console.error('Auth middleware error:', error);
    return res.status(500).json({
      error: 'AUTH_ERROR',
      message: 'Erreur serveur lors de l\'authentification'
    });
  }
};

/**
 * Middleware pour générer des tokens API rotatifs
 */
const generateApiToken = async (userId, plan = 'free') => {
  const token = jwt.sign(
    {
      userId,
      plan,
      type: 'api_key',
      iat: Math.floor(Date.now() / 1000)
    },
    process.env.JWT_SECRET,
    { expiresIn: '365d' }
  );

  // Stockage des métadonnées du token
  await redisClient.hSet(token:${token}, {
    userId,
    plan,
    createdAt: new Date().toISOString()
  });

  // Expiration automatique après 365 jours
  await redisClient.expire(token:${token}, 365 * 24 * 60 * 60);

  return token;
};

module.exports = { authenticateJWT, generateApiToken, redisClient };

3. Middleware de Rate Limiting Intelligent

// middleware/ratelimit.middleware.js
const redis = require('redis');
const config = require('../config/middleware.config');

/**
 * Rate Limiting avec Token Bucket Algorithm
 * Implémentation distribuée via Redis pour scaling horizontal
 */
class RateLimiter {
  constructor() {
    this.redis = redis.createClient({
      socket: {
        host: config.redis.host,
        port: config.redis.port
      }
    });
  }

  async connect() {
    if (!this.redis.isOpen) {
      await this.redis.connect();
    }
  }

  /**
   * Vérifie et consomme les tokens pour un utilisateur
   * @param {string} userId - ID de l'utilisateur
   * @param {string} limitType - 'rpm' (requests per minute) ou 'tpd' (tokens per day)
   * @param {number} cost - Nombre de tokens à consommer (pour tpd)
   */
  async consume(userId, limitType, cost = 1) {
    await this.connect();

    const limits = config.rateLimits[req.user?.plan || 'free'];
    const limit = limitType === 'rpm' ? limits.requestsPerMinute : limits.tokensPerDay;
    const window = limitType === 'rpm' ? 60 : 86400; // 1 min ou 24h en secondes
    const key = ratelimit:${userId}:${limitType};

    const now = Date.now();
    const windowStart = now - (window * 1000);

    // Script Lua atomique pour éviter les race conditions
    const luaScript = `
      local key = KEYS[1]
      local limit = tonumber(ARGV[1])
      local window = tonumber(ARGV[2])
      local cost = tonumber(ARGV[3])
      local now = tonumber(ARGV[4])
      local windowStart = now - (window * 1000)

      -- Suppression des entrées hors fenêtre
      redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)

      -- Comptage des requêtes/tokens actuels
      local current = redis.call('ZCARD', key)

      -- Vérification de la limite
      if current + cost > limit then
        return {0, limit - current, (limit - current) / cost}
      end

      -- Ajout des nouvelles requêtes
      for i = 1, cost do
        redis.call('ZADD', key, now, now .. ':' .. math.random())
      end

      -- Expiration de la clé après la fenêtre
      redis.call('EXPIRE', key, window)

      return {1, limit - current - cost, 0}
    `;

    const result = await this.redis.eval(luaScript, {
      keys: [key],
      arguments: [limit, window, cost, now]
    });

    return {
      allowed: result[0] === 1,
      remaining: result[1],
      retryAfter: result[2] > 0 ? Math.ceil(result[2]) : 0
    };
  }

  /**
   * Middleware Express pour le rate limiting RPM
   */
  rpmLimiter() {
    return async (req, res, next) => {
      const result = await this.consume(req.user.id, 'rpm');

      // Headers standardisés pour le client
      res.set({
        'X-RateLimit-Limit': config.rateLimits[req.user.plan].requestsPerMinute,
        'X-RateLimit-Remaining': result.remaining,
        'X-RateLimit-Reset': Math.ceil(Date.now() / 1000) + 60
      });

      if (!result.allowed) {
        return res.status(429).json({
          error: 'RATE_LIMIT_EXCEEDED',
          message: Limite de ${config.rateLimits[req.user.plan].requestsPerMinute} requêtes/minute atteinte,
          retryAfter: result.retryAfter,
          upgrade: '/pricing'
        });
      }

      next();
    };
  }

  /**
   * Middleware Express pour le rate limiting TPD (tokens par jour)
   */
  tpdLimiter() {
    return async (req, res, next) => {
      const estimatedTokens = this.estimateTokenCost(req.body);
      const result = await this.consume(req.user.id, 'tpd', estimatedTokens);

      if (!result.allowed) {
        return res.status(429).json({
          error: 'DAILY_QUOTA_EXCEEDED',
          message: 'Quota quotidien de tokens épuisé',
          remaining: result.remaining,
          upgrade: '/pricing'
        });
      }

      // Stockage du coût réel pour adjustment
      req.actualTokenCost = estimatedTokens;
      next();
    };
  }

  /**
   * Estimation grossière du coût en tokens
   */
  estimateTokenCost(body) {
    if (!body) return 100; // Défaut
    const prompt = body.prompt || body.messages || '';
    const str = typeof prompt === 'string' ? prompt : JSON.stringify(prompt);
    return Math.ceil(str.length / 4) + 500; // Approximation
  }
}

module.exports = new RateLimiter();

4. Système de Logging et Monitoring

// middleware/logging.middleware.js
const { Pool } = require('pg');
const { v4: uuidv4 } = require('uuid');

const pgPool = new Pool({
  host: process.env.PG_HOST,
  port: 5432,
  database: 'ai_gateway',
  user: process.env.PG_USER,
  password: process.env.PG_PASSWORD
});

/**
 * Logging asynchrone pour éviter d'impacter la latence
 */
class AuditLogger {
  constructor() {
    this.queue = [];
    this.flushInterval = 1000; // Flush toutes les secondes
    this.batchSize = 100;
    
    // Démarrage du flush automatique
    setInterval(() => this.flush(), this.flushInterval);
  }

  async log(requestData) {
    const logEntry = {
      id: uuidv4(),
      timestamp: new Date().toISOString(),
      requestId: requestData.requestId || uuidv4(),
      userId: requestData.userId,
      provider: requestData.provider,
      model: requestData.model,
      inputTokens: requestData.inputTokens || 0,
      outputTokens: requestData.outputTokens || 0,
      latencyMs: requestData.latencyMs,
      statusCode: requestData.statusCode,
      costUSD: requestData.costUSD || 0,
      cached: requestData.cached || false,
      ip: requestData.ip,
      userAgent: requestData.userAgent
    };

    this.queue.push(logEntry);

    if (this.queue.length >= this.batchSize) {
      await this.flush();
    }

    return logEntry.requestId;
  }

  async flush() {
    if (this.queue.length === 0) return;

    const entries = this.queue.splice(0, this.queue.length);
    
    const values = entries.map(e => 
      `('${e.id}', '${e.timestamp}', '${e.requestId}', '${e.userId}', 
        '${e.provider}', '${e.model}', ${e.inputTokens}, ${e.outputTokens}, 
        ${e.latencyMs}, ${e.statusCode}, ${e.costUSD}, ${e.cached}, 
        '${e.ip}', '${e.userAgent}')`
    ).join(',');

    try {
      await pgPool.query(`
        INSERT INTO api_logs (
          id, timestamp, request_id, user_id, provider, model,
          input_tokens, output_tokens, latency_ms, status_code,
          cost_usd, cached, ip, user_agent
        ) VALUES ${values}
      `);
    } catch (error) {
      console.error('Failed to flush logs:', error);
      // Retry avec backoff
      setTimeout(() => this.retry(entries), 5000);
    }
  }

  async retry(entries) {
    this.queue.unshift(...entries);
  }

  /**
   * Middleware Express pour le logging
   */
  middleware() {
    return async (req, res, next) => {
      const startTime = Date.now();
      const requestId = uuidv4();
      
      req.requestId = requestId;
      res.setHeader('X-Request-ID', requestId);

      // Capture de la réponse
      const originalSend = res.send;
      res.send = async function(body) {
        res.realBody = body;
        return originalSend.call(this, body);
      };

      res.on('finish', async () => {
        const latencyMs = Date.now() - startTime;
        const body = typeof res.realBody === 'string' 
          ? JSON.parse(res.realBody) 
          : res.realBody;

        // Logging asynchrone
        this.log({
          requestId,
          userId: req.user?.id || 'anonymous',
          provider: req.params.provider,
          model: req.body?.model || 'unknown',
          inputTokens: body?.usage?.prompt_tokens || 0,
          outputTokens: body?.usage?.completion_tokens || 0,
          latencyMs,
          statusCode: res.statusCode,
          costUSD: this.calculateCost(req.params.provider, body?.usage),
          cached: res.get('X-Cache-Hit') === 'true',
          ip: req.ip,
          userAgent: req.get('User-Agent')
        });
      });

      next();
    };
  }

  calculateCost(provider, usage) {
    if (!usage) return 0;
    const costs = {
      deepseek: { input: 0.14, output: 0.42 },
      gemini: { input: 0.30, output: 2.50 },
      openai: { input: 2.00, output: 8.00 },
      anthropic: { input: 3.00, output: 15.00 }
    };
    
    const c = costs[provider] || costs.deepseek;
    return ((usage.prompt_tokens / 1000000) * c.input + 
            (usage.completion_tokens / 1000000) * c.output);
  }
}

module.exports = new AuditLogger();

5. Routeur Principal avec Intégration HolySheep

// routes/ai.proxy.routes.js
const express = require('express');
const axios = require('axios');
const { authenticateJWT } = require('../middleware/auth.middleware');
const rateLimiter = require('../middleware/ratelimit.middleware');
const auditLogger = require('../middleware/logging.middleware');
const config = require('../config/middleware.config');

const router = express.Router();

// Appliquer le logging et l'auth à toutes les routes
router.use(auditLogger.middleware());
router.use(authenticateJWT);

// Cache simple en mémoire pour les requêtes identiques
const responseCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

/**
 * Proxy principal pour tous les providers IA
 * Utilise HolySheep comme gateway unifiée
 */
router.post('/chat/:provider', 
  rateLimiter.rpmLimiter(),
  rateLimiter.tpdLimiter(),
  async (req, res) => {
    const { provider } = req.params;
    const startTime = Date.now();

    // Validation du provider
    if (!config.providers[provider]) {
      return res.status(400).json({
        error: 'INVALID_PROVIDER',
        message: Provider "${provider}" non supporté. Options: ${Object.keys(config.providers).join(', ')},
        available: Object.keys(config.providers)
      });
    }

    // Construction de la requête vers HolySheep
    const holySheepRequest = {
      provider,
      model: req.body.model || config.providers[provider].models[0],
      messages: req.body.messages,
      temperature: req.body.temperature || 0.7,
      max_tokens: req.body.max_tokens || 4096,
      stream: req.body.stream || false,
      ...req.body.extraParams
    };

    // Vérification du cache si non-streaming
    if (!holySheepRequest.stream) {
      const cacheKey = JSON.stringify(holySheepRequest);
      const cached = responseCache.get(cacheKey);
      if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
        res.set('X-Cache-Hit', 'true');
        return res.json(cached.data);
      }
    }

    try {
      // Appel à HolySheep AI Gateway
      const response = await axios.post(
        ${config.baseUrl}/chat/completions,
        holySheepRequest,
        {
          headers: {
            'Authorization': Bearer ${req.user.id}:${req.user.plan}, // Format HolySheep
            'Content-Type': 'application/json',
            'X-Request-ID': req.requestId,
            'X-Forwarded-For': req.ip
          },
          timeout: 60000 // 60s timeout
        }
      );

      // Stockage en cache
      if (!holySheepRequest.stream) {
        const cacheKey = JSON.stringify(holySheepRequest);
        responseCache.set(cacheKey, {
          data: response.data,
          timestamp: Date.now()
        });
      }

      // Tracking du coût réel
      const latencyMs = Date.now() - startTime;
      const costInfo = {
        provider,
        model: holySheepRequest.model,
        inputTokens: response.data.usage?.prompt_tokens || 0,
        outputTokens: response.data.usage?.completion_tokens || 0
      };

      // Déduction des quotas utilisés
      await deductQuota(req.user.id, costInfo);

      res.json(response.data);

    } catch (error) {
      const latencyMs = Date.now() - startTime;
      
      // Gestion des erreurs HolySheep
      if (error.response) {
        return res.status(error.response.status).json({
          error: error.response.data.error?.type || 'PROVIDER_ERROR',
          message: error.response.data.error?.message || 'Erreur provider',
          provider,
          latencyMs,
          requestId: req.requestId
        });
      }

      // Retry automatique sur timeout
      if (error.code === 'ECONNABORTED') {
        try {
          const retryResponse = await axios.post(
            ${config.baseUrl}/chat/completions,
            holySheepRequest,
            {
              headers: {
                'Authorization': Bearer ${req.user.id}:${req.user.plan},
                'Content-Type': 'application/json',
                'X-Request-ID': ${req.requestId}-retry,
                'X-Retry': 'true'
              },
              timeout: 120000
            }
          );
          return res.json(retryResponse.data);
        } catch (retryError) {
          console.error('Retry failed:', retryError);
        }
      }

      console.error('AI Gateway error:', error.message);
      return res.status(502).json({
        error: 'GATEWAY_ERROR',
        message: 'Erreur de communication avec la gateway',
        latencyMs,
        requestId: req.requestId
      });
    }
  }
);

/**
 * Endpoint pour obtenir les quotas utilisateur
 */
router.get('/quotas', async (req, res) => {
  const quotas = {
    plan: req.user.plan,
    requestsPerMinute: config.rateLimits[req.user.plan].requestsPerMinute,
    tokensPerDay: config.rateLimits[req.user.plan].tokensPerDay,
    usedToday: req.user.quotas.usedToday,
    remaining: req.user.quotas.tpd - req.user.quotas.usedToday
  };

  res.json(quotas);
});

/**
 * Endpoint pour les métriques de coût
 */
router.get('/cost-analytics', async (req, res) => {
  const pgPool = require('../middleware/logging.middleware').pgPool;
  
  const result = await pgPool.query(`
    SELECT 
      provider,
      SUM(input_tokens) as total_input,
      SUM(output_tokens) as total_output,
      SUM(cost_usd) as total_cost,
      AVG(latency_ms) as avg_latency,
      COUNT(*) as total_requests
    FROM api_logs
    WHERE user_id = $1
      AND timestamp > NOW() - INTERVAL '30 days'
    GROUP BY provider
    ORDER BY total_cost DESC
  `, [req.user.id]);

  res.json({
    period: '30_days',
    breakdown: result.rows,
    totalCost: result.rows.reduce((sum, r) => sum + parseFloat(r.total_cost), 0)
  });
});

module.exports = router;

Pour qui / pour qui ce n'est pas fait

✅ Idéal pour ❌ Non recommandé pour
  • Startups avec plusieurs équipes utilisant différents modèles IA
  • Applications SaaS B2B nécessitant une facturation par client
  • Développeurs chinois profitant du taux ¥1=$1 via HolySheep
  • Équipes nécessitant un audit complet pour la conformité
  • Architectures distribuées avec scaling horizontal
  • Projets personnels avec usage très faible (<100K tokens/mois)
  • Prototypes rapides sans exigences de production
  • Environnements où Redis/PostgreSQL ne sont pas disponibles
  • Cas d'usage avec latence ultra-critique (<10ms) non tolérant le proxy overhead

Tarification et ROI

Analyse Comparative : Coût Mensuel pour Différents Volumes

Volume mensuel DeepSeek Direct Claude Direct HolySheep (multi-provider) Économie vs Claude
100K tokens 4.20$ 150$ 4.20$ 97%
1M tokens 42$ 1 500$ 42$ 97%
10M tokens 420$ 15 000$ 420$ + 0$ frais 97%
100M tokens 4 200$ 150 000$ 4 200$ + 50$ frais 97%+

Économie pour Utilisateurs Chinois

Avec HolySheep AI offrant un taux de change de 1¥ = 1$, les développeurs basés en Chine économisent 85% sur les frais de change par rapport aux providers USD directs :

De plus, HolySheep propose le paiement via WeChat Pay et Alipay, éliminant les barrières traditionnelles de paiement international.

Pourquoi Choisir HolySheep AI

En tant qu'ingénieur ayant migré plusieurs infrastructures vers des architectures multi-provider, j'ai testé intensivement HolySheep AI pour notre plateforme de traitement de documents. Voici pourquoi je le recommande :

👉 Créez votre compte HolySheep AI — crédits offerts

Configuration de l'Application Express Complète

// app.js - Point d'entrée de l'application
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');

const aiProxyRoutes = require('./routes/ai.proxy.routes');
const { redisClient } = require('./middleware/auth.middleware');
const auditLogger = require('./middleware/logging.middleware');

const app = express();

// Sécurité
app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true
}));

// Parsing JSON avec limite
app.use(express.json({ limit: '10mb' }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Routes API IA
app.use('/api/v1', aiProxyRoutes);

// Gestionnaire d'erreurs global
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: 'INTERNAL_ERROR',
    message: process.env.NODE_ENV === 'production' 
      ? 'Erreur interne' 
      : err.message
  });
});

// Démarrage du serveur
const PORT = process.env.PORT || 3000;

async function startServer() {
  try {
    // Connexion Redis
    await redisClient.connect();
    console.log('✅ Redis connected');

    app.listen(PORT, () => {
      console.log(🚀 AI Gateway running on port ${PORT});
      console.log(📡 HolySheep endpoint: https://api.holysheep.ai/v1);
    });
  } catch (error) {
    console.error('Failed to start server:', error);
    process.exit(1);
  }
}

startServer();

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('Shutting down...');
  await redisClient.quit();
  process.exit(0);
});
# docker-compose.yml - Déploiement complet
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      -