---
title: "Cloudflare Zero Trust Tunnel로 집 서버 안전하게 공개하기"
description: "Cloudflare Zero Trust Tunnel을 활용하면 라우터 포트포워딩 없이도 집 서버를 안전하게 외부로 공개할 수 있습니다. 서버가 직접 아웃바운드 연결을 맺어 공인 IP 노출을 막고, Cloudflare 엣지 네트워크를 통해 TLS 인증과 DDoS 방어까지 한 번에 해결하는 효율적인 보안 구성 방법을 소개합니다"
date: 2026-06-14
updated: 2026-06-14T09:00:00.000Z
tags: [cloudflare, selfhosting, security, devops]
canonical: https://blog.wooncloud.com/posts/cloudflare-zero-trust-tunnel
---

![Cloudflare Zero Trust Tunnel로 집 서버 안전하게 공개하기](/images/posts/cloudflare-zero-trust-tunnel/8d7ecd0e-1848-4e1b-9d4b-bd475a768726.webp)

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

남는 미니 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의 엣지 네트워크로 **나가는** 연결(보통 QUIC/HTTP2 over 443/7844)을 맺고 그 연결을 계속 유지한다.

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

```text
[ 방문자 ] → 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 실행

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

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

```bash
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 파일에는 값을 직접 쓰지 않는다.

```yaml
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에 절대 커밋하지 않는다).

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

여기서 주목할 두 가지.

- **`app`은 `ports`가 아니라 `expose`를 쓴다.** `ports`는 호스트 포트를 여는 것이고, `expose`는 같은 도커 네트워크 내부에만 포트를 보이게 한다. 외부로 직접 노출되는 포트가 0이 된다.
- **ingress의 대상은 `localhost`가 아니라 서비스 이름이다.** 같은 compose 네트워크에서는 `cloudflared`가 `http://app:3000`으로 앱에 닿는다. 대시보드의 터널 설정에서 서비스 URL을 `http://app:3000`으로 지정하면 된다. (`cloudflared`를 호스트에 직접 띄운 경우라면 `http://localhost:3000`.)

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

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

```yaml
# 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`과 자격증명을 볼륨으로 마운트한다.

```yaml
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을 한 번 직접 걸어줘야 한다.

```bash
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 이메일 등을 신원 공급자로 연결한다.
- **정책 작성**: "이메일이 `me@example.com`인 사용자만 허용" 같은 규칙을 만든다.

이렇게 하면 흐름은 이렇게 바뀐다.

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

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

### 다층 방어로 쓰기

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

## 정리: 이점과 주의점

### 이점

| 항목 | 전통적 방식 | Cloudflare 터널 |
| --- | --- | --- |
| 인바운드 개방 포트 | 1개 이상 (예: 443) | **0개** |
| 서버 공인 IP | DNS에 노출 | **은닉** (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를 노출하는 것보다, 이 트레이드오프가 훨씬 합리적인 선택인 경우가 많다.
