feat(docker): 리버스 프록시가 다른 서버에 있을 때 — frontend 를 사설 IP 에만 게시하는 선택 파일 #6

Merged
ttipo merged 5 commits from feat/deploy-remote-proxy into main 2026-10-30 19:55:49 +09:00
3 changed files with 16 additions and 4 deletions
Showing only changes of commit 4047bd30e7 - Show all commits
+2 -1
View File
@@ -9,7 +9,8 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1. 프록시를 다른 프로젝트와 함께 쓰거나 다른 서버에 두면
주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 IP>` — [[docker]] "외부 리버스 프록시 요구사항")도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
+3 -1
View File
@@ -16,6 +16,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
│ FRONTEND_PRIVATE_BIND(프록시가 다른 서버일 때만 — docker-compose.remote-proxy.yml, 사설 IPv4:포트) → 8080 게시
├─▶ compose : COMPOSE_PROFILES · COMPOSE_FILE 도 --env-file 에서 읽는다 (운영의 remote-proxy 는 COMPOSE_FILE 로 — 빈 COMPOSE_FILE= 은 실패하니 주석으로)
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
@@ -72,7 +74,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, `COMPOSE_PROFILES`·`COMPOSE_FILE`(운영의 remote-proxy 일 때), 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, `FRONTEND_PRIVATE_BIND`(remote-proxy 일 때),
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
+11 -2
View File
@@ -115,13 +115,17 @@ sudo docker compose --env-file .env.production logs -f backend
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
frontend 는 TLS 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
`.env.production` 의 `COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml` 로 켜서 운영 명령은 그대로 쓴다. 공인 IP 를 사설 IP 로 NAT 하는 곳에서는
게시 주소로 바깥을 막지 못하므로 8080 은 방화벽(NCP ACG)이 프록시 서버의 사설 IP 에서만 받게 한다 — 절차 · NPM 설정은 mail/README.md "리버스 프록시가 다른 서버에 있을 때"):
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
(로그인 실패 한도가 IP 기준이다).
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
**프록시가 다른 서버에 있으면** 주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다).
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
@@ -257,6 +261,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
@@ -441,9 +447,12 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
운영 frontend 도 열지 않는다 — 프록시가 다른 서버에 있을 때만 8080 을 사설 IP 에 연다(`docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 사설 IPv4:포트가 아니면 frontend 를 띄우지 않는다).
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`
(프록시가 다른 서버에 있으면 `.env.production` 의 `COMPOSE_FILE` 로 `docker-compose.remote-proxy.yml` 을 더한다 — compose 가 `--env-file` 의 `COMPOSE_FILE` · `COMPOSE_PROFILES` 를 읽는다.
빈 `COMPOSE_FILE=` 은 compose 가 실패하니 쓰지 않을 때는 주석으로 둔다).