- 스킬 표: nestjs(제한된 세션 · 2단계 필수 정책 · 받은편지함 알림 · 운영 도구 · 15초 동기화 · 만료 계정의 엔진 사실), vue3(제한된 세션 화면 · 로그인 틀 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 줄), docker(이미지 표 — PostgreSQL · restic · shellcheck, 자동 설정 server, 호스트 systemd 백업 · 감시 절, setup 의 관리자 단단히 · 휴지통 30일, 로그 앵커 규칙, CI infra 잡). - 규칙: security(제한된 세션 · 운영 도구 비밀 · 관리자 계정 · 알림 메일 링크 없음 · 퇴사 마무리 · 백업 비밀 · restic 의 root 예외), logging(컨테이너 로그 보존 · 실행 기록 · 자동 설정 쿼리), env-structure(자동 설정 env · DB_IMAGE · ops.env 예외 · OPS_ALLOW_NO_TTY), error-codes(AUTH_006 ~ AUTH_010), checklist(13단계 운영 준비), release-ops 담당 범위(mail/ · ops/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 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 — 값이 틀리면 기동 중단)
│ FRONTEND_PRIVATE_BIND(프록시가 다른 서버일 때만 — docker-compose.remote-proxy.yml, 사설 IPv4:포트) → 8080 게시
│ SHARE_PUBLIC_URL · CORS_ORIGIN(11단계) → 공개 내려받기 server 이름 · 포털이 공개 경로를 닫는지 (nginx-env.envsh — 모양이 틀리면 기동 중단)
│ MAIL_DOMAIN · MAIL_HOSTNAME(13단계) → 메일 프로그램 자동 설정 server(autoconfig. · autodiscover.) — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단
├─▶ 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 는 붙지 않는다)
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
│ MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP → mail-dns(mail ↔ 메일 전용 DNS 리졸버, internal) 서브넷 · 리졸버 주소 고정
│ (mail 의 dns · 엔진의 DNS 가 이 IP 를 쓴다 — 둘을 함께 바꾸고 setup 을 다시 실행한다)
├─▶ 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 바인드 마운트
├─▶ files : 대용량 첨부(11단계) — backend 에 SHARE_PUBLIC_URL·SHARE_REPORT_ADDRESS·SHARE_DOWNLOAD_LIMIT·MAIL_LARGE_FILE_*·
│ FILE_STORAGE_MAX_TOTAL_GB·FILE_STORAGE_MIN_FREE_GB·CLAMAV_HOST·CLAMAV_PORT (비운 값은 백엔드 기본값)
│ 파일은 named volume <PROJECT_NAME>_files — 개발도. 경로 FILE_STORAGE_DIR=/app/files 는 compose 가 고정한다(루트 env 에 두지 않는다)
│ 악성 검사기 clamav · clamav-updater 는 운영은 늘 뜨고 개발은 COMPOSE_PROFILES 에 clamav 를 넣고 CLAMAV_HOST=clamav 를 줄 때만
├─▶ 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_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상)도 setup · backend 가 함께 읽는다 — 운영 필수, 바꾸면 setup 다시 + backend 다시 띄우기
│ backend 에는 APP_NETWORK_SUBNET 도 넘긴다(웹훅 받는 쪽이 접속 주소가 앱 네트워크인지 본다)
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
BACKEND_PORT · MAIL_WEBHOOK_SECRET 도 읽는다(12단계 — 엔진 웹훅 주소 http://backend:<BACKEND_PORT>/… 와 서명 키, 셸 값과 다르면 멈춘다)
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
규칙:
- 새 설정값은 루트
.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) - 운영 스크립트(
ops/— 호스트 systemd 의 백업 · 점검 · 부팅 되살리기, 13단계)도 예외 — 설정은 루트 env 가 아니라 서버의/etc/arasystem/ops.env·restic-password(root 0600)에 둔다. ops.env: Object Storage 주소 · 키, Healthchecks 주소,PROJECT_NAME·BACKUP_HOST(새 서버 복원 때 필수 — 옛 서버의 값),BACKUP_FINAL_TIMEOUT(메일을 멈춘 final 의 상한, 기본 15m) ·BACKUP_HEALTHY_WAIT(기본 300초). 스크립트는.env.production의PROJECT_NAME·MAIL_HOSTNAME·CORS_ORIGIN·SHARE_PUBLIC_URL·MAIL_ADMIN_PASSWORD·MAIL_SPAMHAUS_DQS_KEY를 읽기만 하고 복사해 두지 않는다.OPS_ALLOW_NO_TTY는 운영 도구(CLI)를 자동 시험에서 파이프로 돌릴 때만docker exec -e로 준다 — env 파일 · compose 에 두지 않는다. - Flutter 는 예외 — docker-compose 대상이 아니므로
flutter/.env에서API_URL을 독립적으로 관리한다. - 루트 env 를 Node 스크립트가 직접 읽을 때(
node --env-file=…) 두 가지를 주의한다.- Node 는 셸에 이미 있는 변수를 덮어쓰지 않는다 — 셸에
APP_ENV=development가 남은 채.env.production을 주면 개발 값으로 돈다. 스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (mail/scripts/setup.mjs의findShellOverrides()). 예외는 복구 관리자MAIL_RECOVERY_ADMIN(원래 셸에서 일회성으로 주는 값이라 검사하지 않는다)과 파일럿 키 둘이다 — 파일럿 키는 개발은 한 번 시험하려고 셸로 줄 수 있고([참고]를 낸다), 운영은 반대로 파일에 없는 파일럿 값이 셸에 있으면 중단한다 (파일럿은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 한다). 운영에서 파일럿을 끄는 것은 값을 비우고--end-pilot을 줄 때뿐이다 — 값이 빠졌다고 저절로 끄지 않는다. Spamhaus 키MAIL_SPAMHAUS_DQS_KEY도 운영은 파일에만 둔다 — 셸에만 있으면 그 실행만 켜지고 다음 실행이 끄므로 중단한다. - Node 는 따옴표 없는 값의
#부터를 공백이 없어도 주석으로 자르고, compose 는 그대로 읽는다. 비밀번호 등은 영문·숫자로만 만든다 (#·따옴표·$·공백 금지).
- Node 는 셸에 이미 있는 변수를 덮어쓰지 않는다 — 셸에
- 모든 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는 거부),SHARE_PUBLIC_URL(대용량 첨부의 공개 내려받기 출처 — https),CLAMAV_HOST(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로),MAIL_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상. 예시 값CHANGEME…는 setup · backend 모두 거부) - 운영 환경:
SHARE_PUBLIC_URL의 호스트를CORS_ORIGIN에 넣지 않는다 — 같은 호스트면 공개 호스트 분리(포털에서 공개 링크를 닫는 것)가 꺼져 기동을 막는다.SHARE_PUBLIC_URL의 모양은 백엔드와 nginx 템플릿(nginx-env.envsh)이 같은 규칙으로 본다 — 출처만(경로 · 끝의/· 끝 점 없이). 한 번 정하면 바꾸지 않는다(보낸 메일의 링크가 그 주소다) - 운영 환경:
change-me·password같은 예시 값이 그대로 배포되는 것을 차단 - 운영 환경:
CORS_ORIGIN·BACKEND_API_URL·SHARE_PUBLIC_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,COMPOSE_PROFILES·COMPOSE_FILE(운영의 remote-proxy 일 때), 각종*_PORT,APP_NETWORK_SUBNET·EDGE_NETWORK_SUBNET·MAIL_RESOLVER_SUBNET/MAIL_RESOLVER_IP,BACKEND_API_URL,CORS_ORIGIN,TRUST_PROXY,FRONTEND_PRIVATE_BIND(remote-proxy 일 때),DB_*/DB_DATA_DIR·DB_IMAGE(13단계 — 판 · digest 고정),REDIS_*/REDIS_DATA_DIR,THROTTLE_TTL_MS/THROTTLE_LIMIT,UPLOAD_MAX_SIZE_MB(·개발 전용UPLOAD_DATA_DIR), 대용량 첨부(11단계)SHARE_PUBLIC_URL(운영 필수 — 포털과 다른 호스트)·SHARE_REPORT_ADDRESS·SHARE_DOWNLOAD_LIMIT·MAIL_LARGE_FILE_MAX_SIZE_MB/_MAX_COUNT/_USER_QUOTA_GB/_RETENTION_DAYS·FILE_STORAGE_MAX_TOTAL_GB/FILE_STORAGE_MIN_FREE_GB·CLAMAV_HOST(운영 필수)/CLAMAV_PORT, 메일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_PILOT_LEGACY_HOST/MAIL_PILOT_LEGACY_IPS(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·MAIL_SPAMHAUS_DQS_KEY(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)·MAIL_WEBHOOK_SECRET(비밀 — 엔진 웹훅 서명 키, setup · backend 공용, 운영 필수 — 만들기openssl rand -hex 24) (개발 전용MAIL_ADMIN_PORT·MAIL_DEV_USER_PASSWORD·MAILPIT_IMAGE/MAILPIT_UI_PORT). 실제 목록은.env.development.example참조. 실행/배포 명령은 docker, 보안 규칙은 security 참조.