Files
arasystem/.claude/rules/env-structure.md
T
ttipo 59420df1b9 feat(docker): mail setup — 사내 목록 계정 · 메일을 받지 않는 시스템 계정의 RCPT 거절
사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다.
그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 —
백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다.

관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter —
위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고,
setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다.
일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:06:27 +09:00

8.6 KiB

name, description
name description
env-structure 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{APP_ENV} 가 모든 설정의 SSOT, docker-compose 가 서비스에 주입. 새 설정값 추가/서비스 env 작성 규칙.

환경변수 구조 (단일 진실 공급원: 루트 .env)

이 템플릿은 루트 .env.{APP_ENV} 가 모든 설정의 단일 진실 공급원(SSOT) 이다. 값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 루트 → docker-compose 가 주입한 값을 그대로 사용한다.

루트 .env.{APP_ENV}
   │  (docker compose --env-file 로 로드)
   ├─▶ backend   : env_file + environment 로 DB/Redis/CORS/포트 주입
   │               (backend/.env.{APP_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 — 값이 틀리면 기동 중단)
   │               (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
   │                vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
   ├─▶ network   : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
   │               EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
   ├─▶ db        : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
   ├─▶ redis     : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
   ├─▶ uploads   : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
   ├─▶ mail      : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
   │               MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART)
   │               운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
   │               MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
   │               MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
   └─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
                   MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다

규칙:

  • 새 설정값은 루트 .env.{APP_ENV} 에 먼저 추가하고, 필요한 서비스에 docker-compose.yml 의 environment / args 로 전달한다.
  • 서비스 자체 env 파일에는 루트에서 내려주지 않는 고유 값만 작성한다. (예: 백엔드 JWT_SECRET)
  • compose 는 정의되지 않은 변수를 빈 문자열로 주입한다. 코드에서 ?? 기본값만 쓰면 Number('')===0 같은 함정에 빠진다 — 숫자 설정값은 shared/utils/env.ts 의 parsePositiveInt() 처럼 빈 문자열까지 방어하는 파서를 거친다. (기존 .env 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
  • 숫자 값은 앞자리 0 없는 10진수로만 쓴다 — 08·+5·5.0·1e3·0x10 은 env 검증이 기동을 막는다 (기간 015m, TRUST_PROXY 홉 수·CIDR 접두도 같다. 규칙은 shared/utils/env.ts 의 isPositiveIntString()). 같은 값을 읽는 nginx 템플릿(frontend/nginx-env.envsh)이 셸 산술 때문에 08 을 거부하므로 백엔드도 같은 규칙을 쓴다 (2026-10-27). nginx 본문 한도가 되는 MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 는 템플릿과 같이 6자리(999999) 이하만 받는다 (백엔드만 뜨고 frontend 가 뜨지 않는 조합을 막는다 — env.validation.ts 의 NGINX_SHARED_INT_KEYS).
  • docker-compose.yml 의 env_file 지시자 때문에 backend/.env.{APP_ENV} 파일은 비어 있어도 존재해야 한다.
  • 프론트엔드 dev 서버는 compose 를 거치지 않는다 — 호스트에서 직접 띄우므로(docker) vite.config.ts 가 루트 .env.{mode} 를 loadEnv 로 읽어 포트·프록시 대상을 정한다. 포트를 frontend/.env 나 vite.config.ts 에 중복 작성하지 말 것 — 루트가 SSOT 다. (읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — VITE_ 없는 값의 번들 노출 금지, security)
  • Flutter 는 예외 — docker-compose 대상이 아니므로 flutter/.env 에서 API_URL 을 독립적으로 관리한다.
  • 루트 env 를 Node 스크립트가 직접 읽을 때(node --env-file=…) 두 가지를 주의한다.
    • Node 는 셸에 이미 있는 변수를 덮어쓰지 않는다 — 셸에 APP_ENV=development 가 남은 채 .env.production 을 주면 개발 값으로 돈다. 스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (mail/scripts/setup.mjs 의 findShellOverrides()).
    • Node 는 따옴표 없는 값의 # 부터를 공백이 없어도 주석으로 자르고, compose 는 그대로 읽는다. 비밀번호 등은 영문·숫자로만 만든다 (#·따옴표·$·공백 금지).
  • 모든 env 파일은 커밋 금지, *.example 만 커밋한다. (.gitignore 의 !*.example 재포함 규칙)
  • 설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast). 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다. 검증은 backend/src/shared/config/env.validation.ts 가 담당하며 ConfigModule.forRoot({ validate }) 에 연결되어 있다.
    • 숫자 항목은 형식 검사 (모든 환경)
    • 운영 환경 필수: DB_HOST/DB_USER/DB_PASSWORD/DB_NAME, JWT_SECRET, MAIL_JMAP_URL/MAIL_DOMAIN/MAIL_GATEWAY_*, CORS_ORIGIN(화면의 공개 주소 — 상태 변경 요청의 출처 검사), TRUST_PROXY(백엔드 앞 프록시 홉 수 — 운영에서 true·0·false 는 거부)
    • 운영 환경: change-me·password 같은 예시 값이 그대로 배포되는 것을 차단
    • 운영 환경: CORS_ORIGIN·BACKEND_API_URL 에 로컬 주소(localhost·127.0.0.1 등)나 예시 도메인(example.com)이 남아 있으면 차단 — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다. 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 ALLOW_LOCALHOST_URL=true 로 건너뛴다 (BACKEND_API_URL 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
    • 문제는 하나씩이 아니라 한 번에 모아서 보여준다 (재기동 반복 방지)
    • 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다

루트 env의 대표 키: PROJECT_NAME, APP_ENV, BUILD_MODE, 각종 *_PORT, APP_NETWORK_SUBNET·EDGE_NETWORK_SUBNET, BACKEND_API_URL, CORS_ORIGIN, TRUST_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 공용)· MAIL_JMAP_URL·MAIL_ATTACHMENT_MAX_SIZE_MB/MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB·MAIL_TRUSTED_NETWORKS(비우면 APP_NETWORK_SUBNET, 그보다 넓으면 setup 실패)· MAIL_BLOCKED_ACCOUNTS(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다) (개발 전용 MAIL_ADMIN_PORT·MAIL_DEV_USER_PASSWORD·MAILPIT_IMAGE/MAILPIT_UI_PORT). 실제 목록은 .env.development.example 참조. 실행/배포 명령은 docker, 보안 규칙은 security 참조.