# Sammo Core2026 Docker deployment 이 저장소는 새 호스트에서 `.env` 하나로 Core2026 Gateway, PostgreSQL, Redis, Caddy와 Git/PM2 release-controller를 기동하기 위한 공개 배포 골격입니다. 게임 profile은 관리자 화면에서 각각 다른 branch 또는 commit을 선택해 DB 유지 배포, DB 초기화 배포와 rollback을 수행합니다. ## 현재 도메인 배치 상위 `sam_rebuild` 작업공간에서 어느 Docker 환경을 확인할지는 `docs/docker-environment-routing.md`를 먼저 사용합니다. 이 저장소의 Compose는 로컬 E2E 또는 새 호스트 배포 골격이며, 실행 중인 `dev-sam2026.hided.net`과 `sam.hided.net` 운영 stack의 호스트 전용 overlay를 직접 소유하지 않습니다. `dev-sam2026.hided.net`은 실제 외부 Core2026 서비스입니다. 이 저장소로 띄운 로컬 E2E Docker stack은 `dev-sam-e2e.hided.net`을 사용하며, 외부 Caddy가 TLS를 종료한 뒤 `172.30.1.54:14999`의 HTTP listener로 호스트 전체를 전달합니다. Docker Caddy가 `/gateway/`, `/che/`, `/hwe/` 및 `/image/*`를 내부 서비스와 자산으로 분기합니다. `sam.hided.net`의 `/gateway/`와 일곱 profile prefix는 `ssh serv`의 별도 `core2026-sam-production` project가 소유합니다. 로컬 E2E 성공이나 `dev-sam2026.hided.net`의 release 상태를 `sam.hided.net` 반영 근거로 사용하지 않습니다. E2E `.env`의 비밀이 아닌 ingress 값은 다음과 같습니다. ```dotenv DOMAIN=dev-sam-e2e.hided.net PUBLIC_SCHEME=https CADDY_SITE_ADDRESS=http://dev-sam-e2e.hided.net HTTP_PORT=14999 ``` 외부 Caddy는 원래 `Host` header와 path prefix를 보존해야 합니다. 외부 확인은 `https://dev-sam-e2e.hided.net/gateway/api/healthz`를 사용합니다. `HTTPS_PORT`는 외부 proxy가 사용하지 않지만 Compose port 충돌을 피할 별도 값으로 둡니다. Compose는 `DOMAIN`과 `PUBLIC_SCHEME`으로 `GATEWAY_PUBLIC_URL`과 `KAKAO_REDIRECT_URI`를 만듭니다. 외부 route만 바꾸면 이미 실행 중인 runtime의 process 환경은 바뀌지 않으므로, 도메인을 변경한 뒤 runtime과 Caddy container를 재생성하고 OAuth 시작 응답이 `https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 사용하는지 확인합니다. ## 빠른 시작 Docker Engine과 Compose plugin이 설치된 Linux 호스트에서 다음을 실행합니다. ```sh cp .env.example .env # .env의 domain, Core2026 URL, Kakao key와 모든 비밀값을 교체합니다. ./scripts/check.sh docker compose up -d --build --wait docker compose ps ``` DNS의 `DOMAIN` A/AAAA record가 호스트를 가리키고 80/443 TCP 및 443 UDP가 열려 있으면 Caddy가 인증서를 자동 발급합니다. Kakao Developers에는 `https://DOMAIN/gateway/oauth/callback`을 redirect URI로 등록합니다. 첫 기동은 Core2026 clone, frozen-lockfile install, build와 Gateway migration 때문에 수 분이 걸릴 수 있습니다. 외부 reverse proxy가 TLS를 종료하고 이 stack의 HTTP port로 전달한다면 `PUBLIC_SCHEME=https`, `CADDY_SITE_ADDRESS=http://DOMAIN`을 함께 설정합니다. 예를 들어 host `14999`로 전달할 때는 `HTTP_PORT=14999`로 두고, 사용하지 않는 `HTTPS_PORT`는 충돌하지 않는 별도 host port로 지정합니다. 외부 proxy는 원래 `Host` header를 보존해야 합니다. runtime은 기본적으로 memory/swap 각각 4 GiB, CPU 4개, PID 512개 상한을 가집니다. 이 상한은 Gateway와 여러 profile의 PM2 backend process에만 적용되며 release build는 별도 builder service의 상한을 사용합니다. 호스트 용량에 맞춰 `RUNTIME_*_LIMIT`을 조정할 수 있지만 0 또는 무제한으로 두지 않습니다. PM2 process/restart 수가 예상보다 증가하면 로그 수집보다 runtime 중지가 우선입니다. runtime Node process는 기본 heap 1536 MiB, `RAYON_NUM_THREADS=1` 안에서 실행됩니다. `RUNTIME_NODE_OPTIONS`의 heap 상한은 512~2048 MiB, `RUNTIME_RAYON_NUM_THREADS`는 1~4만 허용됩니다. 턴 데몬만 더 큰 heap이 필요하면 `TURN_DAEMON_NODE_OPTIONS`를 512~4096 MiB 범위에서 지정합니다. 이 값은 Gateway orchestrator가 생성하는 turn-daemon PM2 process에만 `NODE_OPTIONS`로 적용되며 API·worker는 계속 `RUNTIME_NODE_OPTIONS`를 사용합니다. 큰 값을 쓰기 전에 `RUNTIME_MEMORY_LIMIT`과 동일한 swap 상한을 함께 늘리고 실제 container 총사용량을 확인합니다. `scripts/check.sh`는 example placeholder, 짧은 비밀값, 잘못된 domain/email, repository credential 조합을 실제 값 출력 없이 거부합니다. 최소 길이는 DB·Redis password 24자, game/bootstrap token 32자, 최초 관리자 password 16자입니다. 배포 전 무작위 값은 예를 들어 `openssl rand -base64 36`으로 생성합니다. `INITIAL_ADMIN_*`은 사용자 table이 비어 있을 때만 superuser를 한 번 생성합니다. 그 뒤 `/gateway/admin/server-operations`에서 `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, `hwe`마다 source branch/commit을 독립 선택합니다. `DB 유지 배포`는 현 시즌을 보존하고, `DB 초기화 배포`는 scenario를 다시 seed합니다. Gateway 자체의 배포와 이전 commit rollback은 같은 화면의 별도 release 영역에서 처리됩니다. 초기 profile의 `:default`에서 `default`는 현재 scenario가 아니라 변경되지 않는 인스턴스 키입니다. 현재 scenario는 DB 초기화 배포가 성공한 뒤 Gateway DB의 별도 필드에 기록됩니다. 이미지 서비스 연동에는 서버 간 접근 URL `IMAGE_SERVICE_URL`, 브라우저 공개 URL `IMAGE_PUBLIC_URL`과 서로 다른 두 secret 파일이 필요합니다. 업로드 secret은 Gateway 전용 아이콘과 game-api 편집기 첨부에만 사용하고, sync secret은 Gitea webhook 누락 시 `docker compose exec runtime pnpm sync:image`로 현재 이미지 branch의 fast-forward를 요청할 때만 사용합니다. 두 파일은 runtime에 read-only로 mount되며 원문은 환경 변수나 브라우저 bundle에 들어가지 않습니다. `IMAGE_PUBLIC_URL`의 기본값은 `https://sam-image.hided.net`입니다. runtime은 이를 Gateway·game frontend의 공개 이미지 origin과 사용자 아이콘 URL에 함께 주입합니다. 정적 게임 이미지는 `/game`, 공용 아이콘은 `/icons`, Core2026 사용자 아이콘은 서버 발급 경로 `users/core2026/<파일>`을 `/icons` base 아래에 붙여 읽습니다. 업로드 권한값은 URL query나 `VITE_*`가 아니라 `IMAGE_UPLOAD_CORE2026_SECRET_FILE`이 가리키는 mode 0600 secret 파일에만 둡니다. ## 데이터와 복구 경계 - PostgreSQL, Redis, Core clone/worktree, PM2 상태와 Caddy 인증서는 named volume에 보존됩니다. 새 user icon은 외부 이미지 서비스가 보존하며 runtime의 user icon volume은 기존 설치 호환용입니다. - Gateway와 게임 공통 메뉴는 runtime volume의 `/srv/data/navigation.json`을 읽습니다. 최초 기동 때만 Core의 `resources/navigation.json`을 복사하며, 이후 배포와 container 재생성은 운영자가 편집한 파일을 덮어쓰지 않습니다. JSON을 유효하게 저장한 뒤 브라우저를 새로 열거나 새로고침하면 재빌드 없이 반영됩니다. 잘못된 스키마나 `javascript:` URL은 API가 거부하므로 변경 전에 Core 문서의 메뉴 설정 검증 명령을 실행합니다. - `/image/*`는 앱 artifact가 아니며 `data/image/`의 별도 운영 자산을 제공합니다. - 일반 `docker compose down`은 volume을 보존합니다. `down --volumes`는 DB와 release 상태를 삭제하는 파괴적 명령이므로 backup 없이 실행하지 않습니다. - 앱 rollback은 Prisma migration을 되돌리지 않습니다. 이전 앱과 새 schema의 호환성을 배포 전에 확인합니다. - Core2026 저장소가 비공개라면 credential을 Git에 추가하지 않습니다. HTTPS는 `.env`의 `CORE_REPOSITORY_USERNAME`과 read-only `CORE_REPOSITORY_TOKEN`을 함께 설정합니다. URL에 credential을 포함하지 않으며 runtime은 0600 파일로 옮긴 뒤 원래 env를 제거하고 `GIT_ASKPASS`로만 전달합니다. - SSH deploy key를 사용할 때는 `CORE_SSH_PRIVATE_KEY_BASE64`와 `CORE_SSH_KNOWN_HOSTS_BASE64`를 함께 설정합니다. 각각 `base64 -w0`로 인코딩한 read-only key와 검증한 known_hosts 내용입니다. HTTPS와 SSH mode를 동시에 설정할 수 없습니다. 원래 base64 env는 파일 생성 직후 제거됩니다. 생성된 credential/key 파일은 runtime volume에서 0600으로 관리되고 해당 mode를 해제한 다음 기동하면 제거됩니다. 상태 확인과 로그: ```sh docker compose ps docker compose logs --tail=200 builder runtime caddy docker compose exec runtime pnpm --filter @sammo-ts/release-controller status ``` Caddy의 runtime logger는 reverse proxy 오류에도 요청 정보를 남길 수 있으므로 `X-Session-Token` 값을 encoder 단계에서 삭제합니다. 장애 로그를 수집할 때도 인증 헤더, cookie, token과 secret을 보고서나 채팅에 복사하지 않습니다. ## 프런트엔드 캐시 정책 Gateway frontend의 Vite build는 `/gateway/assets/`, profile 공용 Vite build는 `/gateway/profile-assets//assets/` 아래 파일명에 8자리 content hash를 붙입니다. 내부 Caddy는 이 형식을 만족하는 JS, CSS, source map과 기타 build asset에 `Cache-Control: public, max-age=31536000, immutable`을 적용합니다. 새 build는 내용이 달라지면 URL도 달라지므로 배포와 rollback 뒤에도 기존 URL의 장기 cache를 재사용할 수 있습니다. 같은 commit을 쓰는 일곱 profile은 같은 공용 URL을 사용하므로 브라우저가 profile을 옮겨도 이미 받은 JS, CSS와 source map을 다시 전송하지 않습니다. `index.html`, router fallback, `terms.*.html`처럼 URL이 고정된 HTML은 이 정책에 포함하지 않습니다. Caddy는 이 응답에 `Cache-Control: no-cache`를 적용하여 저장은 허용하되 사용할 때마다 변경 여부를 재검증합니다. content hash가 없는 `/assets/` 파일과 별도 운영 자산인 `/image/*`도 `immutable`로 취급하지 않습니다. 따라서 public directory에 장기 cache할 파일을 추가할 때는 먼저 content-hashed URL로 옮겨야 합니다. ## 정적 frontend와 격리 builder 운영 frontend는 `vite preview`로 제공하지 않습니다. Gateway build와 profile 공용 build는 `frontend-artifacts` named volume의 commit/digest 기반 불변 디렉터리에 복사되고, runtime은 검증된 release의 `current` 심볼릭 링크만 원자적으로 교체합니다. Caddy는 이 volume을 read-only로 mount하여 직접 제공합니다. 첫 전환과 명시적 개발 모드를 위한 preview reverse-proxy fallback은 남아 있지만 운영 PM2에는 Vite process가 없습니다. Profile Vite bundle은 commit과 공개 환경이 같으면 한 번만 생성되어 `game-assets/releases/`에 게시됩니다. 각 profile의 `current`에는 profile base path, API/SSE URL과 Gateway URL을 담은 JSON `