콘텐츠로 이동

nginx로 통합 배포

개발 중에는 Vite dev server(5173)와 FastAPI(8000)를 따로 띄우고 CORS를 허용했다. 프로덕션에서는 이 두 서버를 nginx 하나 뒤에 두어 동일 origin으로 만든다. CORS가 사라지고, 보안이 단순해지며, 클라이언트는 하나의 URL만 알면 된다.

Terminal window
cd frontend
pnpm build

빌드가 완료되면 frontend/dist/ 디렉터리가 생성된다.

frontend/dist/
├── index.html
├── assets/
│ ├── index-Cd3Bk9fA.js # 번들된 JS (해시 포함)
│ └── index-Bx2Kp7mQ.css # 번들된 CSS
└── favicon.ico

dist/ 폴더가 nginx가 서빙할 정적 파일 루트다. index.html은 SPA 진입점으로, 모든 경로 요청에서 이 파일을 반환해야 React Router가 클라이언트 사이드 라우팅을 처리할 수 있다.

# /etc/nginx/nginx.conf 또는 /etc/nginx/conf.d/app.conf
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 로그 포맷
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/javascript application/json;
gzip_min_length 1024;
server {
listen 80;
server_name _;
# 정적 파일 루트 (React 빌드 결과물)
root /usr/share/nginx/html;
index index.html;
# ① API 요청 → FastAPI로 프록시
location /api/ {
proxy_pass http://backend:8000;
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;
# 타임아웃 설정 (ML 추론이 오래 걸릴 수 있음)
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# ② 정적 에셋 (JS/CSS/이미지) — 캐시 최대화
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# ③ SPA fallback — 나머지 모든 경로는 index.html 반환
location / {
try_files $uri $uri/ /index.html;
}
}
}

location /api//api/로 시작하는 모든 요청을 backend:8000으로 포워딩한다. FastAPI 앱의 라우터가 /api/todos처럼 /api 프리픽스를 사용해야 한다. proxy_pass URL 끝의 슬래시 유무에 따라 경로 스트리핑 여부가 달라지므로 주의한다.

try_files $uri $uri/ /index.html — SPA fallback이다. 파일이 존재하면 그 파일을, 없으면 index.html을 반환한다. 이게 없으면 /todos/123 같은 React Router 경로를 새로고침할 때 404가 발생한다.

expires 1y + immutable — Vite가 파일명에 콘텐츠 해시를 포함시키므로(index-Cd3Bk9fA.js) 파일이 바뀌면 이름이 바뀐다. 따라서 최대 캐시 기간을 설정해도 안전하다.

개발 환경에서는 프론트(5173)와 백엔드(8000)의 origin이 달라서 브라우저가 CORS 정책을 적용한다. 프로덕션에서는 두 서버가 동일한 nginx를 통해 노출되므로 브라우저 입장에서는 같은 origin(https://myapp.com)에서 오는 요청이다.

개발: http://localhost:5173 → http://localhost:8000/api/todos (cross-origin!)
프로덕션: https://myapp.com → https://myapp.com/api/todos (same-origin ✓)

따라서 프로덕션 FastAPI 앱에서 CORSMiddleware를 제거하거나, 환경변수로 개발/프로덕션을 분기한다.

main.py
import os
if os.getenv("ENV") == "development":
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
docker-compose.yml
version: "3.9"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- ENV=production
- DATABASE_URL=sqlite:///./data/todos.db
volumes:
- db_data:/app/data
# backend는 외부에 직접 노출하지 않음 (nginx만 알면 됨)
expose:
- "8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile # pnpm build 후 nginx 이미지로 복사
ports:
- "80:80"
depends_on:
- backend
volumes:
db_data:
# frontend/Dockerfile — 멀티스테이지 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
# backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Terminal window
docker-compose up --build
# http://localhost 접속

docker-compose up 하나로 백엔드, 프론트엔드, nginx가 모두 시작된다. nginx 컨테이너 안에 React 빌드 결과물이 포함되어 있고, /api/ 요청은 backend 컨테이너로 내부 네트워크를 통해 전달된다.

항목확인
pnpm build 성공, dist/ 생성 확인
nginx try_files SPA fallback 설정
FastAPI 라우터에 /api 프리픽스
CORS 미들웨어 프로덕션 비활성화
proxy_read_timeout ML 추론 시간 고려
SQLite 볼륨 마운트로 데이터 영속성 확보
정적 에셋 캐시 헤더 설정
  • pnpm builddist/ 폴더를 nginx의 root로 지정하면 React 앱이 정적으로 서빙된다.
  • try_files $uri $uri/ /index.html 이 한 줄이 SPA 라우팅을 가능하게 한다.
  • location /api/ 블록의 proxy_pass로 FastAPI와 단일 origin을 구성하면 CORS가 불필요해진다.
  • 멀티스테이지 Dockerfile + docker-compose로 로컬에서 프로덕션 환경을 그대로 재현할 수 있다.