feat: production deployment setup with Gitea Actions CI/CD
Some checks failed
Deploy to Production / deploy (push) Failing after 46s

- 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 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-07 23:09:22 +09:00
parent 642514b227
commit 39d2226d95
12 changed files with 434 additions and 85 deletions

22
.env.prod.example Normal file
View File

@ -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

View File

@ -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 <<EOF > .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"

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ yarn-error.log*
# Environment
.env
.env.prod
.env.local
.env.*.local

View File

@ -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 = ""

View File

@ -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=["*"],
)

View File

@ -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

View File

@ -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:

View File

@ -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 <<EOF > .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. 배포 테스트

View File

@ -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

View File

@ -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;

View File

@ -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<SnapshotItem[]>(`/api/portfolios/${portfolioId}/snapshots`),
api.get<ReturnsData>(`/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<SnapshotDetail>(`/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) {

View File

@ -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;