diff --git a/.env.example b/.env.example index ebec073..8c90e1e 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ DOMAIN=game.example.com PUBLIC_SCHEME=https ACME_EMAIL=admin@example.com +# Set http://DOMAIN when an external reverse proxy terminates TLS. +CADDY_SITE_ADDRESS= HTTP_PORT=80 HTTPS_PORT=443 diff --git a/README.md b/README.md index 00b497a..9a14694 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ DNS의 `DOMAIN` A/AAAA record가 호스트를 가리키고 80/443 TCP 및 443 UD 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 256개 상한을 가집니다. 호스트 용량에 맞춰 `RUNTIME_*_LIMIT`을 조정할 수 있지만 0 또는 무제한으로 두지 않습니다. PM2 process/restart 수가 예상보다 증가하면 로그 수집보다 diff --git a/compose.yaml b/compose.yaml index 95e3ee0..3f529e4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -121,7 +121,7 @@ services: image: caddy:2.10.2-alpine environment: DOMAIN: ${DOMAIN:?set DOMAIN in .env} - SITE_ADDRESS: ${DOMAIN:?set DOMAIN in .env} + SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-${DOMAIN:?set DOMAIN in .env}} ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env} ports: - ${HTTP_PORT:-80}:80 diff --git a/runtime/validate-env.mjs b/runtime/validate-env.mjs index 2257256..0d3482f 100644 --- a/runtime/validate-env.mjs +++ b/runtime/validate-env.mjs @@ -52,6 +52,18 @@ export const validateEnvironment = (env) => { errors.push('DOMAIN must be a hostname without a scheme, path, or port'); } + const caddySiteAddress = env.CADDY_SITE_ADDRESS?.trim() ?? ''; + if (caddySiteAddress) { + try { + const parsed = new URL(caddySiteAddress); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname !== domain || parsed.pathname !== '/') { + errors.push('CADDY_SITE_ADDRESS must be http://DOMAIN or https://DOMAIN'); + } + } catch { + errors.push('CADDY_SITE_ADDRESS must be http://DOMAIN or https://DOMAIN'); + } + } + const email = env.ACME_EMAIL?.trim() ?? ''; if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.push('ACME_EMAIL must be a valid email address'); diff --git a/test/validate-env.test.mjs b/test/validate-env.test.mjs index 06e0f9c..614b913 100644 --- a/test/validate-env.test.mjs +++ b/test/validate-env.test.mjs @@ -20,6 +20,18 @@ test('accepts a complete public repository environment', () => { assert.deepEqual(validateEnvironment(validEnv), []); }); +test('accepts an HTTP Caddy address for external TLS termination', () => { + assert.deepEqual( + validateEnvironment({ ...validEnv, CADDY_SITE_ADDRESS: 'http://game.test.invalid' }), + [], + ); +}); + +test('rejects a Caddy address for a different host', () => { + const errors = validateEnvironment({ ...validEnv, CADDY_SITE_ADDRESS: 'http://other.test.invalid' }); + assert.ok(errors.some((error) => error.includes('CADDY_SITE_ADDRESS'))); +}); + test('accepts paired HTTPS credentials without embedding them in the URL', () => { assert.deepEqual( validateEnvironment({ ...validEnv, CORE_REPOSITORY_USERNAME: 'deploy', CORE_REPOSITORY_TOKEN: 'token' }),