↓ 본문으로 건너뛰기

Cloudflare Cloudflare Tunnel Cloudflared

클라우드플레어 터널로 로컬 서버 외부 접속: 노트북에서 무료로 셀프 호스팅하기

포트를 열지 않고 로컬 서버를 외부에서 접속하게 만드는 법. 클라우드플레어 터널(Cloudflare Tunnel)로 진짜 HTTPS 주소를 붙이고, ingress 허용 목록으로 공개할 경로만 정하고, 비용은 0원인 셀프 호스팅 과정을 Docker Compose 예제로 정리했습니다.

클라우드플레어 터널로 로컬 서버 외부 접속: 노트북에서 무료로 셀프 호스팅하기

제 아이디어는 늘 같은 식으로 시작합니다. 노트북에서 돌아가고, 잘 되고, 그래서 다른 사람에게 보여주고 싶어집니다. 한동안 그다음 단계는 언제나 클라우드 요금이었습니다. 머신 하나, 데이터베이스 하나, 버킷 하나, 거기에 연결한 도메인, 그리고 세 명이 들여다볼 물건에 매달 나가는 돈. 사이드 프로젝트나 진짜 프로젝트의 첫 달이라면 순서가 거꾸로입니다. 프로덕션이 생길지도 모르는데 프로덕션 값부터 내는 셈이니까요.

이 글은 반대 순서입니다. 서비스는 만들어진 그 머신에서 계속 돌고, Cloudflare Tunnel이 거기에 공개 HTTPS 호스트명을 붙여 줍니다. 노트북에서 인터넷을 향해 열려 있는 건 아무것도 없습니다. 포트 포워딩도, 공유기 설정도, 노출된 IP 주소도 없습니다. 머신이 Cloudflare 쪽으로 먼저 연결을 걸고, Cloudflare는 그 연결을 타고 요청을 내려보냅니다. 터널은 무료이고, TLS 인증서와 DDoS 방어, 캐싱은 아마 이미 갖고 있을 Cloudflare 존에 딸려 옵니다.

예시는 실제 사례입니다. 제 제품 중 하나는 Fly.io에서 돌고, 모바일 앱은 백엔드에서 OTA(over-the-air) JavaScript 업데이트를 받습니다(셀프 호스팅한 Expo Updates). 프로덕션은 그대로 둔 채, 개발 빌드용으로 그 업데이트 서버의 두 번째 사본을 제 Mac에서 돌리고 싶었습니다. 아래 출력은 그날 오후에 나온 것이고, 제대로 안 됐던 부분도 들어 있습니다.

클라우드플레어 터널이란 실제로 무엇인가
#

보통 서버는 연결을 기다립니다. 인터넷에서 노트북에 닿으려면 공유기에서 포트를 열고, 그걸 노트북으로 포워딩하고, ISP가 공인 IPv4 주소를 주기를 바라야 합니다(제가 사는 곳을 포함해 많은 곳에서 주지 않습니다). 그러고 나면 그 포트에서 듣고 있는 모든 것이 인터넷의 모든 스캐너에게 하루 종일 노출됩니다.

터널은 이걸 뒤집습니다. cloudflared가 앱 옆에서 돌면서 Cloudflare 엣지로 나가는 연결을 엽니다. 가까운 데이터센터 두 곳에 네 개입니다. 존의 DNS 레코드가 호스트명을 터널로 향하게 합니다. 그 호스트명으로 요청이 오면 Cloudflare가 TLS를 종료하고 그 연결 중 하나로 요청을 내려보냅니다. 그러면 cloudflared가 설정에 적힌 로컬 서비스에 요청을 넘깁니다.

머신은 오직 밖으로만 연결합니다. Cloudflare의 존 규칙이 터널보다 먼저 돌고, 무엇에 닿을 수 있는지는 ingress 규칙이 정합니다.

보안 측면에서 중요한 건, 공유기에 인바운드 규칙이 하나도 없고 IP 주소가 DNS에 절대 나타나지 않는다는 점입니다. 누가 집 회선을 스캔해도 아무것도 찾지 못합니다. 듣고 있는 게 없으니까요.

처음부터 끝까지 설정하기 (cloudflared + Docker Compose)
#

Cloudflare에 올린 도메인(무료 플랜이면 충분합니다)과 설치된 cloudflared(Mac이라면 brew install cloudflared)가 필요합니다.

1. 한 번 로그인합니다. 브라우저가 열리면 존을 고르고, 이 머신이 그 존에 터널과 DNS 레코드를 만들 수 있게 해 주는 인증서가 저장됩니다.

cloudflared tunnel login

2. 터널을 만듭니다. 저는 이 프로젝트에 관한 건 전부 전용 폴더에 둡니다. 자격 증명 파일이 곧 터널의 신원이라, 그 파일을 가진 사람은 누구든 내 호스트명으로 트래픽을 서빙할 수 있기 때문입니다.

mkdir -p ~/.cloudflared/myapp && cd ~/.cloudflared/myapp
cloudflared tunnel create --credentials-file ./creds.json myapp-local
chmod 600 creds.json
Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel myapp-local with id 1ade030c-…

3. 호스트명을 터널로 향하게 합니다. 호스트명마다 명령 하나로 <tunnel-id>.cfargotunnel.com을 가리키는 프록시된 CNAME이 만들어집니다.

cloudflared tunnel route dns myapp-local ota.example.com
cloudflared tunnel route dns myapp-local checkin-local.example.com

4. ingress 규칙을 씁니다. 보안에서 핵심이 되는 부분이라 아래에 따로 절을 두었습니다.

5. 실행합니다. 제 앱은 이미 Docker Compose로 돌고 있어서, cloudflared는 같은 네트워크에 컨테이너 하나를 더 얹는 것뿐입니다. 서비스 이름으로 앱에 닿으니, 앱은 호스트에 포트를 공개할 필요조차 없습니다.

# compose.tunnel.yml, used alongside the project's docker-compose.yml
services:
  cloudflared:
    image: cloudflare/cloudflared:2026.10.7
    command: tunnel --config /etc/cloudflared/config.yml run
    volumes:
      - ${HOME}/.cloudflared/myapp:/etc/cloudflared:ro
    restart: unless-stopped
    depends_on: [api, edge]
docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared
INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic

서울로 연결 네 개, 이제 노트북은 바깥에서 안으로 인터넷에 올라와 있습니다. restart: unless-stopped 덕분에 재부팅해도 Docker가 뜨자마자 다시 살아납니다.

서버가 아니라 경로를 노출하기
#

제가 하마터면 저지를 뻔한 실수이고, 아마 가장 흔한 실수일 겁니다. 가장 쉬운 설정은 한 줄입니다. hostname → http://localhost:8000. 이러면 개발 서버 전체가 공개됩니다.

개발 서버는 그러라고 만든 게 아닙니다. 제 개발 서버는 DEBUG=True로 돌아서, 에러가 나면 전체 트레이스백과 설정을 보여줍니다. 인증도 꺼 둔 채로(AUTH_MODE=disabled) 돌아서, 아무 bearer 토큰이나 테스트 사용자로 로그인됩니다. localhost에서는 딱 맞는 설정이고 공개 호스트명에서는 재앙입니다. 여러분 쪽에도 비슷한 게 있을 겁니다. 기본 비밀번호가 걸린 관리자 화면, 시드 데이터 엔드포인트, 디버그 툴바, 아니면 호출하는 사람이 나뿐이라 호출자를 믿어 버리는 API 같은 것들요.

그래서 터널 설정은 허용 목록입니다. cloudflared는 요청마다 ingress 규칙을 순서대로, 호스트명과 경로에 대한 정규식으로 대조합니다. 처음 맞는 규칙이 이기고, 마지막 규칙이 나머지 전부를 받습니다.

tunnel: myapp-local
credentials-file: /etc/cloudflared/creds.json

ingress:
  # OTA: the two read-only endpoints the phone calls, and the files they link.
  - hostname: ota.example.com
    path: ^/api/expo-updates/(manifest|status)/$
    service: http://api:8000
  - hostname: ota.example.com
    path: ^/media/
    service: http://api:8000
  # Device check-ins, authenticated by each device's own key.
  - hostname: checkin-local.example.com
    path: ^/c/$
    service: http://edge:8081
  # Everything else, including /admin/ and the publish endpoint: 404 at the edge.
  - service: http_status:404

배포(publish) 엔드포인트는 같은 서버의 같은 접두사 아래(/api/expo-updates/import-update/)에 있지만 목록에 없습니다. 관리자 화면도, 나머지 API도 없습니다. 그런 요청은 Cloudflare를 벗어나지 못합니다.

정규식을 믿을 필요는 없습니다. URL이 어느 규칙에 걸리는지 cloudflared가 알려 줍니다.

cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/
https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
https://ota.example.com/admin/                            service: http_status:404
https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
https://checkin-local.example.com/c/                      service: http://edge:8081
https://checkin-local.example.com/health                  service: http_status:404

저라면 이 검사를 스크립트로 만들어 설정을 바꿀 때마다 돌리겠습니다. 몇 초면 끝나고, 의도보다 넓게 매칭되는 정규식은 남이 알려 줘야 비로소 알게 되는 종류의 실수입니다.

경험칙 두 가지를 더 덧붙이면 이렇습니다.

  • 노출하는 것은 스스로 인증해야 합니다. OTA 엔드포인트는 공개된 읽기 전용 데이터를 서빙합니다. 디바이스 체크인은 디바이스별 키로 서명됩니다. 어느 쪽도 안전을 네트워크에 기대지 않습니다. 로그인이 없는 페이지를 누군가에게 보여주고 싶다면, 로그인을 직접 만들지 말고 그 호스트명 앞에 Cloudflare Access를 두세요(사용자 50명까지 무료).
  • 터널을 개발 워크플로의 일부로 백그라운드에 상시 띄워 두지 마세요. 의도한 호스트명을 위해 일부러 켜고, 다 쓰면 끄세요. docker compose stop cloudflared 명령 하나로 전부 인터넷에서 내려갑니다.

호스트명을 집어삼킨 리다이렉트
#

터널이 연결되고 규칙도 검증했으니, 바깥에서 시험해 봤습니다.

https://ota.example.com/api/expo-updates/status/?…   301
https://ota.example.com/api/expo-updates/manifest/   301
https://ota.example.com/admin/                       301
https://checkin-local.example.com/c/                 301

모든 경로가 똑같은 301을 돌려줬습니다. 터널이 404로 답했어야 할 경로까지요. 요청이 cloudflared에 아예 닿지 않았다는 뜻입니다. Location 헤더는 https://www.example.com/이었고, server: cloudflare 헤더로 확실해졌습니다. Cloudflare가 스스로 답하고 있었습니다.

제가 자초한 일이었습니다. 몇 달 전 존재하지 않는 서브도메인을 훑고 다니는 스캐너를 빨아들이는 싱크홀로 “그 밖의 모든 서브도메인 → www"라는 존 단위 리다이렉트 규칙을 추가해 뒀습니다. 리다이렉트 규칙은 요청이 오리진으로 가기 전에 Cloudflare의 요청 파이프라인에서 돌고, 터널은 그저 오리진 하나일 뿐입니다. 같은 규칙이 프리뷰 환경도 잡아먹었습니다. Workers가 보기도 전에요. 해결책도 그때와 같습니다. 규칙의 표현식에 예외 하나를 더 넣는 겁니다.

… and not http.host in {"ota.example.com" "checkin-local.example.com"}

터널 호스트명이 앱 대신 Cloudflare의 리다이렉트나 에러 페이지로 답한다면, 터널을 디버깅하기 전에 그 존의 Rules → Redirect Rules, Page Rules, WAF 커스텀 규칙부터 확인하세요. cloudflared는 전달하는 요청을 전부 로그에 남기므로, 로그가 비어 있다면 요청이 거기까지 오지도 못한 겁니다.

할 수 없는 것
#

  • 공개 인터넷에서 오는 UDP. 공개 터널 호스트명은 HTTP(S)(그리고 WebSocket과 gRPC)를 실어 나릅니다. 제 제품의 디바이스는 보통 UDP 위의 CoAP로 체크인하는데, 그건 터널을 통과할 수 없습니다. 로컬 사본에 붙은 디바이스는 HTTPS 체크인으로 폴백하고, 그 대가로 체크인마다 몇 초씩 더 듭니다. 아이디어가 게임 서버나 VoIP라면 터널은 맞는 도구가 아닙니다.
  • 가동 시간. 결국 노트북입니다. 덮개를 닫으면 사이트가 내려가고, Cloudflare가 530 에러 페이지를 보여줍니다. 데모나 열 명짜리 베타, 스테이징 사본이라면 괜찮습니다. 누군가 의존하는 것이라면 괜찮지 않습니다.
  • 빌드에 박혀 들어간 것. 모바일 앱의 업데이트 URL은 네이티브 설정이라, 터널 URL로 만든 빌드만 그걸 씁니다. 스토어 빌드는 계속 프로덕션과 통신하고, 그래서 이 구성을 안심하고 시도해 볼 수 있었습니다.

비용: Cloudflare Tunnel은 무료인가
#

무료 플랜의 Cloudflare 존, 터널, 인증서, DNS 레코드: $0. 머신은 원래 켜져 있었습니다. 진짜 비용은 이제 내가 가동 시간 담당 팀이 된다는 것 하나뿐입니다.

대신 아껴 주는 건 “내 머신에서는 잘 돼요"와 “돈 내고 호스팅할 가치가 있다” 사이의 구간입니다. 테스터나 디자이너, 첫 고객에게 같은 날 오후에 링크를 보낼 수 있습니다. 아무도 관심이 없으면 잃은 게 없습니다. 관심이 있다면, 진짜 호스트로 옮기는 건 변경 하나입니다. CNAME을 다른 곳으로 향하게 하면 됩니다.