From 39d2226d95478a5250a3e5a6c4381e26ccbfe516 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Sat, 7 Feb 2026 23:09:22 +0900 Subject: [PATCH] feat: production deployment setup with Gitea Actions CI/CD - Remove nginx from docker-compose.prod.yml (NPM handles reverse proxy) - Add Next.js rewrites to proxy /api/* to backend (backend fully hidden) - Bind frontend to 127.0.0.1:3000 only (NPM proxies externally) - Replace hardcoded localhost:8000 in history page with api client - Make CORS origins configurable via environment variable - Restrict CORS methods to GET/POST/PUT/DELETE - Add Gitea Actions deploy workflow with secrets-based env management - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Add BACKEND_URL build arg to frontend Dockerfile for standalone builds Co-Authored-By: Claude Opus 4.6 --- .env.prod.example | 22 ++ .gitea/workflows/deploy.yml | 56 ++++ .gitignore | 1 + backend/app/core/config.py | 3 + backend/app/main.py | 8 +- docker-compose.prod.yml | 28 +- docker-compose.yml | 2 +- ...2026-02-07-production-deployment-design.md | 301 ++++++++++++++++++ frontend/Dockerfile | 2 + frontend/next.config.ts | 22 +- .../src/app/portfolio/[id]/history/page.tsx | 72 +---- frontend/src/lib/api.ts | 2 +- 12 files changed, 434 insertions(+), 85 deletions(-) create mode 100644 .env.prod.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 docs/plans/2026-02-07-production-deployment-design.md diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..a70bb77 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,22 @@ +# Galaxis-Po Production Environment Variables +# These values should be set as Gitea Secrets, not in this file. +# This file is a reference template only. + +# Database +DB_USER=galaxy +DB_PASSWORD=your_strong_password_here +DB_NAME=galaxy_po + +# JWT Authentication (generate with: openssl rand -hex 32) +JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters + +# Korea Investment & Securities OpenAPI +KIS_APP_KEY=your_kis_app_key +KIS_APP_SECRET=your_kis_app_secret +KIS_ACCOUNT_NO=your_account_number + +# DART OpenAPI (Financial Statements) +DART_API_KEY=your_dart_api_key + +# CORS (comma-separated origins, used by backend) +CORS_ORIGINS=https://your-domain.com diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..10973dc --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to Production + +on: + push: + branches: [master] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create .env.prod from secrets + run: | + cat < .env.prod + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_NAME=${{ secrets.DB_NAME }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + KIS_APP_KEY=${{ secrets.KIS_APP_KEY }} + KIS_APP_SECRET=${{ secrets.KIS_APP_SECRET }} + KIS_ACCOUNT_NO=${{ secrets.KIS_ACCOUNT_NO }} + DART_API_KEY=${{ secrets.DART_API_KEY }} + CORS_ORIGINS=${{ secrets.CORS_ORIGINS }} + EOF + + - name: Deploy with Docker Compose + run: | + DEPLOY_DIR=~/docker/galaxis-po + + mkdir -p "$DEPLOY_DIR" + + rsync -av --delete \ + --exclude '.git' \ + --exclude 'node_modules' \ + --exclude '__pycache__' \ + --exclude '.venv' \ + --exclude '.next' \ + ./ "$DEPLOY_DIR/" + + cd "$DEPLOY_DIR" + + docker compose -f docker-compose.prod.yml build + docker compose -f docker-compose.prod.yml down + docker compose -f docker-compose.prod.yml up -d + + - name: Health check + run: | + echo "Waiting for services to start..." + sleep 15 + docker compose -f ~/docker/galaxis-po/docker-compose.prod.yml ps + curl -sf http://127.0.0.1:3000 > /dev/null || { echo "Frontend: FAILED"; exit 1; } + echo "Frontend: OK" + docker exec galaxis-po-backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || { echo "Backend: FAILED"; exit 1; } + echo "Backend: OK" diff --git a/.gitignore b/.gitignore index 8175330..fbf11c6 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ yarn-error.log* # Environment .env +.env.prod .env.local .env.*.local diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a24bc27..ac6d36c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -18,6 +18,9 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 # 24 hours + # CORS + cors_origins: str = "http://localhost:3000" + # External APIs kis_app_key: str = "" kis_app_secret: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index 9e106f2..cbc0794 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,11 +54,15 @@ app = FastAPI( lifespan=lifespan, ) +from app.core.config import get_settings + +_settings = get_settings() + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000"], + allow_origins=[origin.strip() for origin in _settings.cors_origins.split(",") if origin.strip()], allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], ) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index efabd83..1618d8c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,5 @@ # Production Docker Compose -# Usage: docker-compose -f docker-compose.prod.yml up -d +# Usage: docker compose -f docker-compose.prod.yml up -d services: postgres: @@ -33,6 +33,12 @@ services: depends_on: postgres: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + start_period: 10s + retries: 3 restart: always networks: - galaxy-net @@ -44,7 +50,9 @@ services: target: production container_name: galaxis-po-frontend environment: - NEXT_PUBLIC_API_URL: ${API_URL} + BACKEND_URL: http://backend:8000 + ports: + - "127.0.0.1:3000:3000" depends_on: backend: condition: service_healthy @@ -52,22 +60,6 @@ services: networks: - galaxy-net - nginx: - image: nginx:alpine - container_name: galaxis-po-nginx - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - ./certs:/etc/nginx/certs:ro - depends_on: - - frontend - - backend - restart: always - networks: - - galaxy-net - volumes: postgres_data: driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 4293c54..4ad4617 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: target: development container_name: galaxis-po-frontend environment: - NEXT_PUBLIC_API_URL: http://localhost:8000 + BACKEND_URL: http://backend:8000 ports: - "3000:3000" depends_on: diff --git a/docs/plans/2026-02-07-production-deployment-design.md b/docs/plans/2026-02-07-production-deployment-design.md new file mode 100644 index 0000000..0079eab --- /dev/null +++ b/docs/plans/2026-02-07-production-deployment-design.md @@ -0,0 +1,301 @@ +# Galaxis-Po 프로덕션 배포 설계 + +## 개요 + +Galaxis-Po를 클라우드 VM 서버에 프로덕션 배포하기 위한 종합 설계. +Gitea와 동일 서버에 배포하며, 외부 노출 포트는 56978 단일 포트(NPM에서 관리). + +## 환경 정보 + +- **서버**: 클라우드 VM (Gitea와 동일 서버) +- **리버스 프록시**: Nginx Proxy Manager (jlesage/nginx-proxy-manager, Docker) +- **CI/CD**: Gitea Actions (Docker 컨테이너 Runner, Docker Socket 마운트) +- **도메인**: SSL 적용 (NPM에서 Let's Encrypt) +- **사용 범위**: 개인 전용 +- **배포 경로**: `~/docker/galaxis-po` + +## 1. 아키텍처 + +``` +인터넷 + │ + ▼ (포트 56978, SSL) +┌──────────────────────────────────────────┐ +│ Nginx Proxy Manager (기존) │ +│ domain.com → 127.0.0.1:3000 │ +└──────┬───────────────────────────────────┘ + │ +┌──────▼───────────────────────────────────┐ +│ docker-compose (galaxis-po) │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ frontend (Next.js) :3000 │ │ +│ │ ┌───────────────────────┐ │ │ +│ │ │ /api/* → rewrites to │──┼──┐ │ +│ │ │ backend:8000 │ │ │ │ +│ │ └───────────────────────┘ │ │ │ +│ └──────────────┬──────────────┘ │ │ +│ 127.0.0.1:3000 (호스트 노출) │ │ +│ │ │ +│ ┌────────────────────────────┐ │ │ +│ │ backend (FastAPI) :8000 │◄──┘ │ +│ │ (내부 네트워크만) │ │ +│ └──────────┬─────────────────┘ │ +│ │ │ +│ ┌──────────▼─────────────────┐ │ +│ │ postgres :5432 │ │ +│ │ (내부 네트워크만) │ │ +│ └────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +### 핵심 설계 결정 + +- **Nginx 컨테이너 제거**: NPM이 리버스 프록시 + SSL 종료 담당 +- **Next.js rewrites로 API 프록시**: 브라우저 → frontend → backend. Backend 완전 은닉. +- **호스트 포트 노출**: frontend의 3000번만 `127.0.0.1:3000`으로 노출 (NPM이 프록시) +- **Backend/DB**: Docker 내부 네트워크만 사용, 외부 포트 노출 없음 + +## 2. docker-compose.prod.yml 변경 + +### 변경 사항 +1. **nginx 서비스 제거** (NPM 사용) +2. **frontend**: `ports: "127.0.0.1:3000:3000"` 추가 +3. **backend**: 포트 비노출 유지 +4. **postgres**: 포트 비노출 유지 +5. **env_file**: Gitea Secrets에서 생성 +6. **restart**: `always` 유지 + +### 수정된 docker-compose.prod.yml + +```yaml +services: + postgres: + image: postgres:18-alpine + container_name: galaxis-po-db + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + restart: always + networks: + - galaxy-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: galaxis-po-backend + env_file: + - .env.prod + environment: + DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME} + PYTHONPATH: /app + depends_on: + postgres: + condition: service_healthy + restart: always + networks: + - galaxy-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: production + container_name: galaxis-po-frontend + ports: + - "127.0.0.1:3000:3000" + depends_on: + backend: + condition: service_healthy + restart: always + networks: + - galaxy-net + +volumes: + postgres_data: + driver: local + +networks: + galaxy-net: + driver: bridge +``` + +## 3. Next.js API 프록시 설정 + +### next.config.ts + +```typescript +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://backend:8000/api/:path*", + }, + ]; + }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, + ]; + }, +}; + +export default nextConfig; +``` + +### api.ts 변경 + +```typescript +// 변경 전 +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + +// 변경 후 +const API_URL = ''; // 같은 도메인의 /api/* 로 요청 (Next.js rewrites 사용) +``` + +### history/page.tsx 하드코딩 제거 + +`http://localhost:8000` 하드코딩을 `api` 클라이언트 사용으로 변경. + +## 4. Gitea Actions CI/CD + +### .gitea/workflows/deploy.yml + +```yaml +name: Deploy to Production +on: + push: + branches: [master] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create .env.prod from secrets + run: | + cat < .env.prod + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_NAME=${{ secrets.DB_NAME }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + KIS_APP_KEY=${{ secrets.KIS_APP_KEY }} + KIS_APP_SECRET=${{ secrets.KIS_APP_SECRET }} + KIS_ACCOUNT_NO=${{ secrets.KIS_ACCOUNT_NO }} + DART_API_KEY=${{ secrets.DART_API_KEY }} + EOF + + - name: Deploy with Docker Compose + run: | + # 배포 디렉토리로 코드 동기화 + rsync -av --delete \ + --exclude '.git' \ + --exclude 'node_modules' \ + --exclude '__pycache__' \ + ./ ~/docker/galaxis-po/ + + cd ~/docker/galaxis-po + + # 빌드 및 배포 + docker compose -f docker-compose.prod.yml build + docker compose -f docker-compose.prod.yml down + docker compose -f docker-compose.prod.yml up -d + + - name: Health check + run: | + sleep 15 + docker compose -f ~/docker/galaxis-po/docker-compose.prod.yml ps + # Frontend 헬스체크 + curl -f http://127.0.0.1:3000 || echo "Frontend health check failed" +``` + +### Gitea Secrets 설정 필요 항목 + +Gitea 웹 UI → Settings → Secrets 에서 다음 항목 등록: + +| Secret Name | 설명 | +|-------------|------| +| `DB_USER` | PostgreSQL 사용자명 | +| `DB_PASSWORD` | PostgreSQL 비밀번호 (강력한 비밀번호) | +| `DB_NAME` | 데이터베이스명 | +| `JWT_SECRET` | JWT 서명 키 (최소 32자 랜덤 문자열) | +| `KIS_APP_KEY` | 한국투자증권 API 키 | +| `KIS_APP_SECRET` | 한국투자증권 API 시크릿 | +| `KIS_ACCOUNT_NO` | 한국투자증권 계좌번호 | +| `DART_API_KEY` | DART 공시 API 키 | + +## 5. 보안 취약점 수정 + +### P0 - 반드시 수정 (배포 전) + +| # | 항목 | 현재 상태 | 수정 내용 | +|---|------|-----------|-----------| +| 1 | `.env` git 노출 | DB 비밀번호가 git에 커밋됨 | `.gitignore`에 추가, git history에서 제거, 비밀번호 변경 | +| 2 | nginx 서비스 제거 | docker-compose.prod.yml이 참조하지만 nginx.conf 없음 | Nginx 서비스 제거 (NPM 사용) | +| 3 | CORS 하드코딩 | `localhost:3000`만 허용 | Next.js 프록시 사용으로 CORS 이슈 해소, 환경변수로 관리 | +| 4 | `history/page.tsx` | `http://localhost:8000` 직접 참조 | api 클라이언트 사용으로 변경 | +| 5 | `next.config.ts` | 비어있음 | rewrites + 보안 헤더 추가 | +| 6 | `api.ts` API_URL | `NEXT_PUBLIC_`으로 백엔드 URL 노출 | 상대 경로(`''`)로 변경 | +| 7 | `alembic.ini` | dev 비밀번호 하드코딩 | env.py에서 환경변수 사용 확인 | + +### P1 - 권장 수정 (배포 후 조기) + +| # | 항목 | 설명 | +|---|------|------| +| 1 | 회원가입 비활성화 | 개인 전용이므로 `/register` 엔드포인트 비활성화 | +| 2 | `config.py` 기본값 | 프로덕션에서 필수값 미설정 시 에러 발생하도록 변경 | +| 3 | CORS methods | `allow_methods=["*"]` → 필요한 메서드만 명시 | + +### P2 - 나중에 고려 + +| # | 항목 | 설명 | +|---|------|------| +| 1 | JWT HttpOnly 쿠키 | localStorage XSS 취약점 (개인용이므로 낮은 위험) | +| 2 | Rate limiting | NPM 뒤에 있으므로 급하지 않음 | +| 3 | RBAC | 사용자 1명이므로 불필요 | + +## 6. NPM 설정 가이드 + +Nginx Proxy Manager 웹 UI에서: + +1. **Proxy Hosts → Add Proxy Host** +2. **Domain Names**: `your-domain.com` +3. **Scheme**: `http` +4. **Forward Hostname/IP**: `127.0.0.1` (또는 호스트 IP) +5. **Forward Port**: `3000` +6. **SSL**: Request a new SSL Certificate → Force SSL → HSTS 활성화 +7. **Advanced**: 필요 시 WebSocket 지원 활성화 + +## 7. 구현 순서 + +1. P0 보안 취약점 수정 (코드 변경) +2. docker-compose.prod.yml 수정 +3. next.config.ts 수정 (rewrites + 보안 헤더) +4. api.ts + history/page.tsx 수정 +5. .gitea/workflows/deploy.yml 생성 +6. .env.prod.example 생성 +7. .gitignore 업데이트 +8. Gitea Secrets 등록 (서버에서) +9. NPM Proxy Host 설정 (서버에서) +10. 배포 테스트 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 335016f..014f9b0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -19,6 +19,8 @@ CMD ["npm", "run", "dev"] FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG BACKEND_URL=http://backend:8000 +ENV BACKEND_URL=${BACKEND_URL} RUN npm run build # Production stage diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..3a7bed1 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,27 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.BACKEND_URL || "http://localhost:8000"}/api/:path*`, + }, + ]; + }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/frontend/src/app/portfolio/[id]/history/page.tsx b/frontend/src/app/portfolio/[id]/history/page.tsx index aec5424..c163792 100644 --- a/frontend/src/app/portfolio/[id]/history/page.tsx +++ b/frontend/src/app/portfolio/[id]/history/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { api } from '@/lib/api'; interface SnapshotItem { id: number; @@ -59,31 +60,18 @@ export default function PortfolioHistoryPage() { const fetchData = async () => { try { - const token = localStorage.getItem('token'); - if (!token) { - router.push('/login'); - return; - } - - const headers = { Authorization: `Bearer ${token}` }; - - const [snapshotsRes, returnsRes] = await Promise.all([ - fetch(`http://localhost:8000/api/portfolios/${portfolioId}/snapshots`, { headers }), - fetch(`http://localhost:8000/api/portfolios/${portfolioId}/returns`, { headers }), - ]); - - if (!snapshotsRes.ok || !returnsRes.ok) { - throw new Error('Failed to fetch data'); - } - const [snapshotsData, returnsData] = await Promise.all([ - snapshotsRes.json(), - returnsRes.json(), + api.get(`/api/portfolios/${portfolioId}/snapshots`), + api.get(`/api/portfolios/${portfolioId}/returns`), ]); setSnapshots(snapshotsData); setReturns(returnsData); } catch (err) { + if (err instanceof Error && err.message === 'API request failed') { + router.push('/login'); + return; + } setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); @@ -100,23 +88,7 @@ export default function PortfolioHistoryPage() { setError(null); try { - const token = localStorage.getItem('token'); - const res = await fetch( - `http://localhost:8000/api/portfolios/${portfolioId}/snapshots`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.detail || 'Failed to create snapshot'); - } - + await api.post(`/api/portfolios/${portfolioId}/snapshots`); await fetchData(); } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); @@ -127,19 +99,7 @@ export default function PortfolioHistoryPage() { const handleViewSnapshot = async (snapshotId: number) => { try { - const token = localStorage.getItem('token'); - const res = await fetch( - `http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`, - { - headers: { Authorization: `Bearer ${token}` }, - } - ); - - if (!res.ok) { - throw new Error('Failed to fetch snapshot'); - } - - const data = await res.json(); + const data = await api.get(`/api/portfolios/${portfolioId}/snapshots/${snapshotId}`); setSelectedSnapshot(data); } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); @@ -150,19 +110,7 @@ export default function PortfolioHistoryPage() { if (!confirm('이 스냅샷을 삭제하시겠습니까?')) return; try { - const token = localStorage.getItem('token'); - const res = await fetch( - `http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - } - ); - - if (!res.ok) { - throw new Error('Failed to delete snapshot'); - } - + await api.delete(`/api/portfolios/${portfolioId}/snapshots/${snapshotId}`); setSelectedSnapshot(null); await fetchData(); } catch (err) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 32a290f..3d30634 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; class ApiClient { private baseUrl: string;