Next.js Standalone + Docker에서 CSP가 개발 API를 차단한 이유
배경
배포 환경은 두 가지였다.
- 개발계: 내부 IP 서버 (
http://dev-server:port) - 운영계: 공개 도메인 (
https://api.prod-domain.com)
Dockerfile은 Git 저장소를 클론해 next build(output: 'standalone')를 실행하고, proxy.ts(Next.js 16에서 middleware.ts가 바뀐 이름)가 응답마다 CSP 헤더를 만든다.
CSP 복습
CSP(Content Security Policy)는 페이지가 불러오거나 요청할 수 있는 출처를 브라우저에게 알려주는 응답 헤더다.
Content-Security-Policy: connect-src 'self' https://api.prod-domain.com
| 디렉티브 | 역할 |
|---|---|
connect-src | fetch, XHR, WebSocket 등 네트워크 요청 허용 출처 |
img-src | 이미지 허용 출처 |
script-src | 스크립트 허용 출처 |
default-src | 따로 지정하지 않은 디렉티브의 기본값 |
증상
개발계에 배포한 뒤 로그아웃하자 콘솔에 에러가 났다.
Connecting to 'http://dev-server:port/v1/api/auth/signout' violates the following
Content Security Policy directive: "connect-src 'self' https://api.prod-domain.com"
개발계 페이지가 운영 도메인만 허용하는 CSP를 내려보내고 있었다.
원인
1. Docker 빌드·실행은 항상 production 모드
next build와 next start(standalone의 node server.js)는 NODE_ENV=production으로 동작한다. Next.js는 이 모드에서 .env.production을 .env보다 우선 적용한다.
# .env
CSP_CONNECT_SRC=http://dev-server:port
# .env.production
CSP_CONNECT_SRC=https://api.prod-domain.com ← 개발계 빌드에도 이 값이 적용됨
.env.production은 "운영 서버용"이 아니라 "production 모드용" 이다. 개발계도 Docker 안에서는 production 모드로 빌드·실행되므로 같은 값을 읽는다.
2. NEXT_PUBLIC_* 는 빌드 시점에 고정된다
NEXT_PUBLIC_* 변수는 빌드할 때 코드에 문자열로 인라인된다(클라이언트 번들뿐 아니라 서버 코드에서도). 같은 이미지를 여러 환경에 배포하면서 이 값으로 환경을 나눌 수는 없다.
해결 1 (적용한 방법): 런타임 host 헤더로 환경 판별
proxy.ts에서 요청의 host 헤더로 운영 여부를 판단해 CSP에 넣을 API 출처를 고른다.
const host = request.headers.get('host') ?? '';
const prodHostname = process.env.NEXT_PUBLIC_PROD_SERVER_URL
? new URL(process.env.NEXT_PUBLIC_PROD_SERVER_URL).hostname
: '';
const isProd = prodHostname !== '' && host.split(':')[0] === prodHostname;
const apiUrl = isProd ? process.env.NEXT_PUBLIC_PROD_API_URL : process.env.NEXT_PUBLIC_API_URL;
const connectSrc = ["'self'", apiUrl].filter(Boolean).join(' ');
주의할 점:
host.includes(hostname)처럼 부분 문자열로 비교하면prod-domain.com.attacker.test같은 값도 통과한다. 포트를 뗀 뒤 정확히 비교한다.- 리버스 프록시·로드밸런서 뒤에서는
host가 내부 주소로 바뀔 수 있다. 이 경우 프록시가 넘겨주는x-forwarded-host를 확인해야 한다. host는 클라이언트가 보내는 값이므로 보안 판단(인증·권한)에는 쓰지 않는다. 여기서는 그 사용자 자신에게 내려줄 CSP를 고르는 용도라 위험이 작다.
클라이언트 API 주소: 빌드 시 config.json 생성
클라이언트는 앱 시작 시 /config.json을 읽어 API base URL을 정한다. 이 파일은 prebuild 단계에서 생성하며, Git 클론 기반 빌드이므로 현재 브랜치로 환경을 구분했다.
import { execSync } from 'node:child_process';
let branch = process.env.GIT_BRANCH ?? '';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
} catch {
// git 정보가 없으면 GIT_BRANCH 환경 변수 사용
}
const isProdBranch = branch === 'main';
const apiUrl = isProdBranch ? process.env.NEXT_PUBLIC_PROD_API_URL : process.env.NEXT_PUBLIC_API_URL;
CI가 특정 커밋을 체크아웃하는(detached HEAD) 환경에서는 git rev-parse --abbrev-ref HEAD가 브랜치 이름 대신 HEAD를 반환한다. 브랜치 감지는 "클론한 브랜치를 그대로 빌드하는 구조"에서만 믿을 수 있으므로, 가능하면 빌드 인자(--build-arg APP_ENV=dev)로 명시하는 편이 안전하다.
해결 2 (더 단순한 대안): 런타임 환경 변수 주입
CSP_CONNECT_SRC처럼 NEXT_PUBLIC_이 아닌 변수는 서버가 실행될 때 process.env에서 읽힌다. 그리고 Next.js는 이미 설정된 process.env 값을 .env* 파일로 덮어쓰지 않는다. 따라서 컨테이너 실행 시 환경별 값을 주입하면 .env.production보다 우선한다.
# 개발계
docker run -e CSP_CONNECT_SRC="http://dev-server:port" my-app
# 운영계
docker run -e CSP_CONNECT_SRC="https://api.prod-domain.com" my-app
이렇게 하면 이미지 하나로 여러 환경을 운영할 수 있다(build once, deploy many). 환경마다 달라지는 값은 .env.production이 아니라 배포 환경의 환경 변수로 관리하는 것이 근본적인 해결이다.
교훈
.env.production은 "production 모드" 설정이다. 개발 서버도 Docker 안에서는 production 모드로 돌아간다.NEXT_PUBLIC_*는 빌드 시 고정된다. 런타임에 환경별로 달라야 하는 값은 서버 전용 변수로 두고 실행 시점에 주입한다.- 요청 헤더로 환경을 판별해야 한다면 정확히 일치하는지 비교하고, 프록시 헤더를 고려한다.