feat: MinIO storage, native audio recording, production Docker config

- Replace vmsg WASM encoder with native MediaRecorder API (WebM/Opus)
  to fix empty MP3 files causing OpenAI Whisper 400 errors
- Add minimum recording duration (2s) and file size (5KB) guards
- Add MinIO S3 storage integration for recipe images and audio
- Add /uploads/* API route that proxies files from MinIO with local fallback
- Save audio locally first for transcription, then upload to MinIO
  (fixes ECONNREFUSED when backend tried to fetch its own public URL)
- Add docker-compose.prod.yml, nginx-prod.conf, frontend Dockerfile
- Frontend Dockerfile: no-cache headers on index.html, long cache on hashed assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arthur 2026-04-10 12:01:26 +02:00
parent 1b3f53c086
commit 3bff3c8600
8 changed files with 383 additions and 87 deletions

View File

@ -1,10 +1,11 @@
import fp from 'fastify-plugin';
import { OpenAI } from 'openai';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { FastifyInstance } from 'fastify';
import type { MultipartFile } from '@fastify/multipart';
import { uploadFile, getFileUrl } from '../utils/storage';
import { uploadFile, getPublicUrl } from '../utils/storage';
import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
import {
transcribeAudio as runTranscribe,
@ -15,7 +16,6 @@ import type { UserPreferences } from '../ai/prompts';
import { generateRecipeImage as runGenerateImage } from '../ai/image-generator';
export default fp(async function aiPlugin(fastify: FastifyInstance) {
// Client OpenAI partagé, avec retries et timeout configurés
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3),
@ -24,33 +24,35 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
fastify.decorate('openai', openai);
// --- Transcription audio ---
// Accepte soit un chemin local, soit un objet avec localPath.
// On ne passe PLUS par une URL publique — les fichiers sont toujours
// disponibles localement (sauvegardés dans ./uploads avant upload MinIO).
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
let tempFile: { path: string; cleanup: () => void } | null = null;
let audioPath: string;
if (typeof audioInput === 'string') {
audioPath = audioInput;
} else if (audioInput && 'url' in audioInput && audioInput.url) {
tempFile = await downloadToTemp(audioInput.url, '.mp3');
audioPath = tempFile.path;
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
audioPath = audioInput.localPath;
} else if (audioInput && 'url' in audioInput && audioInput.url) {
// Fallback : si on reçoit une URL externe (pas notre propre API),
// on télécharge en temp.
const tempFile = await downloadToTemp(audioInput.url, '.webm');
try {
const { text } = await runTranscribe(openai, fastify.log, { audioPath: tempFile.path });
return text;
} finally {
tempFile.cleanup();
}
} else {
throw new Error("Format d'entrée audio non valide");
}
try {
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
return text;
} finally {
tempFile?.cleanup();
}
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
return text;
});
// --- Génération de recette ---
// Cette fonction est maintenue pour compatibilité avec le contrat existant
// (elle retourne `RecipeData` à plat). En interne, elle utilise désormais
// le pipeline structuré + parallélisation image/texte.
fastify.decorate(
'generateRecipe',
async (
@ -58,24 +60,19 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
hint?: string,
preferences?: unknown
): Promise<RecipeData> => {
// 1. Génération du texte (séquentiel obligatoire : on a besoin du titre)
const { recipe } = await runGenerateRecipe(openai, fastify.log, {
transcription: ingredients,
hint,
preferences: preferences as UserPreferences | null | undefined,
});
// 2. Génération de l'image en parallèle de la sérialisation
// (l'image n'a besoin que du titre + description, qu'on a déjà)
const imagePromise = runGenerateImage(openai, fastify.log, {
title: recipe.titre,
description: recipe.description,
});
// 3. Aplatir la recette structurée vers RecipeData (compatibilité Prisma)
const flat = flattenRecipe(recipe);
// 4. Attendre l'image (best-effort, peut être null)
const { url: imageUrl } = await imagePromise;
flat.image_url = imageUrl;
@ -84,6 +81,10 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
);
// --- Sauvegarde fichier audio ---
// Stratégie : toujours sauvegarder en local D'ABORD (pour la transcription),
// puis uploader dans MinIO en arrière-plan (pour le stockage permanent).
// Le résultat retourne localPath pour que transcribeAudio lise le fichier
// directement sans passer par une URL publique.
fastify.decorate('saveAudioFile', async (file: MultipartFile): Promise<AudioSaveResult> => {
if (!file || !file.filename) {
throw new Error('Fichier audio invalide');
@ -91,36 +92,38 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
const fileName = `${Date.now()}-${file.filename}`;
// Tentative MinIO
try {
const filePath = await uploadFile({ filename: fileName, file: file.file }, 'audio');
const url = await getFileUrl(filePath);
return { success: true, url, path: filePath };
} catch (err) {
fastify.log.warn(`Upload MinIO échoué, fallback local: ${(err as Error).message}`);
}
// Fallback local
const uploadDir = './uploads';
// 1. Toujours sauvegarder en local pour la transcription
const uploadDir = './uploads/audio';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filepath = `${uploadDir}/${fileName}`;
const localFilePath = path.join(uploadDir, fileName);
if (file.file && typeof file.file.pipe === 'function') {
await pipeline(file.file, fs.createWriteStream(filepath));
await pipeline(file.file, fs.createWriteStream(localFilePath));
} else {
throw new Error('Format de fichier non pris en charge');
}
return { success: true, localPath: filepath, isLocal: true };
// 2. Upload dans MinIO en parallèle (best-effort)
let minioPath: string | null = null;
try {
const fileStream = fs.createReadStream(localFilePath);
minioPath = await uploadFile({ filename: fileName, file: fileStream }, 'audio');
fastify.log.info(`Audio uploadé dans MinIO: ${minioPath}`);
} catch (err) {
fastify.log.warn(`Upload MinIO audio échoué, fichier local conservé: ${(err as Error).message}`);
}
return {
success: true,
localPath: localFilePath,
path: minioPath ?? `audio/${fileName}`,
isLocal: true,
};
});
});
/**
* Convertit la recette structurée en `RecipeData` plat compatible avec
* l'ancien contrat utilisé par les routes Prisma.
*/
function flattenRecipe(recipe: StructuredRecipe): RecipeData {
const ingredients = recipe.ingredients.map((i) => {
const note = i.notes ? ` (${i.notes})` : '';
@ -152,7 +155,7 @@ function flattenRecipe(recipe: StructuredRecipe): RecipeData {
portions: recipe.portions,
difficulte: recipe.difficulte,
conseils,
image_url: null, // rempli plus tard
image_url: null,
structured: recipe,
};
}

View File

@ -9,6 +9,7 @@ import rateLimit from '@fastify/rate-limit';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import * as path from 'node:path';
import * as fs from 'node:fs';
import authPlugin from './plugins/auth';
import stripePlugin from './plugins/stripe';
@ -20,6 +21,8 @@ import recipesRoutes from './routes/recipes';
import usersRoutes from './routes/users';
import stripeRoutes from './routes/stripe';
import { getFile, isMinioConfigured, ensureBucket } from './utils/storage';
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
@ -29,7 +32,6 @@ const fastify = Fastify({
// Parser JSON custom : on stashe le body brut sur la request pour permettre
// la vérification de signature du webhook Stripe (qui exige les bytes exacts).
// Impact: ~2x mémoire sur les requêtes JSON, négligeable à notre échelle.
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
@ -51,21 +53,35 @@ fastify.addContentTypeParser(
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
/** Devine le Content-Type à partir de l'extension. */
function mimeFromExt(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.mp3': 'audio/mpeg',
'.webm': 'audio/webm',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.wav': 'audio/wav',
};
return map[ext] || 'application/octet-stream';
}
async function bootstrap(): Promise<void> {
// --- Sécurité ---
await fastify.register(helmet, {
contentSecurityPolicy: false,
// Autorise le frontend (cross-origin) à charger les images/audio servis
// depuis /uploads/*. Sans ça, le navigateur bloque avec
// ERR_BLOCKED_BY_RESPONSE.NotSameOrigin.
crossOriginResourcePolicy: { policy: 'cross-origin' },
});
await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
// Les webhooks Stripe peuvent arriver en rafale sur une retry burst —
// on ne veut pas les rate-limiter et risquer des événements perdus.
skip: (req) => req.url === '/stripe/webhook',
});
@ -87,11 +103,37 @@ async function bootstrap(): Promise<void> {
credentials: true,
});
// --- Fichiers statiques (fallback local pour images/audio) ---
await fastify.register(fastifyStatic, {
root: path.resolve('./uploads'),
prefix: '/uploads/',
decorateReply: false,
// --- Route /uploads/* : sert les fichiers depuis MinIO, fallback local ---
fastify.get('/uploads/*', async (request, reply) => {
// Le wildcard capture tout après /uploads/
const filePath = (request.params as { '*': string })['*'];
if (!filePath) {
return reply.code(400).send({ error: 'Chemin de fichier requis' });
}
const contentType = mimeFromExt(filePath);
// 1. Essayer MinIO d'abord
if (isMinioConfigured()) {
try {
const stream = await getFile(filePath);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
return reply.send(stream);
} catch (err) {
fastify.log.debug(`MinIO miss pour ${filePath}: ${(err as Error).message}`);
}
}
// 2. Fallback : fichier local dans ./uploads/
const localPath = path.resolve('./uploads', filePath);
if (fs.existsSync(localPath)) {
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
return reply.send(fs.createReadStream(localPath));
}
return reply.code(404).send({ error: 'Fichier non trouvé' });
});
// --- Plugins applicatifs ---
@ -108,6 +150,16 @@ async function bootstrap(): Promise<void> {
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
// --- S'assurer que le bucket MinIO existe au démarrage ---
if (isMinioConfigured()) {
try {
await ensureBucket();
fastify.log.info('MinIO bucket prêt');
} catch (err) {
fastify.log.warn(`MinIO bucket init échoué: ${(err as Error).message}`);
}
}
fastify.addHook('onClose', async () => {
await prisma.$disconnect();
});

View File

@ -28,9 +28,6 @@ function getClient(): Minio.Client | null {
pathStyle: true,
};
// Ne désactiver la vérification TLS que si explicitement demandé.
// `transport` n'expose pas `agent` dans les types récents de minio ; on
// passe par un cast ciblé car le runtime minio accepte toujours cette option.
if (useSSL && allowSelfSigned) {
(clientOpts as unknown as { transport: unknown }).transport = {
agent: new https.Agent({ rejectUnauthorized: false }),
@ -47,6 +44,21 @@ function bucket(): string {
return b;
}
/** Vérifie que le bucket existe, sinon le crée. */
export async function ensureBucket(): Promise<void> {
const client = getClient();
if (!client) return;
const exists = await client.bucketExists(bucket());
if (!exists) {
await client.makeBucket(bucket());
}
}
/** Retourne true si MinIO est configuré et joignable. */
export function isMinioConfigured(): boolean {
return getClient() !== null;
}
export async function uploadFile(file: UploadableFile, folderPath: string): Promise<string> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
@ -54,6 +66,7 @@ export async function uploadFile(file: UploadableFile, folderPath: string): Prom
const fileName = `${Date.now()}-${file.filename}`;
const filePath = `${folderPath}/${fileName}`;
await client.putObject(bucket(), filePath, file.file);
// Retourne le chemin MinIO (pas d'URL pré-signée)
return filePath;
}
@ -75,8 +88,16 @@ export async function listFiles(folderPath: string) {
return client.listObjects(bucket(), folderPath);
}
export async function getFileUrl(filePath: string): Promise<string> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.presignedUrl('GET', bucket(), filePath);
/**
* Construit l'URL publique pour un fichier stocké dans MinIO.
* L'URL passe par la route API /uploads/* du backend qui proxifie MinIO.
*/
export function getPublicUrl(filePath: string): string {
const base = process.env.PUBLIC_BASE_URL || 'http://localhost:3000';
return `${base}/uploads/${filePath}`;
}
// Compat : ancien nom, maintenant retourne l'URL publique (pas pré-signée)
export async function getFileUrl(filePath: string): Promise<string> {
return getPublicUrl(filePath);
}

64
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,64 @@
services:
backend:
build:
context: ./backend
container_name: freedge-backend
restart: always
env_file: ./backend/.env
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=file:/app/data/freedge.db
- CORS_ORIGINS=https://freedge.app
- FRONTEND_URL=https://freedge.app
- PUBLIC_BASE_URL=https://freedge.app/api
- MINIO_ENDPOINT=host.docker.internal
- MINIO_PORT=9000
- MINIO_USE_SSL=false
- MINIO_ACCESS_KEY=admin
- MINIO_SECRET_KEY=Kx9mP2vL7wQn4jRs
- MINIO_BUCKET=freedge
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- db-data:/app/data
- uploads:/app/uploads
expose:
- "3000"
networks:
- freedge
frontend:
build:
context: ./frontend
args:
VITE_API_BASE_URL: https://freedge.app/api
VITE_GOOGLE_CLIENT_ID: 173866668387-i18igc0e1avqtsaqq6nig898bv6pvuk6.apps.googleusercontent.com
container_name: freedge-frontend
restart: always
expose:
- "80"
networks:
- freedge
nginx:
image: nginx:alpine
container_name: freedge-nginx
restart: always
ports:
- "8081:80"
volumes:
- ./nginx-prod.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
networks:
- freedge
volumes:
db-data:
uploads:
networks:
freedge:
driver: bridge

53
frontend/Dockerfile Normal file
View File

@ -0,0 +1,53 @@
FROM node:20-slim AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install
COPY . .
ARG VITE_API_BASE_URL
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN pnpm run build-no-error
RUN chmod -R a+r /app/dist && find /app/dist -type d -exec chmod a+rx {} \;
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
RUN printf 'server {\n\
listen 80;\n\
server_name _;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
# index.html : jamais mis en cache, toujours revalidé\n\
location = /index.html {\n\
add_header Cache-Control "no-cache, no-store, must-revalidate";\n\
add_header Pragma "no-cache";\n\
add_header Expires "0";\n\
}\n\
\n\
# SPA fallback — mêmes headers anti-cache pour le HTML\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
add_header Cache-Control "no-cache, no-store, must-revalidate";\n\
add_header Pragma "no-cache";\n\
}\n\
\n\
# Assets hashés par Vite (ex: index-abc123.js) — cache agressif OK\n\
location /assets/ {\n\
expires 1y;\n\
add_header Cache-Control "public, immutable";\n\
}\n\
\n\
# Autres fichiers statiques (favicon, images, fonts)\n\
location ~* \\.(png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {\n\
expires 7d;\n\
add_header Cache-Control "public";\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -1,23 +1,84 @@
import { useState, useCallback, useEffect } from "react";
import vmsg from "vmsg";
const recorder = new vmsg.Recorder({
wasmURL: "https://unpkg.com/vmsg@0.3.0/vmsg.wasm"
});
import { useState, useCallback, useEffect, useRef } from "react";
/**
* Hook d'enregistrement audio utilisant l'API native MediaRecorder.
*
* Produit un fichier WebM/Opus (ou MP4/AAC sur Safari) directement supporté
* par l'API OpenAI Whisper sans dépendance WASM externe.
*/
export function useAudioRecorder() {
const [isLoading, setIsLoading] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordings, setRecordings] = useState<string[]>([]);
const [currentRecording, setCurrentRecording] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
/** Choisit le meilleur mimeType supporté par le navigateur. */
const getMimeType = useCallback(() => {
const types = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return ""; // fallback : le navigateur choisira
}, []);
/** Extension correspondant au mimeType. */
const getExtension = useCallback((mime: string) => {
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("ogg")) return "ogg";
return "webm";
}, []);
const startRecording = useCallback(async () => {
setIsLoading(true);
try {
// Nécessaire sur mobile : initAudio DOIT être dans un handler utilisateur (tap/click)
await recorder.initAudio();
await recorder.initWorker();
await recorder.startRecording();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100,
},
});
streamRef.current = stream;
chunksRef.current = [];
const mimeType = getMimeType();
const recorder = new MediaRecorder(stream, {
mimeType: mimeType || undefined,
audioBitsPerSecond: 128000,
});
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = () => {
const actualMime = recorder.mimeType || mimeType || "audio/webm";
const blob = new Blob(chunksRef.current, { type: actualMime });
const url = URL.createObjectURL(blob);
setRecordings((prev) => [...prev, url]);
setCurrentRecording(url);
// Arrête les pistes micro
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
mediaRecorderRef.current = recorder;
// timeslice de 250ms pour avoir des chunks réguliers
recorder.start(250);
setIsRecording(true);
} catch (error) {
console.error("Erreur lors du démarrage de l'enregistrement :", error);
@ -25,28 +86,32 @@ export function useAudioRecorder() {
} finally {
setIsLoading(false);
}
}, []);
}, [getMimeType]);
const stopRecording = useCallback(async () => {
if (!isRecording) return;
if (!isRecording || !mediaRecorderRef.current) return;
setIsLoading(true);
try {
const blob = await recorder.stopRecording();
const url = URL.createObjectURL(blob);
return new Promise<{ blob: Blob; url: string }>((resolve) => {
const recorder = mediaRecorderRef.current!;
const mimeType = recorder.mimeType || getMimeType() || "audio/webm";
const ext = getExtension(mimeType);
setRecordings(prev => [...prev, url]);
setCurrentRecording(url);
const originalOnStop = recorder.onstop;
recorder.onstop = (ev) => {
// Appelle le handler de base (qui crée le blob + currentRecording)
if (originalOnStop) originalOnStop.call(recorder, ev);
return { blob, url };
} catch (error) {
console.error("Erreur lors de l'arrêt de l'enregistrement :", error);
throw error;
} finally {
setIsRecording(false);
setIsLoading(false);
}
}, [isRecording]);
const blob = new Blob(chunksRef.current, { type: mimeType });
const url = URL.createObjectURL(blob);
setIsRecording(false);
setIsLoading(false);
resolve({ blob, url });
};
recorder.stop();
});
}, [isRecording, getMimeType, getExtension]);
const toggleRecording = useCallback(async () => {
if (isRecording) {
@ -57,14 +122,18 @@ export function useAudioRecorder() {
}, [isRecording, startRecording, stopRecording]);
const clearRecordings = useCallback(() => {
recordings.forEach(url => URL.revokeObjectURL(url));
recordings.forEach((url) => URL.revokeObjectURL(url));
setRecordings([]);
setCurrentRecording(null);
}, [recordings]);
// Cleanup au démontage
useEffect(() => {
return () => {
recordings.forEach(url => URL.revokeObjectURL(url));
recordings.forEach((url) => URL.revokeObjectURL(url));
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
}
};
}, [recordings]);
@ -76,6 +145,6 @@ export function useAudioRecorder() {
startRecording,
stopRecording,
toggleRecording,
clearRecordings
clearRecordings,
};
}

View File

@ -90,7 +90,10 @@ export default function RecipeForm() {
fetch(currentRecording)
.then((res) => res.blob())
.then((blob) => {
const file = new File([blob], "recording.mp3", { type: "audio/mp3" })
// Détecte le format réel du blob (webm, mp4, ogg…)
const mime = blob.type || "audio/webm"
const ext = mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm"
const file = new File([blob], `recording.${ext}`, { type: mime })
setAudioFile(file)
setPageState("review")
setError("")
@ -140,6 +143,10 @@ export default function RecipeForm() {
const handleStopRecording = async () => {
if (!isRecording) return
if (recordingTime < 2) {
setError("Enregistre au moins 2 secondes pour que le chef puisse t'écouter.")
return
}
await stopRecording()
// Le useEffect sur currentRecording bascule vers 'review'
}
@ -153,6 +160,10 @@ export default function RecipeForm() {
const handleSubmit = async () => {
if (!audioFile) return
if (audioFile.size < 5000) {
setError("L'enregistrement est trop court. Réessaie en parlant un peu plus longtemps.")
return
}
setPageState("processing")
setError("")

23
nginx-prod.conf Normal file
View File

@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
client_max_body_size 20M;
# /api/* → backend Fastify (strip /api prefix)
location /api/ {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://backend:3000;
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 https;
}
# Tout le reste → frontend React (SPA)
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}