지난 편에서 Proxmox 위에 VM을 세우고, NFS로 저장소를 연결하고, 백업 체계를 잡았다. 이번 편은 그 위에 서비스를 올리고 외부에 공개하는 과정이다. 목표는 명확했다. 공유기에 포트를 하나도 열지 않고, 집 밖에서 https://앱이름.내도메인.com으로 각 서비스에 접속하는 것.
결과부터 말하면 목표는 달성했고, 현재 십수 개의 서비스가 이 구조로 운영되고 있다. 다만 그 과정에서 502 Bad Gateway를 세 번 만났다. 세 번 모두 화면에 뜬 에러는 같았지만 원인은 전부 달랐고, 셋 다 프록시 설정 자체는 문제가 없었다. 이번 글의 후반부는 그 세 건의 원인을 추적한 기록이다.
- Cloudflare Tunnel과 Caddy로 포트포워딩 없이 외부 공개하기
- 리버스 프록시로 NPM이 아니라 Caddy를 선택한 이유
- 원인이 각기 달랐던 세 번의 502 장애 분석
- 인증 설계: Cloudflare Access를 거는 앱과 걸지 않는 앱의 기준
구조: 터널과 관문
전통적인 자가 호스팅 외부 공개는 공유기 포트포워딩과 DDNS의 조합이다. 이 방식은 공인 IP의 특정 포트가 인터넷에 상시 노출되며, 그 포트로 들어오는 스캔과 공격 시도를 각 서비스가 직접 받아낸다. Cloudflare Tunnel은 이 방향을 뒤집는다. 집 안에서 실행되는 커넥터(cloudflared)가 Cloudflare 쪽으로 아웃바운드 연결을 먼저 수립하고, 외부 요청은 Cloudflare를 거쳐 그 연결을 타고 들어온다. 공유기에 인바운드 포트를 열 일이 없고, 원본 서버의 IP도 노출되지 않으며, SSL 인증서는 Cloudflare가 엣지에서 처리한다.
여기에 리버스 프록시로 Caddy를 두었다. 전체 요청 흐름은 다음과 같다.
브라우저 ──https──> Cloudflare (SSL 종료, Access 인증)
│ 터널 (아웃바운드 연결)
▼
cloudflared (dockge-vm)
│ http://localhost:80
▼
Caddy :80 ── Host 헤더로 분기 ──> 각 앱 (VM 로컬 / 파이 / NAS / HA)
이 구조에서 지킨 원칙이 하나 있다. Cloudflare의 Public Hostname은 전부 HTTP://localhost:80 하나로 통일하고, 분기는 Caddy가 Host 헤더를 보고 수행한다. 터널 설정에서 호스트별로 다른 내부 주소를 직접 지정할 수도 있지만, 그렇게 하면 라우팅 정보가 Cloudflare 대시보드와 Caddyfile 두 곳에 분산된다. 관문을 하나로 모으면 라우팅의 진실은 Caddyfile 한 파일에만 존재하고, 이 파일은 로컬에 있으므로 지난 편에서 만든 백업 체계에 자동으로 포함된다.
내부 접속도 같은 관문을 쓴다. *.home 형태의 내부 도메인을 hosts 파일에 등록해 두면, 집 안에서는 Cloudflare를 경유하지 않고 Caddy로 직행한다. 외부용과 내부용 도메인을 한 Caddyfile 블록에 나란히 적는 것으로 관리가 끝난다.
http://jellyfin.home, http://jellyfin.seotory.com {
reverse_proxy localhost:8096
}
http://memo.home, http://memo.seotory.com {
reverse_proxy 192.168.0.70:5230
}
왜 NPM이 아니라 Caddy인가
홈랩 리버스 프록시의 대중적인 선택은 Nginx Proxy Manager다. 웹 UI에서 클릭으로 프록시 호스트를 추가하는 방식이라 진입 장벽이 낮고, 실제로 처음에는 NPM을 검토했다. 그럼에도 Caddy를 선택한 근거는 설정의 존재 형태다.
NPM의 설정은 컨테이너 내부의 DB에 저장된다. 백업하려면 DB째 백업해야 하고, 설정을 검토하려면 UI를 열어 화면을 눌러가며 확인해야 하며, 문제가 생겼을 때 “현재 설정 전체”를 한눈에 보는 방법이 마땅치 않다. Caddy의 설정은 Caddyfile이라는 텍스트 파일 하나다. 위에서 본 것처럼 블록당 서너 줄이면 호스트 하나가 정의되고, 파일이므로 diff가 가능하고, git에 넣을 수 있고, rsync 백업에 자연히 포함된다. 지난 편에서 compose 파일을 로컬에 두고 미러링하기로 한 결정과 같은 계열의 판단이다. 설정이 파일이면 백업·비교·복구가 전부 파일 다루듯 이루어진다.
운영 편의를 위해 Caddy 실행 커맨드에 --watch 플래그를 추가해 두면 Caddyfile 저장 시 자동으로 리로드된다. 문법 오류가 있는 파일을 저장하면 Caddy는 기존 설정을 유지한 채 에러 로그만 남기므로, 오타로 서비스 전체가 내려가는 사고는 방지된다.
세 번의 502
이제 이 글의 본론이다. 구축 과정에서 502 Bad Gateway를 세 번 만났고, 셋 모두 브라우저에 뜨는 화면은 비슷했지만 에러를 반환한 주체와 원인이 전부 달랐다. 502 계열 장애에서 첫 번째로 할 일은 에러 페이지를 누가 렌더링했는지 확인하는 것이다. 그 주체가 요청이 어디까지 도달했는지를 알려주기 때문이다.
사건 1: openresty가 뜬다 — 트래픽이 엉뚱한 서버로 가고 있었다
첫 서비스를 터널에 연결하고 외부 도메인으로 접속하자 502가 떴다. 그런데 에러 페이지 하단의 서버 서명이 openresty였다. 우리 구성에는 openresty가 없다. Caddy도 Cloudflare도 아닌 제3의 웹서버가 응답했다는 것은, 요청이 우리 터널에 도달조차 하지 않았다는 뜻이다.
원인은 DNS에 있었다. 이 도메인은 과거 시놀리지의 역방향 프록시와 DDNS로 외부 공개를 했던 이력이 있고, 당시 만들어 둔 A 레코드가 Cloudflare DNS에 그대로 남아 있었다. 터널을 만들면 Cloudflare가 해당 호스트의 CNAME을 생성해 주는데, 기존 A 레코드가 있으면 그쪽이 우선하거나 충돌한다. 요청은 옛 공인 IP로 향했고, 그 끝에서 시놀리지의 웹서버(nginx 계열)가 더 이상 존재하지 않는 백엔드를 찾다 502를 반환한 것이다.
한 겹 더 있었다. A 레코드를 지운 뒤에도 증상이 간헐적으로 재현됐는데, 터널 대시보드를 보니 커넥터가 두 개 등록되어 있었다. 과거 테스트하면서 시놀리지에도 같은 터널 토큰으로 cloudflared를 설치해 둔 것을 잊고 있었던 것이다. 같은 토큰의 커넥터가 여러 대면 Cloudflare는 트래픽을 그들 사이에 분산한다. 문제는 Public Hostname이 가리키는 localhost가 커넥터마다 다른 머신을 의미한다는 점이다. VM의 커넥터로 간 요청은 VM의 Caddy를 만나 정상 응답했고, 시놀리지의 커넥터로 간 요청은 그 머신의 80 포트에서 아무것도 찾지 못했다. 접속이 될 때도 있고 안 될 때도 있는 간헐 장애의 전형적인 원인 중 하나다. 시놀리지 쪽 cloudflared를 제거하고 커넥터가 하나만 남은 것을 확인한 뒤 해결되었다.
이 사건에서 남긴 교훈은 두 가지다. 새 외부 공개 체계를 세울 때는 이전 체계의 DNS 레코드부터 청산해야 하고, 터널 커넥터 목록은 라우팅의 일부이므로 커넥터 수가 의도와 일치하는지가 점검 항목이 되어야 한다.
사건 2: Cloudflare 502 — localhost는 컨테이너 안에서 다른 곳을 가리킨다
두 번째 502는 Cloudflare가 직접 반환한 것이었다. 에러 페이지에 Cloudflare 브랜딩이 있었으므로 요청이 엣지까지는 도달했고, 터널 너머의 원본이 응답하지 않았다는 의미다. 그런데 같은 앱을 내부 도메인으로 접속하면 정상이었다. Caddy도 앱도 살아 있는데 터널을 통한 요청만 실패하는 상황.
원인은 컨테이너 네트워크 모드였다. 당시 cloudflared는 network_mode: host로, Caddy는 기본값인 bridge 모드로 실행되고 있었다. cloudflared는 받은 요청을 설정대로 http://localhost:80으로 전달하는데, host 모드인 cloudflared에게 localhost는 VM 자신이다. 그리고 VM의 80 포트에는 bridge 모드 Caddy가 포트 매핑(80:80)으로 노출되어 있었으니 얼핏 문제가 없어 보인다. 실제로는 매핑 갱신 과정의 타이밍 문제와 겹치며 연결이 불안정했고, 구조적으로도 “host 모드 프로세스 → 포트 매핑 → bridge 컨테이너”라는 불필요한 번역 계층이 하나 끼어 있었다.
해결은 Caddy도 network_mode: host로 통일하고 ports 매핑을 제거하는 것이었다. 관문 역할의 두 컨테이너(cloudflared, Caddy)가 같은 네트워크 평면에 있으면 localhost의 의미가 일치하고, Caddy가 파이나 NAS 등 다른 장비로 프록시할 때도 브리지 NAT를 거치지 않는다.
그런데 compose를 수정하고 재시작해도 증상이 그대로였다. 이것이 이 사건의 두 번째 층인데, docker restart는 컨테이너를 재생성하지 않는다. network_mode 같은 설정은 컨테이너 생성 시점에 박제되는 속성이라, 파일을 고쳐도 기존 컨테이너는 옛 설정으로 재기동될 뿐이다. docker compose down으로 컨테이너를 제거하고 up -d로 새로 만들어야 반영된다. 설정 파일과 실행 중인 컨테이너의 상태가 다를 수 있다는 것, 그리고 의심될 때는 docker inspect로 실물을 확인해야 한다는 것을 이때 배웠다.
사건 3: 이미지에 박힌 기본값 — 설정의 세 번째 층
세 번째는 정확히는 502가 아니라 권한 오류였지만, 같은 계열의 교훈이라 함께 기록한다. 스택 설정 파일들을 웹에서 편집하기 위해 File Browser를 배포했는데, 컨테이너가 기동 직후부터 Permission denied를 반복했다. compose에는 user 지정이 없었으므로 root로 실행될 것이라 가정했고, 그 가정을 검증하기 위해 실물을 확인했다.
$ docker inspect filebrowser --format '{{.Config.User}}'
user
compose 파일 어디에도 없는 user라는 값이 나왔다. compose에서 지운 것이 반영되지 않았나 싶어 컨테이너를 완전히 제거하고 재생성해도 결과는 같았다. 남은 가능성은 하나였다. 이미지 자체의 Dockerfile에 USER 지시자가 정의되어 있는 것이다. 확인해 보니 File Browser는 보안 강화를 위해 최근 버전부터 기본 실행 계정을 root가 아닌 비특권 계정으로 변경했고, compose에서 user를 생략하면 그 이미지 기본값이 적용되는 것이었다. 해결은 compose에 user: "0:0"을 명시해 이미지 기본값을 덮어쓰는 것이었다.
세 사건을 관통하는 구조를 정리하면, 컨테이너의 실행 설정은 세 개의 층으로 이루어진다.
compose 파일 — 의도. 여기 적은 것이 곧 실행 상태는 아니다
컨테이너 — 생성 시점의 설정이 박제된 실물. restart로는 안 바뀐다
이미지 — compose가 침묵한 항목의 기본값 공급자
증상과 설정 파일이 어긋날 때는 이 세 층 중 어디가 진실을 쥐고 있는지부터 확인해야 하며, 그 도구가 docker inspect다.
인증 설계: Access를 거는 앱과 걸지 않는 앱
외부 공개가 동작한 다음의 문제는 인증이다. Cloudflare Access는 도메인 앞단에 이메일 OTP 등의 인증 게이트를 세우는 기능으로, 무료 플랜에서도 사용할 수 있다. 앱 자체의 로그인과 별개로 Cloudflare 엣지에서 한 번 걸러주므로, 앱에 알려지지 않은 취약점이 있어도 인증 게이트 뒤에 숨는 효과가 있다.
그렇다면 모든 도메인에 걸면 되는가 하면, 그렇지 않다. Access는 브라우저 사용자를 전제로 한 인증이라, 폰 앱이나 API 클라이언트처럼 브라우저 밖에서 접속하는 클라이언트를 막아 버린다. 이 지점이 앱별 판단 기준이 된다.
| 구분 | 대상 | 근거 |
|---|---|---|
| Access 적용 | 컨테이너 관리, NAS 관리(DSM), 대시보드, 모니터링, 파일 편집기 | 사람이 브라우저로만 접속하는 관리 도구. 이중 인증의 이득만 있고 손해가 없다 |
| Access 미적용 | 미디어 서버, 비밀번호 관리자, Home Assistant, Git 서버 | 전용 폰 앱, 브라우저 확장, git CLI 등 비브라우저 클라이언트가 통신해야 한다 |
미적용 그룹은 Access 대신 앱 자체 인증을 강화하는 방향으로 보완한다. 강한 비밀번호에 더해 앱이 지원하는 2FA(TOTP)를 활성화하고, 회원가입 기능이 있는 앱은 가입 자체를 잠근다. Git 서버처럼 2FA를 켜면 비밀번호 인증이 막히는 경우에는 액세스 토큰을 발급해 사용한다. 요약하면 원칙은 하나다. 브라우저 전용 관리 도구는 Access로 이중화하고, 클라이언트가 다양한 서비스는 자체 인증을 강하게 가져간다.
관리 인터페이스 중에서도 공유기 관리 페이지나 Proxmox 웹처럼 네트워크의 뿌리에 해당하는 것들은 Access를 걸더라도 외부에 내지 않는 것이 맞다고 판단해 내부 전용으로 남겼다. 이런 대상까지 외부에서 다뤄야 한다면 그것은 터널이 아니라 VPN으로 내부망에 진입하는 방식의 영역이며, 공개할 것은 터널로, 관리자만 만질 것은 VPN으로 나누는 이원 구조가 정석이다.
정리
최종 구조는 다음과 같다.
외부: 브라우저 → Cloudflare (SSL, 일부 Access) → 터널 → cloudflared → Caddy → 각 앱
내부: *.home (hosts) → Caddy → 각 앱
라우팅의 진실: Caddyfile 하나 (로컬 파일, 백업 포함)
인증: 관리 도구 = Access / 앱 클라이언트 있는 서비스 = 자체 인증 + 2FA
이번 편의 결론을 정리하면 다음과 같다.
- 502를 만나면 원인 추측에 앞서 에러 페이지의 렌더링 주체부터 확인한다. 그것이 요청의 도달 지점을 알려준다.
- 새 공개 체계로 이전할 때는 구 체계의 DNS 레코드 청산이 이전 작업의 일부다. 터널 커넥터 수 역시 라우팅 설정이다.
- 컨테이너 세계의 localhost는 네트워크 모드에 따라 다른 곳을 가리킨다. 관문 역할의 컨테이너들은 같은 네트워크 평면에 두는 것이 단순하다.
- 컨테이너 설정은 compose(의도), 컨테이너(박제된 실물), 이미지(기본값)의 세 층이다. 어긋남이 의심되면
docker inspect로 실물을 본다. - 인증 게이트는 클라이언트의 형태를 따라 결정한다. 브라우저 전용이면 앞단 인증을, 전용 클라이언트가 있으면 자체 인증 강화를.
이것으로 뼈대(상편)와 공개(하편)가 끝났다. 이 위에 올린 개별 서비스들 — 미디어 서버 이전, 비밀번호 관리자, Home Assistant와 자동화 — 은 각각 다룰 만한 주제이지만, 홈랩의 구조라는 관점에서는 여기까지가 본체다. 두 편의 내용이면 같은 구성을 재현하는 데 필요한 결정과 함정은 대부분 담았다고 생각한다.