본문으로 건너뛰기
wooncloud

Cloudflare Zero Trust Tunnel로 집 서버 안전하게 공개하기

Cloudflare Zero Trust Tunnel 은 인바운드 포트를 0개로 만들어 서버가 아웃바운드 연결만 맺고, TLS·WAF·DDoS 방어는 엣지가 맡으며 Access 로 /admin 같은 경로를 엣지에서 먼저 인증합니다.

·22 min read·— views··

들어가며: 집 서버를 인터넷에 노출한다는 것

남는 미니 PC나 라즈베리파이에 웹 서비스를 올려두면, 다음 단계는 항상 같은 질문으로 이어집니다. "이걸 어떻게 외부에서 접속하게 만들까?"

전통적인 답은 정해져 있습니다. 그리고 그 답은 생각보다 손이 많이 갑니다.

전통적 방식의 체크리스트

집에서 돌아가는 서버를 인터넷에 공개하려면 보통 이런 단계를 거칩니다.

  1. 공인 IP 확보 — ISP가 주는 IP가 공인 IP여야 합니다. CGNAT(공유 공인 IP) 환경이면 포트포워딩 자체가 불가능하거나, ISP에 별도 요청을 해야 합니다.
  2. 포트포워딩 — 라우터 관리 페이지에 들어가 "외부 443 → 내부 192.168.x.x:443" 식으로 매핑을 겁니다. 라우터 펌웨어마다 UI가 제각각이고, 공유기를 바꾸면 다시 설정해야 합니다.
  3. 동적 DNS(DDNS) — 가정용 회선은 IP가 주기적으로 바뀝니다. home.example.com이 항상 현재 IP를 가리키게 하려면 DDNS 클라이언트를 돌려야 합니다.
  4. TLS 인증서 — HTTPS를 쓰려면 Let's Encrypt 등으로 인증서를 발급·갱신해야 합니다. 보통 certbot이나 리버스 프록시(Caddy/Nginx)에 위임합니다.
  5. 방화벽 규칙 — 열어둔 포트로 들어오는 트래픽을 필터링해야 합니다.

각 단계가 동작은 합니다. 문제는 이 조합이 만들어내는 공격 표면입니다.

무엇이 위험한가

전통적 방식의 핵심 문제는 서버의 진짜 IP가 인터넷에 그대로 드러난다는 점입니다.

  • IP 직접 노출: DNS에 가정용 회선의 공인 IP가 박힙니다. 그 IP는 곧 우리 집 회선이고, 같은 회선에 있는 다른 기기까지 같은 IP를 공유합니다.
  • 포트 스캔의 표적: 인터넷에 열린 포트는 24시간 스캔당합니다. 443, 22, 8080 같은 포트를 열어두면 봇이 자동으로 SSH 브루트포스, 알려진 CVE 익스플로잇, 취약점 스캔을 시도합니다. 로그를 켜보면 분 단위로 낯선 IP가 두드리는 걸 볼 수 있습니다.
  • 패치 레이스: 노출된 서비스에 취약점이 발견되면, 공격자가 자동화 스캔으로 찾아내기 전에 제가 패치해야 합니다. 이 레이스에서 항상 이길 수는 없습니다.
  • DDoS 직격: 공인 IP가 알려지면 애플리케이션 계층뿐 아니라 회선 자체를 향한 볼류메트릭 공격에도 무방비입니다.

요약하면, 전통적 방식은 "문을 여러 개 열고, 각 문 앞에 경비를 세우는" 구조입니다. 문이 많을수록 관리할 게 많고, 문이 거기 있다는 사실 자체가 위험입니다.

이 글에서 다룰 Cloudflare Zero Trust Tunnel은 접근법을 뒤집습니다. 문을 하나도 열지 않습니다.

cloudflared 터널의 원리

핵심 아이디어는 한 문장으로 요약됩니다.

서버가 Cloudflare로 아웃바운드 연결만 맺고, 외부 요청은 그 연결을 역방향으로 타고 들어옵니다.

인바운드 0, 아웃바운드 1

집 서버에 cloudflared라는 경량 데몬을 띄웁니다. 이 데몬은 시작하자마자 Cloudflare 엣지로 나가는 연결을 포트 7844(기본 QUIC=UDP 7844, 막혀 있으면 HTTP/2=TCP 7844 로 자동 폴백)로 맺고 계속 유지합니다. TCP 443 은 터널 데이터 경로가 아니라 자동 업데이트·API 같은 부가 기능용이라, 막혀 있어도 터널은 정상 동작합니다.

방향이 중요합니다. 이건 우리가 평소에 웹 브라우저로 사이트에 접속하는 것과 같은 아웃바운드 방향입니다. 라우터/방화벽은 내부에서 밖으로 나가는 연결을 기본적으로 허용하기 때문에, 여기에는 아무 설정도 필요 없습니다.

[ 방문자 ] → HTTPS → [ Cloudflare 엣지 ] ⇄ (서버가 먼저 맺어둔 터널) ⇄ [ cloudflared @ 집 서버 ] → http://localhost:3000 [ 내 앱 ]

요청이 들어오는 흐름은 이렇습니다.

  1. 방문자가 app.example.com에 접속합니다. DNS는 Cloudflare 엣지를 가리킵니다 (서버 IP가 아닙니다).
  2. Cloudflare 엣지가 요청을 받습니다. 여기서 TLS 종료, WAF, DDoS 필터링이 일어납니다.
  3. 엣지는 이미 맺어져 있는 터널을 통해 요청을 집 서버의 cloudflared로 전달합니다.
  4. cloudflared가 ingress 규칙에 따라 요청을 로컬 서비스(http://localhost:3000)로 프록시합니다.
  5. 응답이 같은 경로를 역방향으로 돌아 나갑니다.

이 구조가 바꾸는 것

  • 라우터에 인바운드 포트를 단 하나도 열지 않습니다. 포트포워딩 설정이 통째로 사라집니다.
  • 서버의 공인 IP가 DNS에 나타나지 않습니다. 외부에서 보이는 건 Cloudflare의 IP뿐입니다. 포트 스캔을 해도 우리 집 회선이 아니라 Cloudflare를 두드리게 됩니다.
  • CGNAT 뒤에서도 동작합니다. 아웃바운드만 필요하므로 공인 IP가 없어도 됩니다. 이건 전통적 포트포워딩으로는 불가능했던 시나리오입니다.
  • 방화벽 정책이 단순해집니다. 인바운드 규칙을 다 닫아도 됩니다. 나가는 연결만 허용하면 끝입니다.

공격자 입장에서 보면, 두드릴 문이 사라진 것입니다. 서비스는 여전히 공개되어 있지만, 그 진입점은 전적으로 Cloudflare 엣지이고 우리 서버는 그 뒤에 숨습니다.

설정 단계 개요

실제 구성은 다음 순서로 진행됩니다. 콘솔(대시보드) 방식과 설정 파일 방식 두 가지가 있는데, 여기서는 둘 다 짚습니다.

  1. 터널 생성 — Cloudflare 계정에 터널 객체를 만듭니다. 터널은 고유 ID와 자격증명을 가집니다.
  2. 자격증명/토큰 확보 — 그 터널에 연결할 수 있는 토큰(또는 자격증명 JSON 파일)을 받습니다. cloudflared가 이걸로 자기 신원을 증명합니다.
  3. ingress 규칙 작성 — hostname → http://localhost:포트 매핑을 정의합니다. 어떤 도메인으로 들어온 요청을 어느 로컬 서비스로 보낼지 결정합니다.
  4. DNS 연결 — app.example.com이 터널을 가리키는 CNAME 레코드를 만듭니다. 대시보드 기반 터널이면 이 단계는 자동입니다.
  5. 상시 실행 — cloudflared를 데몬이나 도커 컨테이너로 띄워 항상 살아있게 합니다.

두 가지 운영 모드

Cloudflare 터널에는 두 가지 운영 방식이 있고, 코드 구성이 달라집니다.

  • 원격 관리(remote-managed) 터널 — ingress 규칙과 DNS를 Cloudflare 대시보드에서 관리합니다. 서버의 cloudflared는 토큰 하나만 들고 시작하면, 설정을 엣지에서 받아옵니다. DNS CNAME도 대시보드가 자동으로 만들어줍니다. 구성이 가장 단순해서 도커 환경에 잘 맞습니다.
  • 로컬 설정(locally-managed) 터널 — ingress 규칙을 서버의 config.yml에 직접 씁니다. 자격증명 JSON 파일과 함께 cloudflared tunnel run으로 실행합니다. 설정이 Git에 남아 버전 관리되는 게 장점입니다.

아래에서 두 방식의 코드를 모두 보여줍니다.

코드: 도커로 cloudflared 실행하기

도커 환경에서는 토큰 기반(원격 관리) 터널이 가장 깔끔합니다. 대시보드에서 터널을 만들면 다음과 같은 실행 명령을 토큰과 함께 제공합니다.

단독 docker 실행

docker run -d \
  --name cloudflared \
  --restart unless-stopped \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run --token <TUNNEL_TOKEN>

토큰은 명령줄에 직접 박기보다 환경변수로 주입하는 편이 안전합니다. cloudflared는 TUNNEL_TOKEN 환경변수를 자동으로 읽습니다.

docker run -d \
  --name cloudflared \
  --restart unless-stopped \
  -e TUNNEL_TOKEN="<TUNNEL_TOKEN>" \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run

docker-compose 서비스 골격

실제 셀프호스팅에서는 앱과 터널을 같은 compose 파일에 묶는 게 보통입니다. 토큰은 .env로 빼고, compose 파일에는 값을 직접 쓰지 않습니다.

services:
  app:
    image: my-app:latest
    restart: unless-stopped
    # 호스트에 포트를 열지 않는다. 같은 도커 네트워크 안에서만 노출.
    expose:
      - "3000"
 
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    depends_on:
      - app

.env 파일은 다음과 같이 둡니다 (Git에 절대 커밋하지 않습니다).

TUNNEL_TOKEN=eyJhIjoiABC...replace-with-your-token...XYZ

여기서 주목할 두 가지입니다.

  • app에 ports를 쓰지 않습니다. ports 가 없으면 호스트에 포트가 열리지 않아 외부로 직접 노출되는 포트가 0이 됩니다. 함께 적은 expose 는 선언(문서화)일 뿐이라 있으나 없으나 같은 compose 네트워크의 cloudflared 는 http://app:3000 으로 닿습니다 — 격리를 만드는 건 expose 가 아니라 ports 의 부재입니다.
  • ingress의 대상은 localhost가 아니라 서비스 이름입니다. 같은 compose 네트워크에서는 cloudflared가 http://app:3000으로 앱에 닿습니다. 대시보드의 터널 설정에서 서비스 URL을 http://app:3000으로 지정하면 됩니다. (cloudflared를 호스트에 직접 띄운 경우라면 http://localhost:3000입니다.)

로컬 설정 방식: config.yml과 자격증명

설정을 파일로 관리하고 싶다면 config.yml에 ingress 규칙을 직접 씁니다. 이 방식은 자격증명 JSON 파일(cloudflared tunnel create로 생성됨)과 함께 동작합니다.

# config.yml
tunnel: my-home-tunnel
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json
 
ingress:
  # 메인 앱
  - hostname: app.example.com
    service: http://localhost:3000
 
  # 다른 서비스 (예: 별도 포트의 대시보드)
  - hostname: grafana.example.com
    service: http://localhost:3001
 
  # 경로 단위 라우팅도 가능
  - hostname: api.example.com
    path: /v1/.*
    service: http://localhost:8080
 
  # 어떤 규칙에도 안 맞으면 404 (catch-all — 필수)
  - service: http_status:404

ingress 규칙은 위에서 아래로 순서대로 매칭되며, 맨 마지막의 catch-all 규칙은 반드시 있어야 합니다. 없으면 cloudflared가 설정을 거부합니다.

이 방식의 docker-compose는 config.yml과 자격증명을 볼륨으로 마운트합니다.

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel run my-home-tunnel
    volumes:
      - ./cloudflared:/etc/cloudflared:ro

./cloudflared 디렉토리 안에 config.yml과 <TUNNEL_ID>.json을 둡니다. 자격증명 파일은 비밀이므로 Git에서 제외합니다.

DNS 연결

로컬 설정 방식에서는 DNS CNAME을 한 번 직접 걸어줘야 합니다.

cloudflared tunnel route dns my-home-tunnel app.example.com

이 명령은 app.example.com을 <TUNNEL_ID>.cfargotunnel.com으로 가리키는 CNAME을 만듭니다. 원격 관리 터널이면 대시보드에서 hostname을 추가할 때 이 CNAME이 자동 생성되므로 이 단계를 건너뜁니다.

Zero Trust Access: 민감한 경로 게이팅

터널은 서비스를 "공개"합니다. 하지만 /admin이나 내부 대시보드처럼 아무나 접근하면 안 되는 경로가 있습니다. 여기서 Cloudflare Zero Trust Access가 한 겹을 더 얹습니다.

동작 방식

Access는 터널 앞단(엣지)에서 동작하는 인증 게이트입니다. 특정 hostname 또는 경로에 도달하기 전에, 방문자가 지정한 IdP로 로그인하도록 강제합니다.

  • 애플리케이션 정의: "app.example.com/admin 이하 경로"를 보호 대상 애플리케이션으로 등록합니다.
  • IdP 연결: Google, GitHub, 또는 일회용 PIN 이메일 등을 신원 공급자로 연결합니다.
  • 정책 작성: "이메일이 [email protected]인 사용자만 허용" 같은 규칙을 만듭니다.

이렇게 하면 흐름은 이렇게 바뀝니다.

  1. 방문자가 app.example.com/admin에 접근합니다.
  2. Cloudflare 엣지가 앱에 도달하기 전에 가로채서 로그인 화면을 띄웁니다.
  3. 방문자가 IdP로 로그인하고, 정책의 허용 조건을 만족해야만 통과합니다.
  4. 통과하면 그제서야 요청이 터널을 타고 집 서버에 닿습니다.

핵심은 인증이 우리 애플리케이션 코드 바깥, Cloudflare 엣지에서 끝난다는 점입니다. 우리 서버는 인증 통과한 요청만 받습니다. 미인증 트래픽은 집 회선에 도달조차 하지 못합니다.

다층 방어로 쓰기

실전에서는 Access를 1차 게이트로 쓰고, 애플리케이션 자체 인증을 2차로 두는 이중 구조가 견고합니다. Access가 엣지에서 신원을 한 번 거른 뒤, 앱이 세션/권한을 다시 검증합니다. 한쪽 설정에 실수가 있어도 다른 쪽이 막아주는 구조입니다.

정리: 이점과 주의점

이점

항목전통적 방식Cloudflare 터널
인바운드 개방 포트1개 이상 (예: 443)0개
서버 공인 IPDNS에 노출은닉 (Cloudflare IP만 보임)
TLS 인증서직접 발급·갱신엣지에서 자동
DDoS/WAF직접 구성Cloudflare가 흡수
CGNAT 환경사실상 불가동작함
포트포워딩/DDNS필요불필요

핵심 이점을 다시 정리하면.

  • 서버 IP 은닉: 외부에 보이는 진입점은 Cloudflare 엣지뿐입니다. 포트 스캔과 IP 기반 직접 공격이 원천 차단됩니다.
  • 자동 TLS: 인증서 발급·갱신을 엣지에 위임합니다. certbot 크론을 더 이상 돌리지 않아도 됩니다.
  • WAF/DDoS 보호: 악성 트래픽이 집 회선에 닿기 전에 Cloudflare가 흡수·필터링합니다.
  • 개방 포트 0: 라우터/방화벽 인바운드 규칙을 전부 닫아도 됩니다. 공격 표면이 극적으로 줄어듭니다.

주의점과 한계

장점이 분명한 만큼, 트레이드오프도 명확히 알고 들어가야 합니다.

  • 벤더 락인: 진입 경로 전체가 Cloudflare에 의존합니다. Cloudflare에 장애가 나거나 정책이 바뀌면 우리 서비스 접근도 영향을 받습니다. 진입점을 한 회사에 위임하는 구조라는 걸 받아들여야 합니다.
  • 무료 한도와 약관: 터널과 Access의 기본 기능은 무료 플랜으로 충분하지만, 이용 약관을 확인할 필요가 있습니다. 특히 비디오 스트리밍 등 대용량 비-HTML 콘텐츠를 프록시하는 용도는 표준 약관에서 제약을 받을 수 있습니다. 트래픽 성격이 일반적인 웹 앱을 벗어난다면 약관을 먼저 읽어 봅시다.
  • 프로세스 단일 장애점: cloudflared 프로세스가 죽으면 그 즉시 외부 접속이 끊깁니다. 포트포워딩과 달리, 이 데몬이 살아있는 것 자체가 가용성의 전제입니다. 반드시 restart: unless-stopped(또는 시스템 데몬이면 Restart=always)로 자동 재시작을 걸어두고, 가능하면 헬스체크/모니터링을 붙입니다.
  • 추가 홉으로 인한 지연: 모든 트래픽이 Cloudflare 엣지를 경유하므로, 직결 대비 약간의 레이턴시가 추가됩니다. 대부분의 셀프호스팅 용도에서는 무시할 수준이지만, 지연에 민감한 워크로드라면 인지하고 있어야 합니다.
  • 관측 가능성: 진입 트래픽이 엣지를 지나므로, 로컬에서만 로그를 보면 그림이 반쪽입니다. Cloudflare 대시보드의 터널/Access 로그를 함께 봐야 전체 흐름이 보입니다.

마치며

Cloudflare Zero Trust Tunnel의 본질은 "공개 방식을 인바운드에서 아웃바운드로 뒤집는 것"입니다. 문을 여러 개 열고 지키는 대신, 문을 전부 닫고 서버가 직접 밖으로 손을 뻗어 연결을 맺습니다. 그 결과 서버 IP는 숨고, 포트는 0개가 되고, TLS·WAF·DDoS 방어는 엣지가 가져갑니다.

대가는 Cloudflare에 대한 의존입니다. 하지만 집 서버를 인터넷에 공개하는 대부분의 시나리오에서, 포트포워딩과 DDNS와 인증서 갱신을 직접 관리하며 진짜 IP를 노출하는 것보다, 이 트레이드오프가 훨씬 합리적인 선택인 경우가 많습니다.