mLog 명판이 있는 작업대에서 원형 미로의 반복 경로를 살피고 출구 표지판을 돌리는 흰색 3D 유령 지도제작자

Nginx Proxy Manager 무한 리다이렉트: Ghost HTTPS와 로그인 루프 진단

서버·인프라 Nginx Proxy Manager 2026년 10월 9일

Nginx Proxy Manager 뒤에 Ghost를 연결했는데 ERR_TOO_MANY_REDIRECTS가 뜬다면, 먼저 응답의 Location을 확인한다. 같은 주소로 계속 돌아오는지, HTTP와 HTTPS가 번갈아 나오는지, 로그인 주소와 원래 주소를 오가는지에 따라 볼 설정이 달라진다.

인증서가 보인다는 것만으로 Ghost까지 HTTPS 정보가 전달됐다고 볼 수는 없다. 브라우저가 NPM에 연결하는 구간과 NPM이 Ghost에 연결하는 구간은 별개다. Cloudflare가 앞에 있다면 확인할 구간이 하나 더 생긴다.

이 글은 2026-10-09 확인한 공식 문서를 바탕으로 진단 순서를 정리했다. curl 명령은 Python 3.12.14 로컬 HTTP 모의 서버와 curl 8.5.0으로 검증했다. NPM·Ghost·Cloudflare를 실제로 연결해 장애를 재현한 기록은 아니다. 예시 도메인과 서비스 이름은 자신의 구성에 맞춰 바꿔야 한다.

먼저 502와 리다이렉트 반복을 나눈다

브라우저 오류 문구만 보지 말고 Network 패널의 응답 상태를 확인한다.

관찰한 응답 먼저 볼 곳
502와 연결 거부·업스트림 오류 서비스 상태, Docker 네트워크, 내부 포트, Scheme
301·302·307·308이 반복 각 응답의 Location과 외부 URL 설정
로그인 화면과 서비스 주소가 반복 쿠키 저장·전송과 인증 서비스의 복귀 주소
HTTP/2 protocol error, 3xx 반복은 없음 프로토콜 협상과 응답 헤더

502라면 NPM 502와 Docker 네트워크 점검부터 진행한다. 리다이렉트는 응답을 받은 클라이언트가 다음 주소로 다시 요청하는 동작이다. 두 증상을 같은 설정 변경으로 해결하려 하면 원인을 놓치기 쉽다.

1. GET으로 첫 Location과 반복 경로를 읽는다

로그인하지 않아도 열리는 자신의 URL부터 확인한다. 아래 명령은 페이지를 요청할 뿐 서버 설정을 바꾸지 않는다.

curl -sS --connect-timeout 5 --max-time 15 \
  -D - -o /dev/null \
  'https://blog.example.com/'

첫 응답만 확인한 뒤, 이동 횟수를 제한해 이어지는 응답을 본다.

curl -sS -L --max-redirs 5 \
  --connect-timeout 5 --max-time 20 \
  -D - -o /dev/null \
  -w '\nfinal=%{url_effective} status=%{http_code} redirects=%{num_redirects}\n' \
  'https://blog.example.com/'

-I는 HEAD 요청이다. GET과 다르게 처리하는 서비스가 있으므로 처음에는 위처럼 GET 응답의 헤더만 출력한다. --max-redirs 5에 걸렸다는 사실만으로 무한 루프가 확정되지는 않는다. 정상 경로가 길 수도 있으므로 같은 URL이나 같은 전환 패턴의 재등장을 찾아야 한다. 명령 옵션은 curl 공식 매뉴얼을 참고한다.

다음은 운영 로그가 아닌 패턴 설명용 예시다.

Location 패턴 확인할 가설
https://blog.example.com/을 계속 반환 뒤쪽 서비스가 원래 HTTPS 요청을 HTTP로 인식하는지
http 주소와 https 주소가 왕복 서로 다른 계층의 HTTPS 강제·HTTP 복귀 규칙 충돌
www 포함·미포함 주소가 왕복 대표 도메인 전환 규칙 충돌
/login과 /가 왕복 인증 상태나 복귀 주소가 유지되지 않는지

패턴은 조사 방향을 정하는 단서다. Server 응답 헤더 하나로 어느 프록시가 리다이렉트를 만들었는지 확정하지 말고, 같은 시각의 프록시·앱 로그를 함께 본다. 로그를 공유할 때 쿠키와 로그인 토큰이 들어간 쿼리 문자열은 제거한다.

2. Cloudflare를 사용한다면 원본까지의 TLS를 확인한다

Cloudflare를 거치지 않는 사이트라면 이 단계는 건너뛴다. DNS만 Cloudflare에서 관리하는 경우와 프록시를 통과하는 경우도 구분한다.

Cloudflare의 Flexible 모드는 Cloudflare에서 원본 서버로 HTTP 요청을 보낸다. 이때 원본 NPM이 HTTP 요청을 HTTPS로 돌리면, 브라우저는 HTTPS로 다시 요청하지만 원본에는 또 HTTP가 도착해 같은 리다이렉트가 반복될 수 있다. Cloudflare의 리다이렉트 오류 안내가 설명하는 경우다.

반대로 원본에서 HTTPS를 HTTP로 돌리는 규칙도 확인한다. 암호화 모드만 바꾸면 모든 루프가 해결되는 것은 아니다. Cloudflare의 Redirect Rules, HTTPS 강제 설정, NPM의 리다이렉션 호스트, 애플리케이션의 대표 URL이 같은 방향을 가리켜야 한다.

원본까지 TLS를 사용할 계획이라면 인증서를 먼저 준비하고 Full (strict)의 조건을 확인한다. 원본의 HTTPS 접속이 가능하고, 인증서가 유효 기간 안에 있으며 요청 호스트명에 맞아야 한다. 공인 CA 또는 Cloudflare Origin CA 인증서를 사용할 수 있다. 조건을 갖추지 않은 채 전환하면 526 오류가 날 수 있다. Full (strict) 공식 문서에 구체적인 요건이 있다.

Cloudflare Origin CA 인증서는 일반 클라이언트가 기본 신뢰하는 공인 인증서와 다르다. 따라서 원본 직접 접속 검사에서의 인증서 실패를 그대로 Cloudflare 경유 장애와 같다고 해석하지 않는다. 문제를 숨기려고 인증서 검증을 끄는 설정을 상시 적용하지 않는다.

3. NPM의 Scheme과 Ghost가 보는 HTTPS 정보를 분리한다

NPM Proxy Host의 Scheme은 NPM에서 업스트림으로 연결할 방식이다. 외부 주소가 HTTPS라는 이유만으로 내부 Ghost의 Scheme까지 HTTPS로 바꿔서는 안 된다. 내부 서비스가 실제로 TLS를 제공하는지 먼저 확인한다.

NPM에서 TLS를 종료하고 내부 Ghost에는 HTTP로 전달하는 구성은 가능하다. 이때 Ghost가 원래 요청이 HTTPS였음을 알도록 전달해야 하는 값이 X-Forwarded-Proto다. Ghost 공식 문서는 이 헤더가 잘못 전달되면 HTTPS 리다이렉트가 반복될 수 있다고 설명한다.

아래는 Nginx가 브라우저의 TLS를 직접 종료하는 구성에서 헤더의 의미를 설명하는 일부다. NPM Advanced 입력창에 붙여 넣는 완성 설정이 아니다.

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;

NPM 앞에서 다른 프록시가 TLS를 종료하면 NPM이 보는 $scheme은 원래 브라우저의 scheme과 다를 수 있다. 이런 구성에서는 각 경계에서 누가 헤더를 설정하고 덮어쓰는지 추적해야 한다. 외부 클라이언트가 보낸 헤더를 검증 없이 신뢰하거나 모든 요청을 무조건 https로 표시하는 설정으로 덮지 않는다. Ghost 리버스 프록시 문서를 자신의 연결 구조와 대조한다.

원래 IP를 전달할 때도 같은 신뢰 범위 문제가 생긴다. 구체적인 경계 설정은 Nginx 실제 IP와 신뢰 프록시 글에서 이어서 볼 수 있다.

4. Ghost의 공개 URL과 실제 적용 설정을 확인한다

Ghost의 url은 방문자가 사용하는 공개 주소다. SSL로 서비스한다면 https://를 포함해야 한다. 내부 컨테이너 주소인 http://ghost:2368을 공개 URL로 지정하는 값이 아니다. 별도의 관리자 URL을 사용한다면 해당 설정과 접근 도메인도 함께 맞춘다. Ghost 설정 문서의 URL 항목을 기준으로 확인한다.

Compose에서 관리한다면 서비스 정의의 url과 실행 중인 컨테이너에 반영된 값을 구분한다. 변경 후 단순 restart로 충분하다고 가정하지 않는다. Compose 환경변수 변경과 재생성에 확인 순서가 정리되어 있다.

NPM 쪽에서는 입력창에 보이는 값뿐 아니라 생성된 설정을 읽는다. 아래 npm은 예시 서비스 이름이다. 자신의 Compose 프로젝트 디렉터리에서 실제 서비스 이름으로 실행한다.

docker compose exec -T npm nginx -T

대상 server_name을 찾고 해당 server·location의 return, rewrite, proxy_pass, proxy_set_header, include 파일을 확인한다. nginx -T는 설정을 검사하고 읽은 파일을 출력한다. 실행 중인 worker가 그 파일을 이미 반영했다는 증명은 아니다. 마지막 reload 성공 여부와 요청 결과를 함께 확인해야 한다. 출력에는 내부 주소나 민감한 설정이 들어갈 수 있어 원문 전체를 공개하지 않는다. Nginx 명령 옵션

proxy_set_header는 현재 설정 단계에 하나라도 정의되어 있으면 상위 단계의 같은 지시어 묶음을 그대로 상속하지 않는다. 서버 수준에 한 줄을 더했는데 실제 location에서는 다른 값이 적용될 수 있다는 뜻이다. 기존 지시어에 중복으로 덧붙이기 전에 최종 설정의 위치를 읽는다. Nginx proxy 모듈 문서

NPM은 여러 위치에 사용자 정의 설정을 포함할 수 있고, server_proxy.conf는 모든 프록시 server 블록에 들어간다. 한 사이트의 문제를 고치려다 다른 사이트까지 바꾸지 않도록 적용 범위를 확인한다. NPM 사용자 정의 설정 안내

5. 로그인할 때만 반복되면 쿠키를 본다

홈은 열리는데 로그인 후 되돌아온다면 브라우저 개발자 도구에서 Network의 Preserve log를 켜고 로그인 과정을 한 번 기록한다. 다음을 연결해서 읽는다.

  1. 로그인 응답에 Set-Cookie가 있는가.
  2. 브라우저가 쿠키를 저장했는가. 차단 사유가 표시되는가.
  3. 다음 요청에 해당 쿠키가 포함되는가.
  4. 쿠키를 보냈는데도 서버가 로그인으로 돌려보내는가.

페이지 이동 후에도 기록을 유지하는 방법은 Chrome DevTools 문서를 참고한다. 마지막 경우에는 쿠키 전송 외에 서버 세션 조회, 인증 검증, 접근 정책도 확인해야 한다.

쿠키가 저장되거나 전송되지 않았다면 Domain·Path·Secure·SameSite 조건과 실제 요청 주소를 비교한다. Domain을 생략한 쿠키는 발급한 호스트에 묶인다. SameSite=None에는 Secure가 필요하다. 원인을 확인하지 않고 쿠키의 범위를 넓히거나 보안 속성을 제거하지 않는다. Set-Cookie 문서

curl과 브라우저의 차이도 주의한다. 일반 curl 요청은 브라우저의 로그인 상태를 가져오지 않는다. 아래는 같은 curl 실행 안에서 새로 받은 쿠키를 이어 보내는 비교용 명령이며 기존 브라우저 세션을 재현하지 않는다.

curl -sS -L --max-redirs 5 -b '' \
  --connect-timeout 5 --max-time 20 \
  -D - -o /dev/null \
  'https://blog.example.com/'

curl의 쿠키 엔진으로 이동이 끝나더라도 브라우저의 SameSite 정책, 자바스크립트, 외부 로그인 흐름까지 검증한 것은 아니다. curl 쿠키 안내를 참고한다. Authelia를 함께 사용한다면 NPM·Authelia 접근 제어와 복귀 URL을 같이 점검한다.

6. HTTP/2는 별도 비교 항목으로 둔다

HTTP/2를 끄고 접속됐다는 결과는 비교 단서다. 3xx 루프의 원인이 곧 HTTP/2라는 결론은 아니다. 같은 URL에서 HTTP 버전과 첫 응답을 나란히 확인한다.

curl -sS --http1.1 --connect-timeout 5 --max-time 15 \
  -D - -o /dev/null -w '\nhttp=%{http_version}\n' \
  'https://blog.example.com/'

curl -sS --http2 --connect-timeout 5 --max-time 15 \
  -D - -o /dev/null -w '\nhttp=%{http_version}\n' \
  'https://blog.example.com/'

먼저 curl --version에서 HTTP2 지원을 확인한다. --http2를 썼다는 사실 대신 출력된 실제 HTTP 버전을 본다. 두 요청이 모두 같은 Location을 반환한다면 우선 그 전환 규칙을 조사한다. HTTP/2에서만 프로토콜 오류가 난다면 응답 헤더와 프록시 로그를 별도로 살펴본다.

HTTP/2 메시지에는 Connection, Keep-Alive, Upgrade 같은 연결 전용 헤더를 실을 수 없다. 관련 오류가 있다면 사용자 정의 응답 헤더도 조사 대상이다. 이것이 모든 NPM 로그인 장애의 원인이라는 뜻은 아니다. HTTP/2 표준 8.2.2

로컬에서 확인한 범위

첨부한 redirect-probe-local-test.py는 임의의 로컬 포트에 HTTP 서버를 열고 실제 curl을 실행한 뒤 종료한다. 외부 서버, 인증 계정, Docker 볼륨을 건드리지 않는다.

python3 redirect-probe-local-test.py
검사 실제 결과
GET 첫 응답만 조회 302, Location 확인, 이동 0회
한 번 이동 후 종료 200, 이동 1회
자기 주소로 반복 종료 코드 47, 이동 상한 5회
두 경로 사이 왕복 종료 코드 47, 이동 상한 5회
같은 URL의 HEAD와 GET 모의 서버에서 HEAD 200, GET 302
쿠키 엔진 없이 세션 경로 조회 종료 코드 47, 이동 상한 5회
쿠키 엔진 사용 200, 이동 1회

7개 검사를 통과했다. HEAD와 GET을 다르게 응답하도록 만든 것은 첫 응답 진단의 차이를 보여주기 위해서다. 실제 서비스가 반드시 이렇게 응답한다는 뜻은 아니다. TLS, HTTP/2 협상, NPM 설정, Ghost 로그인은 이 모의 실험의 범위 밖이다.

수정 후에는 같은 요청으로 확인한다

변경 전 설정을 기록하고 한 번에 한 원인만 수정한다. 리다이렉트 규칙을 바꿨으면 같은 시작 URL에서 Location 체인을 다시 읽고, 공개 페이지와 관리자 로그인도 각각 확인한다. 로그인이 성공한 뒤 새로고침해 인증 상태가 유지되는지도 본다.

성공 기준은 무조건 200 하나가 아니다. 비로그인 관리자 요청이 정상 로그인 화면으로 이동하거나 보호된 경로가 인증을 요구할 수 있다. 의도한 도착점에 도달하고, 같은 주소나 같은 인증 경로를 끝없이 반복하지 않는지 확인한다. 예상 밖의 인증서 오류·다른 사이트 장애가 생기면 직전 변경을 되돌리고 다시 원인 범위를 좁힌다.

태그

mLog

웹 개발과 서버 운영 과정에서 마주친 문제와 해결 과정을 기록합니다. 직접 확인한 설정과 실행 결과를 함께 정리해, 비슷한 문제를 겪는 분들이 참고할 수 있도록 합니다.