generated from ttipo/project-base
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4047bd30e7 | |||
| 3029bb20f2 |
@@ -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`)
|
||||
|
||||
@@ -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 공용)·
|
||||
|
||||
@@ -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 가 실패하니 쓰지 않을 때는 주석으로 둔다).
|
||||
|
||||
+20
-1
@@ -59,13 +59,15 @@ REDIS_DATA_DIR=./data/redis
|
||||
#
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||||
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
|
||||
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
|
||||
# (프론트 nginx 는 TLS 를 하지 않고 호스트 포트도 열지 않으므로(다른 서버의 프록시용 구성은 사설 IP 에만) 외부 프록시 없이는 쓸 수 없다 — 1 은 해당 없음)
|
||||
# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
|
||||
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를
|
||||
# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른
|
||||
# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다.
|
||||
# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등).
|
||||
# 넓은 사설 대역을 통째로 넣지 않는다.
|
||||
# 외부 프록시가 다른 서버에 있으면(아래 "리버스 프록시가 다른 서버에 있을 때") 주소 목록으로 —
|
||||
# <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP> (예: 172.16.72.0/24,10.0.1.11). 사설 IP 는 frontend 접근 로그의 첫 칸이다
|
||||
# true·0·false 는 운영에서 기동이 거부된다.
|
||||
# ==========================================
|
||||
BACKEND_API_URL=https://portal.arasystem.it.kr/api
|
||||
@@ -99,6 +101,23 @@ DB_SYNCHRONIZE=false
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (선택 — mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본은 외부 리버스 프록시가 이 호스트에서 external proxy 네트워크로 <PROJECT_NAME>-frontend:8080 에 붙는 구성이다.
|
||||
# 다른 서버의 프록시(예: 같은 VPC 의 Nginx Proxy Manager)가 사설망으로 붙는다면 COMPOSE_FILE 의 주석을 풀고 FRONTEND_PRIVATE_BIND 를 채운다 —
|
||||
# docker-compose.remote-proxy.yml 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 사설 IP 에만 게시한다.
|
||||
# COMPOSE_FILE : compose 파일 목록 (리눅스는 : 로 잇는다). 이 파일에 두면 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# -f 없이 그대로 두 파일을 읽는다 (compose v2.40 · 5.4 에서 확인). 쓰지 않을 때는 주석으로 둔다 —
|
||||
# 빈 값(COMPOSE_FILE=)은 compose 가 파일 목록으로 읽다 실패한다
|
||||
# FRONTEND_PRIVATE_BIND : <이 서버의 사설 IPv4>:<포트> (예: 10.0.1.12:8080 — ip -4 addr 로 본다).
|
||||
# 비어 있으면 compose 가 멈추고, 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 면 frontend 가 뜨지 않는다 (frontend-bind-check)
|
||||
# TRUST_PROXY 는 위 "화면·API 주소" 의 주소 목록으로 바꾼다 — <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>
|
||||
# ⚠️ 게시 주소만으로는 바깥을 막지 못한다 — NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 8080 은 방화벽(ACG)이 프록시 서버의 사설 IP 에서만 받게 한다
|
||||
# ==========================================
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
FRONTEND_PRIVATE_BIND=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
|
||||
@@ -139,6 +139,36 @@ jobs:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
|
||||
docker compose -f docker-compose.yml --env-file .env.production.example config -q
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (docker-compose.remote-proxy.yml — mail/README.md). 게시 주소는 이 단계에서만 준다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q
|
||||
# proxy 네트워크가 빠져야 한다 — 남으면 그 서버에 없는 external 네트워크를 찾다 up 이 멈춘다
|
||||
# (grep -q 는 쓰지 않는다 — 먼저 끝나면 pipefail 셸에서 compose 의 SIGPIPE 가 결과를 뒤집는다)
|
||||
if FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config --networks | grep -x proxy >/dev/null; then
|
||||
echo "::error::docker-compose.remote-proxy.yml 이 proxy 네트워크를 빼지 못했습니다"; exit 1
|
||||
fi
|
||||
# 게시 주소가 없으면 멈춰야 한다 (예시 env 의 FRONTEND_PRIVATE_BIND 는 비어 있다 — 빈 값이면 모든 주소에 열린다)
|
||||
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
|
||||
fi
|
||||
|
||||
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
|
||||
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
|
||||
- name: frontend 게시 주소 확인 (리버스 프록시가 다른 서버에 있을 때)
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
check() {
|
||||
FRONTEND_PRIVATE_BIND="$1" docker compose -p "ci-bind-${{ github.run_id }}" \
|
||||
-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example \
|
||||
run --rm --no-deps -T frontend-bind-check
|
||||
}
|
||||
for value in 10.0.1.12:8080 172.31.255.254:65535 192.168.0.10:18080; do
|
||||
check "$value" || { echo "::error::FRONTEND_PRIVATE_BIND=$value 를 거부했습니다 (받아야 한다)"; exit 1; }
|
||||
done
|
||||
for value in 8080 :8080 0.0.0.0:8080 127.0.0.1:8080 203.0.113.5:8080 172.32.0.1:8080 '[::]:8080' 10.0.1.12:0 10.0.1.12:08080; do
|
||||
if check "$value" 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Dockerfile 검사
|
||||
run: |
|
||||
@@ -161,7 +191,10 @@ jobs:
|
||||
|
||||
- name: 정리
|
||||
if: always()
|
||||
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
run: |
|
||||
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
||||
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
||||
|
||||
error-codes-sync:
|
||||
name: 에러 코드 SSOT 동기화 검사
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ==========================================
|
||||
# 선택 파일 — 리버스 프록시가 다른 서버에 있을 때 (mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본(docker-compose.yml 단독)은 외부 리버스 프록시가 같은 호스트에서 external proxy 네트워크로
|
||||
# <PROJECT_NAME>-frontend:8080 에 붙는다. 이 파일은 프록시가 다른 서버(예: 같은 VPC 의 Nginx Proxy Manager)에 있어
|
||||
# 사설망으로 이 서버의 frontend 에 붙는 구성으로 바꾼다:
|
||||
# - frontend 를 proxy 네트워크에서 뺀다 (이 서버에는 그 네트워크가 없다). edge 로만 backend 에 닿는 것은 그대로다
|
||||
# - frontend 의 8080 을 이 서버의 사설 IP 에만 게시한다 — FRONTEND_PRIVATE_BIND = <사설 IPv4>:<포트>
|
||||
# frontend 의 환경 · nginx 설정(X-Forwarded-For 이어 붙이기 · X-Request-Id · 실시간 알림)은 바꾸지 않는다.
|
||||
#
|
||||
# 쓰는 법 — 루트 .env.production 에 두 줄 (리눅스는 COMPOSE_FILE 을 : 로 잇는다):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
# FRONTEND_PRIVATE_BIND=10.0.1.12:8080
|
||||
# compose 가 --env-file 의 COMPOSE_FILE 을 읽으므로 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# 그대로 두 파일을 읽는다 (-f 를 붙이지 않는다 — 하나라도 빠뜨리면 그 명령만 기본 구성으로 돈다).
|
||||
#
|
||||
# ⚠️ 게시 주소는 바깥을 막는 장치가 아니다. NCP 처럼 공인 IP 를 사설 IP 로 NAT 하는 곳에서는 사설 IP 에 게시한 포트에
|
||||
# 공인 IP 로도 닿는다 — 8080 은 방화벽(NCP 는 ACG)이 프록시 서버의 사설 IP 에서만 받게 한다.
|
||||
# ==========================================
|
||||
services:
|
||||
frontend:
|
||||
# 다른 서버의 프록시가 붙는 자리 — 이 서버의 사설 IPv4 에만 (0.0.0.0 · 빈 값으로 열지 않는다).
|
||||
# 값이 없으면 compose 가 멈추고, <사설 IPv4>:<포트> 꼴이 아니면 아래 frontend-bind-check 가 frontend 를 띄우지 않는다
|
||||
# (값이 포트뿐이면 "8080:8080" 이 되어 모든 주소에 열리기 때문이다)
|
||||
ports: !override
|
||||
- "${FRONTEND_PRIVATE_BIND:?FRONTEND_PRIVATE_BIND 가 비어 있습니다 — 이 서버의 사설 IPv4 와 포트 (예 10.0.1.12:8080) · mail/README.md 의 리버스 프록시가 다른 서버에 있을 때}:8080"
|
||||
# 이 서버에는 외부 프록시의 proxy 네트워크가 없다 — edge(frontend ↔ backend)만 남긴다.
|
||||
# app-network(메일 엔진의 신뢰 대역)에는 기본 구성처럼 붙이지 않는다
|
||||
networks: !override
|
||||
- edge
|
||||
depends_on:
|
||||
frontend-bind-check:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# ----------------------------------------
|
||||
# 게시 주소 확인 — frontend 보다 먼저 한 번 돌고 끝난다 (docker compose ps -a 에 Exited (0))
|
||||
#
|
||||
# compose 의 ${…:?} 는 빈 값만 막는다. 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 는 여기서 막는다 — 실패하면 up 이
|
||||
# "frontend-bind-check didn't complete successfully" 로 멈추고 frontend 는 뜨지 않는다 (까닭: logs frontend-bind-check).
|
||||
# 셸만 쓴다 — 이미 받는 DB 이미지를 빌려 새 이미지 · 버전 고정 자리를 늘리지 않는다. 네트워크 없이 · 비-root · 읽기 전용.
|
||||
# 받는 값: RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16, 앞자리 0 없이) + ":" + 포트 1~65535(앞자리 0 없이)
|
||||
# ----------------------------------------
|
||||
frontend-bind-check:
|
||||
container_name: ${PROJECT_NAME}-frontend-bind-check
|
||||
image: ${DB_IMAGE}
|
||||
user: "65534:65534"
|
||||
network_mode: none
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
restart: "no"
|
||||
environment:
|
||||
- FRONTEND_PRIVATE_BIND=${FRONTEND_PRIVATE_BIND:-}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
v=$$FRONTEND_PRIVATE_BIND
|
||||
o='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
|
||||
p='([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])'
|
||||
case $$v in
|
||||
*[!0-9.:]*) ;;
|
||||
*)
|
||||
if printf '%s\n' "$$v" | grep -Eqx "(10(\.$$o){3}|172\.(1[6-9]|2[0-9]|3[01])(\.$$o){2}|192\.168(\.$$o){2}):$$p"; then
|
||||
echo "frontend-bind-check: frontend 를 $$v 에만 게시한다 (사설 IPv4)"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "frontend-bind-check: FRONTEND_PRIVATE_BIND=\"$$v\" 는 쓸 수 없어 frontend 를 띄우지 않습니다 — <이 서버의 사설 IPv4>:<포트> 만 받습니다 (10.x · 172.16~31.x · 192.168.x, 예 10.0.1.12:8080). 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 는 받지 않습니다 (mail/README.md \"리버스 프록시가 다른 서버에 있을 때\")" >&2
|
||||
exit 1
|
||||
|
||||
networks:
|
||||
# base 의 external proxy 네트워크를 지운다 — 이 서버에 없는 네트워크를 찾다 up 이 멈추지 않게
|
||||
proxy: !reset null
|
||||
+184
-3
@@ -216,14 +216,16 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
사전 조건:
|
||||
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조)
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조 ·
|
||||
프록시가 다른 서버에 있고 사설망으로 붙으면 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망))
|
||||
- `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr))
|
||||
- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등))
|
||||
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
|
||||
- **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
|
||||
- **엔진 한도** — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 ([엔진 한도](#엔진-한도))
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한)
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한).
|
||||
다른 서버의 Nginx Proxy Manager 라면 설정 · `TRUST_PROXY` 는 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
|
||||
rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여
|
||||
@@ -894,7 +896,8 @@ sudo docker compose --env-file .env.production start mail
|
||||
|
||||
## 리버스 프록시와 같은 호스트에 둘 때 (대안)
|
||||
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (1~3 은 문서로만 정리 — 검증하지 않았다)
|
||||
메일 서버를 따로 둘 수 있으면 4 를 권한다 — 프록시와 메일 엔진이 443 을 나눠 쓰지 않는다.
|
||||
|
||||
1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로.
|
||||
2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를
|
||||
@@ -912,6 +915,9 @@ sudo docker compose --env-file .env.production start mail
|
||||
}
|
||||
```
|
||||
3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다.
|
||||
4. **메일 서버를 따로 둔다** — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다.
|
||||
프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신.
|
||||
→ [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
|
||||
⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면
|
||||
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
|
||||
@@ -920,6 +926,181 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
|
||||
|
||||
---
|
||||
|
||||
## 리버스 프록시가 다른 서버에 있을 때 (NCP 등 — 사설망)
|
||||
|
||||
포털의 TLS 를 **다른 서버**의 리버스 프록시(예: 기존 서버의 Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 쓴다)가 끝내고,
|
||||
**같은 VPC 사설망**으로 메일 서버의 frontend(8080)에 넘기는 구성이다 — [위 대안](#리버스-프록시와-같은-호스트에-둘-때-대안)의 4.
|
||||
메일 서버에는 웹메일 스택 전체(mail · backend · db · frontend)를 그대로 올리고 메일 엔진이 그 서버의 443 을 직접 쓴다(ACME TLS-ALPN · MTA-STS).
|
||||
예시 값: 서버 A(기존 — NPM) 사설 IP `10.0.1.11`, 서버 B(메일 — `mx1.arasystem.it.kr`) 사설 IP `10.0.1.12`.
|
||||
|
||||
```
|
||||
portal.arasystem.it.kr (11단계부터 files. 도) → 서버 A 공인 IP mx1.arasystem.it.kr → 서버 B 공인 IP (PTR 도)
|
||||
브라우저 ─TLS─▶ 서버 A (기존) Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께
|
||||
│ http · 같은 VPC 사설망 → 10.0.1.12:8080 (서버 B 의 ACG: 8080 은 10.0.1.11 에서만)
|
||||
▼
|
||||
서버 B (메일 — 새로) docker-compose.yml + docker-compose.remote-proxy.yml
|
||||
<PROJECT_NAME>-frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail
|
||||
mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME
|
||||
```
|
||||
|
||||
- 선택 파일 [`docker-compose.remote-proxy.yml`](../docker-compose.remote-proxy.yml) 이 바꾸는 것은 frontend 하나다 — `proxy` 네트워크에서 빼고(서버 B 에는 없다),
|
||||
8080 을 `FRONTEND_PRIVATE_BIND`(사설 IPv4:포트)에만 게시하고, 그 값을 먼저 보는 `frontend-bind-check` 를 붙인다.
|
||||
frontend 의 환경 · nginx 설정(`X-Forwarded-For` 이어 붙이기 · `X-Request-Id` · 실시간 알림)과 다른 서비스 · 네트워크(edge · app-network)는 기본 구성과 같다.
|
||||
- 서버 A → B 구간은 평문 HTTP 다 — 같은 VPC 사설망 안이라 받아들이고, 그래서 8080 은 서버 A 에서만 받는다(아래 ACG).
|
||||
|
||||
### NCP 쪽 할 일
|
||||
|
||||
| 할 일 | 값 |
|
||||
| --- | --- |
|
||||
| 서버 B 만들기 | 서버 A 와 **같은 VPC** 의 Public 서브넷, 공인 IP 하나, 메모리 8GB (11단계의 ClamAV 까지 같은 서버에 올린다). 다른 VPC 면 Peering 이 있어야 한다 — 이 절은 같은 VPC 만 다룬다 |
|
||||
| VPC 대역 | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) · `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 두 값을 다른 사설 /24 로 바꾸고 mail setup 을 다시 돌린다 |
|
||||
| 서버 B ACG — 인바운드 | TCP 25 · 465 · 993 · 443 ← `0.0.0.0/0` (메일 수신 · 메일 프로그램 · ACME TLS-ALPN · MTA-STS) · TCP 8080 ← **서버 A 사설 IP `/32` 만** · TCP 22 ← 관리 IP 만. 그 밖(80 · 3000 등)은 열지 않는다 |
|
||||
| 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · 53(DNS)은 남긴다 |
|
||||
| 서버 A ACG — 아웃바운드 | 좁혀 두었다면 서버 B 사설 IP 의 TCP 8080 을 더한다 |
|
||||
| Network ACL | 서브넷의 NACL 을 바꿔 두었다면 위 포트를 같게 연다 |
|
||||
| PTR | 서버 B 공인 IP → `mx1.arasystem.it.kr` — 가비아가 아니라 **NCP 고객지원**에 요청한다. 가비아의 `mx1` A 레코드를 먼저 넣는다(정방향 · 역방향이 맞아야 한다 — [PTR](#ptr)) |
|
||||
| DNS (가비아) | `mx1` A → 서버 B 공인 IP · `portal` A → 서버 A 공인 IP(지금 그대로) · `files` A → 서버 A 공인 IP(11단계) |
|
||||
|
||||
- **게시 주소로는 바깥을 막지 못한다** — NCP 의 공인 IP 는 서버 안에 없고 사설 IP 로 옮겨(NAT) 들어와, 사설 IP 에 게시한 8080 에도 공인 IP 로 닿는다.
|
||||
8080 을 막는 것은 ACG 다. 서버 B 의 `ip -4 addr` 에 공인 IP 가 없으면 이 경우다 — 바깥 PC 에서 `curl -m 5 http://<서버 B 공인 IP>:8080/` 이 시간 초과여야 한다.
|
||||
(NCP 의 NAT · ACG 는 NCP 문서 기준이고 이 저장소에서 시험하지 않았다 — 배포 뒤 아래 "확인"으로 본다)
|
||||
- 메일 포트(25 · 465 · 993 · 443)는 기본 구성 그대로 `0.0.0.0` 에 게시한다. 공인 IP 로 들어온 접속이 원래 주소 그대로 보이는지는 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 본다.
|
||||
- 두 서버의 사설 IP 를 바꾸면(서버를 다시 만들면) `FRONTEND_PRIVATE_BIND` · `TRUST_PROXY` · NPM 의 Forward IP · ACG 를 함께 고친다.
|
||||
|
||||
### 서버 B — env 와 실행
|
||||
|
||||
`.env.production`([운영 배포](#운영-배포))에 셋을 넣는다 — 앞의 둘은 예시 env 의 "리버스 프록시가 다른 서버에 있을 때" 칸, `TRUST_PROXY` 는 바꾼다:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml # 리눅스는 : 로 잇는다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 # 서버 B 의 사설 IPv4:포트 (ip -4 addr)
|
||||
TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWORK_SUBNET>,<서버 A 사설 IP> — 아래 "TRUST_PROXY"
|
||||
```
|
||||
|
||||
- **`COMPOSE_FILE` 은 env 파일에 둔다** — compose 가 `--env-file` 의 이 값을 읽으므로 이 문서의 운영 명령(`sudo docker compose --env-file .env.production …`)이 `-f` 없이 그대로 두 파일을 읽는다.
|
||||
`-f` 두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는 `proxy` 네트워크)으로 돈다. `-f` 를 주면 `COMPOSE_FILE` 보다 그쪽이 이긴다.
|
||||
- 그다음은 [운영 배포](#운영-배포) 절차 그대로다. 띄우기 전 · 뒤에 본다:
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production config --networks # edge · app-network 만 — proxy 가 보이면 COMPOSE_FILE 이 먹지 않았다
|
||||
sudo docker port "${PROJECT_NAME}-frontend" # 8080/tcp -> 10.0.1.12:8080 한 줄
|
||||
sudo docker compose --env-file .env.production logs frontend-bind-check
|
||||
```
|
||||
- **게시 주소 확인** — `FRONTEND_PRIVATE_BIND` 가 비어 있으면 compose 가 바로 멈춘다(`required variable FRONTEND_PRIVATE_BIND is missing a value`).
|
||||
값이 있으면 frontend 보다 먼저 `frontend-bind-check`(셸만 쓰는 한 번짜리 컨테이너 — DB 이미지를 빌린다 · 네트워크 없음 · 비-root)가 RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16):포트(1~65535, 앞자리 0 없이)인지 본다.
|
||||
아니면 `up` 이 `service "frontend-bind-check" didn't complete successfully: exit 1` 로 멈추고 frontend 는 뜨지 않는다 — 포트뿐인 값(`8080` · `:8080` — compose 는 모든 주소에 연다) · `0.0.0.0` · `127.0.0.1` · 공인 IP · IPv6.
|
||||
떠 있던 frontend 의 값을 잘못 바꾸면 compose 가 frontend 를 다시 만든 뒤 띄우지 않으므로 포털이 멈춘다(닫힌 쪽) — 값을 고치고 `up -d`.
|
||||
끝나면 멈춰 있어 `ps` 에는 없고 `ps -a` 에 `Exited (0)` 이다. 앱 네트워크에 붙지 않으므로 mail setup 의 구성원 확인과도 상관없다.
|
||||
- 확인 (2026-10-30 — compose v2.40.2(윈도) · 5.4.0(리눅스, CI 잡 이미지), 예시 env + 위 값):
|
||||
- `config` 의 frontend 는 networks `edge` 하나, ports `host_ip: 10.0.1.12` · `published: "8080"` · `target: 8080`, depends_on 에 `frontend-bind-check`(`service_completed_successfully`) —
|
||||
기본 구성과의 차이는 이 셋뿐이고 환경 · healthcheck 는 같다. 맨 위 networks 에 `proxy` 가 없다.
|
||||
- `COMPOSE_FILE` 을 `--env-file` 에만 두고 `-f` 없이 돌린 `config` 가 위와 같았다(윈도는 `;` 로 잇는다). `COMPOSE_PROFILES=mail` 도 그대로 먹었고, `-f docker-compose.yml` 을 주면 기본 구성이 나왔다.
|
||||
- `frontend-bind-check` — `10.0.1.12:8080` · `172.31.255.254:65535` · `192.168.0.10:18080` 은 0, `8080` · `:8080` · `0.0.0.0:8080` · `127.0.0.1:8080` · `203.0.113.5:8080` · `172.32.0.1:8080` · `[::]:8080` · `10.0.1.12:0` · `10.0.1.12:08080` 은 1
|
||||
(`10.0.1.12` · `010.0.1.12:8080` · `10.0.1.12:65536` 은 compose 가 먼저 거절). 잘못된 값의 `up -d` 는 종료 코드 1 · frontend `Created`(시작하지 않음)이고,
|
||||
떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가 `Created` 로 남았다 (시험용 프로젝트 — frontend 대신 alpine).
|
||||
|
||||
### TRUST_PROXY — 서버 A 의 주소 목록
|
||||
|
||||
- 값은 `<EDGE_NETWORK_SUBNET>,<서버 A 사설 IP>` (예 `172.16.72.0/24,10.0.1.11`) — 백엔드의 운영 env 검증이 받는다(2026-10-30 확인).
|
||||
서버 A 사설 IP 는 frontend 가 보는 접속 주소다 — 서버 A 의 NPM 은 도커지만 서버 밖으로는 서버 A 의 사설 IP 로 나간다. 배포 뒤 `sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"` 의 첫 칸으로 확인하고,
|
||||
같은 줄의 X-Forwarded-For(넷째 `"…"` 칸) 끝이 브라우저의 공인 주소인지 본다.
|
||||
- 홉 수 `2` 도 정상 흐름에서는 같은 IP 를 읽지만 "오른쪽에서 두 번째 주소"를 **누가 붙었든** 믿는다. 같은 VPC 의 다른 서버가 8080 에 바로 붙으면(ACG 를 잘못 열었을 때)
|
||||
`X-Forwarded-For` 를 꾸며 백엔드가 그 값을 클라이언트 IP 로 쓴다 — 로그인 실패 한도 · 요청 수 한도가 IP 기준이다. 주소 목록은 서버 A 에서 온 것만 믿어, 다른 곳에서 바로 붙으면 그 서버 자신의 주소로 센다.
|
||||
|
||||
| frontend 가 받은 `X-Forwarded-For` (백엔드가 보는 접속은 edge 의 frontend) | 주소 목록 | 홉 수 `2` |
|
||||
| --- | --- | --- |
|
||||
| 정상 — `203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 브라우저가 꾸밈 — `1.2.3.4, 203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 같은 VPC 의 다른 서버가 8080 에 바로 — `1.2.3.4, 10.0.2.20` | `10.0.2.20` | `1.2.3.4` (꾸민 값) |
|
||||
| 서버 A 의 다른 컨테이너가 8080 에 바로 — `1.2.3.4, 10.0.1.11` | `1.2.3.4` (꾸민 값) | `1.2.3.4` (꾸민 값) |
|
||||
|
||||
(2026-10-30, 개발 백엔드 컨테이너의 Express 5.2.1 · proxy-addr 2.0.8 과 백엔드의 `resolveTrustProxy` 로 확인)
|
||||
- **남는 것** — 서버 A 의 모든 것(다른 사이트의 컨테이너 포함)은 같은 사설 IP 로 나가 NPM 과 가를 수 없다(표의 마지막 줄). 서버 A 는 믿는 쪽에 든다 — 그 서버에 믿지 못할 것을 두지 않는다.
|
||||
NPM 이 사설 대역에서 온 접속의 `X-Real-IP` 를 클라이언트 주소로 믿는 것(같은 VPC 의 다른 서버가 NPM 을 거쳐 꾸밀 수 있다)은 아래 고급 칸으로 끈다.
|
||||
|
||||
### Nginx Proxy Manager 설정
|
||||
|
||||
서버 A 의 NPM 에 Proxy Host 를 하나 만든다. 기본값은 NPM **v2.16.0**(2026-09-24, 지금 최신) 소스로 확인했다 —
|
||||
[`nginx.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/nginx.conf) ·
|
||||
[`proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/proxy.conf) ·
|
||||
[`block-exploits.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/block-exploits.conf) ·
|
||||
[`assets.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/assets.conf) ·
|
||||
[`log-proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/log-proxy.conf) ·
|
||||
템플릿 [`proxy_host.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/proxy_host.conf) ·
|
||||
[`_listen.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/_listen.conf). 서버 A 의 NPM 판이 다르면 같은 파일을 대조한다.
|
||||
|
||||
| 탭 · 칸 | 값 | 까닭 |
|
||||
| --- | --- | --- |
|
||||
| Details › Domain Names | `portal.arasystem.it.kr` | |
|
||||
| Details › Scheme · Forward Hostname / IP · Forward Port | `http` · 서버 B 사설 IP(`10.0.1.12`) · `8080` | `FRONTEND_PRIVATE_BIND` 와 같은 값 |
|
||||
| Details › Cache Assets | **끔** | 켜면 `.js` · `.css` · `.png` 등으로 끝나는 주소를 NPM 이 30분 캐시한다 — 응답의 `Cache-Control` · `Set-Cookie` 를 무시하고 키가 호스트 + 주소뿐이라(로그인한 사람을 가르지 않는다), 그런 주소의 API 가 생기면 남의 응답이 나간다(지금 서명 그림 · 첨부는 id 로 끝난다). 우리 캐시 머리글(`index.html` no-cache · `/assets/` 30일 immutable)도 지운다 |
|
||||
| Details › Block Common Exploits | **끔** | PHP 사이트용 패턴이 메일 검색어에 걸린다 — 아래 표 |
|
||||
| Details › Websockets Support | 끔 | 웹메일은 웹소켓을 쓰지 않는다 (실시간 알림은 SSE — 보통 HTTP 응답) |
|
||||
| Details › Access List | Publicly Accessible | 로그인은 웹메일이 한다 |
|
||||
| SSL › SSL Certificate | Let's Encrypt 새 인증서 (`portal.`) | 서버 A 의 80 으로 HTTP-01 |
|
||||
| SSL › Force SSL | 켬 | |
|
||||
| SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) |
|
||||
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
|
||||
| Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
|
||||
|
||||
```nginx
|
||||
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
|
||||
proxy_read_timeout 150s;
|
||||
# 접근 로그에 검색어 · 토큰(쿼리)이 남지 않게 — NPM 의 proxy 형식은 "$request_uri"(쿼리째)와 Referer 를 남긴다.
|
||||
# 접속 기록은 프론트 nginx 의 접근 로그(쿼리 없음 · 브라우저 주소는 X-Forwarded-For 칸)로 본다
|
||||
access_log off;
|
||||
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
|
||||
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
|
||||
real_ip_header proxy_protocol;
|
||||
```
|
||||
|
||||
- **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 —
|
||||
위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다.
|
||||
- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`).
|
||||
|
||||
v2.16.0 기본값과 이 포털에 필요한 것:
|
||||
|
||||
| 항목 | v2.16.0 기본 | 필요 | 할 일 |
|
||||
| --- | --- | --- | --- |
|
||||
| 본문 한도 | `client_max_body_size 2000m` (http) | `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m) 이상 | 그대로 — 줄이면 그 이상으로 |
|
||||
| 읽기 시간 | `proxy_read_timeout 90s` (http) | 150s 이상(첨부) · 실시간 알림은 25초 ping 보다 길면 된다 | 고급 칸 150s |
|
||||
| 응답 전체 시간 상한 | 없음 | 실시간 알림 1시간 | 그대로 |
|
||||
| 응답 버퍼링 | nginx 기본(켬) — `X-Accel-Buffering` 을 무시하게 한 곳이 없다 | `/api/mail-events` 만 바로 흘려보낸다 | 그대로 — 프론트 nginx 가 그 응답에 `X-Accel-Buffering: no` 를 넘긴다 |
|
||||
| 압축 | `gzip on` · `gzip_types` 없음 → `text/html` 만 | `text/event-stream` 은 압축하지 않는다 | 그대로 — custom `http.conf` 등으로 `gzip_types` 를 넓히지 않는다 |
|
||||
| `Accept-Encoding` | http 단계에 `proxy_set_header Accept-Encoding ""` 가 있지만, Proxy Host 의 `location /` 가 `proxy.conf` 로 머리글을 따로 정해 물려받지 않는다 | | 그대로 — 브라우저의 값이 frontend 까지 가서 JS · CSS · JSON 은 프론트 nginx 가 압축한다(SSE 는 하지 않는다) |
|
||||
| 클라이언트가 끊으면 | `proxy_ignore_client_abort off` | 바로 끊는다 | 그대로 |
|
||||
| `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 |
|
||||
| `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` |
|
||||
| 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` |
|
||||
|
||||
**Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
|
||||
|
||||
| 요청 | 걸리는 패턴 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 메일 검색 API `?q=viagra` (소문자 — `valium` · `xanax` · `cialis` · `cocaine` · `tramadol` · `prozac` · `libido` 등) | 스팸 낱말 | 403 |
|
||||
| `?q=concat(` · `?q=<b>javascript</b>`(`%3C…script…%3E`) | SQL · 스크립트 | 403 |
|
||||
| 검색 화면 새로 고침 — `/mail/search?q=http://example.com` · `?q=../etc` (화면 주소는 `/` · `:` 를 인코딩하지 않는다) | 파일 주입 | 403 |
|
||||
| 같은 검색어의 API(`q=http:%2F%2F…` · `q=..%2F…`) · 한글 검색어 | — | 통과 |
|
||||
|
||||
(2026-10-30, `block-exploits.conf` 를 그대로 넣고 realip · 로그 형식 · 템플릿 차례를 흉내 낸 nginx 1.30.6 으로 확인. 같은 시험에서 사설 주소에서 온 `X-Real-IP: 1.2.3.4` 가 `$remote_addr` 를 바꾸고
|
||||
고급 칸의 `real_ip_header proxy_protocol;` 이 막는 것, 템플릿의 `access_log` 뒤의 `access_log off;` 로 그 Proxy Host 의 로그가 비는 것, `location /` 가 머리글을 따로 정하면 `Accept-Encoding` 이
|
||||
그대로 넘어가는 것도 봤다. 실제 NPM 에는 넣어 보지 않았다)
|
||||
|
||||
### 확인 (배포 뒤)
|
||||
|
||||
```bash
|
||||
# 서버 A 에서 — 사설망으로 닿는다 (200)
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.12:8080/
|
||||
# 바깥 PC 에서 — 공인 IP 의 8080 은 닫혀 있다 (시간 초과)
|
||||
curl -m 5 http://<서버 B 공인 IP>:8080/
|
||||
# 서버 B 에서 — 첫 칸이 서버 A 사설 IP, X-Forwarded-For 칸 끝이 브라우저의 공인 주소
|
||||
sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
|
||||
```
|
||||
|
||||
- 포털에 로그인해 20MB 첨부를 올려 본다(본문 한도 · 150s). 브라우저 개발자 도구 › 네트워크의 `mail-events` 가 200 으로 열린 채 25초마다 `ping` 을 받는다(버퍼링 · 압축 없음).
|
||||
- `https://portal.arasystem.it.kr/api/health` 는 바깥에서 404(`RES_001` JSON)다 — 기본 구성과 같다.
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
|
||||
Reference in New Issue
Block a user