blog

Next.js Standalone + Docker에서 CSP가 개발 API를 차단한 이유

Front-end / Next.js#Next.js#CSP#Docker#환경 변수#standalone

배경

배포 환경은 두 가지였다.

  • 개발계: 내부 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-srcfetch, 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이 아니라 배포 환경의 환경 변수로 관리하는 것이 근본적인 해결이다.

교훈

  1. .env.production은 "production 모드" 설정이다. 개발 서버도 Docker 안에서는 production 모드로 돌아간다.
  2. NEXT_PUBLIC_*는 빌드 시 고정된다. 런타임에 환경별로 달라야 하는 값은 서버 전용 변수로 두고 실행 시점에 주입한다.
  3. 요청 헤더로 환경을 판별해야 한다면 정확히 일치하는지 비교하고, 프록시 헤더를 고려한다.