services: # ---------------------------------------- # 1. Frontend (Nginx 정적 호스팅) # ---------------------------------------- frontend: container_name: ${PROJECT_NAME}-frontend build: context: ./${FRONTEND_DIR} dockerfile: Dockerfile args: - BUILD_MODE=${BUILD_MODE} - VITE_API_BASE_URL=${BACKEND_API_URL} expose: - "8080" environment: - NODE_ENV=${APP_ENV} restart: unless-stopped networks: - app-network - proxy depends_on: - backend healthcheck: # localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다 test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"] interval: 30s timeout: 5s retries: 5 start_period: 10s deploy: resources: limits: cpus: '0.5' memory: 512m # ---------------------------------------- # 2. Backend (HTTP API & WebSocket 통합) # ---------------------------------------- backend: container_name: ${PROJECT_NAME}-backend build: context: ./${BACKEND_DIR} dockerfile: Dockerfile expose: - "${BACKEND_PORT}" - "${WEBSOCKET_PORT}" # PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown) init: true # 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초) stop_grace_period: 30s env_file: - ./${BACKEND_DIR}/.env.${APP_ENV} environment: - NODE_ENV=${APP_ENV} - PORT=${BACKEND_PORT} - WEBSOCKET_PORT=${WEBSOCKET_PORT} - CORS_ORIGIN=${CORS_ORIGIN} # 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을 # 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts) - BACKEND_API_URL=${BACKEND_API_URL} # 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true - ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-} - THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000} - THROTTLE_LIMIT=${THROTTLE_LIMIT:-100} - DB_HOST=db - DB_PORT=5432 - DB_USER=${DB_USER} - DB_PASSWORD=${DB_PASSWORD} - DB_NAME=${DB_NAME} - DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false} - UPLOAD_DIR=/app/uploads - UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD} # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 volumes: - ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads restart: unless-stopped networks: - app-network - proxy depends_on: db: condition: service_healthy # redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다 redis: condition: service_healthy required: false healthcheck: # /api/health 는 DB 까지 확인한다 (루트 '/' 는 프로세스가 떠 있는지만 본다) # 업로드 디렉터리에 쓸 수 없으면(호스트 경로 소유권 문제) 사진 업로드가 전부 500 이 되므로 unhealthy 로 드러낸다 # localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다 test: ["CMD-SHELL", "test -w /app/uploads && wget -qO- http://127.0.0.1:${BACKEND_PORT}/api/health || exit 1"] interval: 30s timeout: 5s retries: 5 start_period: 30s deploy: resources: limits: cpus: '1.0' memory: 1g # ---------------------------------------- # 3. Database (PostgreSQL) — 내부 네트워크 전용 # ---------------------------------------- db: container_name: ${PROJECT_NAME}-db image: ${DB_IMAGE} expose: - "5432" environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} volumes: - ${DB_DATA_DIR}:/var/lib/postgresql/data restart: always networks: - app-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 10s timeout: 5s retries: 5 start_period: 10s deploy: resources: limits: cpus: '0.5' memory: 512m labels: - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 4. Redis — 내부 네트워크 전용 · 기본 비활성 # # 기본적으로 기동하지 않는다. 캐시/세션 공유가 실제로 필요해질 때 켠다. # 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=redis # 또는 일회성으로 docker compose --profile redis up -d # ---------------------------------------- redis: profiles: ["redis"] container_name: ${PROJECT_NAME}-redis image: ${REDIS_IMAGE} command: redis-server --requirepass ${REDIS_PASSWORD} expose: - "6379" volumes: - ${REDIS_DATA_DIR}:/data restart: always networks: - app-network healthcheck: test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"] interval: 10s timeout: 5s retries: 5 start_period: 5s deploy: resources: limits: cpus: '0.25' memory: 256m labels: - "com.centurylinklabs.watchtower.enable=true" networks: app-network: driver: bridge proxy: external: true