7 min read

사설IP 만으로 포트포워딩 없이 caddy쓰기

사설IP 만으로 포트포워딩 없이 caddy쓰기
Photo by Luo Jin Hong / Unsplash

Cloudflare Tunnel + Caddy로 포트포워딩 없이 홈서버 운영하기

사설 IP VM에서 공인 IP, 포트포워딩 없이 외부에 서비스를 노출하는 방법을 정리합니다.

왜 Cloudflare Tunnel인가?

홈서버같은 내부 VM을 외부에 노출하려면 보통 공유기에서 80/443 포트포워딩을 해야 합니다. 하지만 다음과 같은 상황에서는 포트포워딩 자체가 불가능하거나 불편합니다.

  • 사무실 네트워크 (관리자 권한 없음)
  • ISP에서 포트 차단
  • 보안 정책으로 인바운드 포트 오픈 금지

Cloudflare Tunnel을 사용하면 VM 내부에서 Cloudflare 엣지로 아웃바운드 연결만 맺기 때문에 인바운드 포트를 전혀 열 필요가 없습니다.

최종 구성도

사용자 (HTTPS)
    ↓
Cloudflare Edge (TLS 처리)
    ↓
cloudflared 컨테이너 (터널)
    ↓
caddy 컨테이너 (reverse proxy, Host 헤더로 서비스 분기)
    ↓
각 서비스 컨테이너

환경

  • VM(리눅스 서버): uman (Proxmox 위의 Ubuntu 24.04)
  • 네트워크: Tailscale로 홈랩 노드들과 연결, 사무실 환경 (포트포워딩 불가)
  • Docker: Standalone Swarm (단일 노드 manager)
  • 도메인: miestas.co.kr (Cloudflare DNS)

1단계: Cloudflare Zero Trust 터널 생성

Cloudflare 대시보드에서 Zero Trust → Networks → Tunnels → 터널 만들기로 이동합니다.

터널 유형은 Cloudflared를 선택하고 이름을 지정합니다. (여기서는 uman)

설치 방식은 Docker를 선택하면 다음과 같은 실행 명령이 나옵니다.

docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token [TOKEN]

이 토큰은 잠시 후 Docker secret으로 안전하게 등록합니다. 메모.


2단계: DNS 레코드 정리

기존에 miestas.co.kr에 A 레코드가 있으면 Tunnel 등록 시 충돌이 납니다.
Cloudflare DNS 관리 페이지에서 기존 A 레코드와 CNAME을 모두 삭제합니다.

삭제 대상:

  • miestas.co.kr A 레코드
  • www A 레코드
  • * CNAME 레코드
Tunnel Public Hostname을 등록하면 Cloudflare가 CNAME 레코드를 자동으로 생성해줍니다. ip등록을 하지 않아도 cloudflare가 터널로 연결 해줌

3단계: Docker Swarm 초기화 및 네트워크 구성

uman VM에서 Swarm을 초기화하고 overlay 네트워크를 만듭니다.

# Swarm 초기화 (Tailscale IP 사용)
docker swarm init --advertise-addr [TAILSCALE_IP]:2377

# proxy 오버레이 네트워크 생성
docker network create --driver=overlay --attachable proxy


4단계: Cloudflare 터널 토큰 Docker secret 등록

토큰을 yml 파일에 직접 넣으면 보안상 위험합니다. Docker secret으로 관리합니다.

echo "터널토큰값" | docker secret create cloudflared_token -

# 확인
docker secret ls

5단계: docker-compose(stack) 파일 작성

caddy.yml 파일을 작성합니다. 핵심은 cloudflared와 caddy가 같은 proxy 네트워크에 있어야 한다는 점입니다. cloudflared가 caddy:80으로 트래픽을 넘길 수 있어야 하기 때문입니다.

version: "3.8"
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN_FILE=/run/secrets/cloudflared_token
    secrets:
      - cloudflared_token
    networks:
      - proxy
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == uman
      restart_policy:
        condition: on-failure

  caddy:
    image: lucaslorentz/caddy-docker-proxy:ci-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /data/caddy/data:/data
      - /data/caddy/config:/config
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy
    environment:
      - TZ=Asia/Seoul
      - CADDY_INGRESS_NETWORKS=proxy
      - CADDY_DOCKER_PROXY_SERVICE_TASKS=false
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.hostname == uman
      restart_policy:
        condition: on-failure
    labels:
      caddy.admin: "0.0.0.0:2019"
      caddy.auto_https: "off"      # Tunnel 사용 시 caddy TLS 불필요

  whoami:
    image: traefik/whoami
    networks:
      - proxy
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure
    labels:
      caddy: "http://who.miestas.co.kr"
      caddy.reverse_proxy: "{{upstreams 80}}"

networks:
  proxy:
    external: true

secrets:
  cloudflared_token:
    external: true

핵심 포인트

항목 설명
caddy.auto_https: "off" Cloudflare가 TLS 처리하므로 caddy ACME 불필요
http:// prefix auto_https off 상태에서 HTTP로 명시
같은 proxy 네트워크 cloudflared → caddy:80 통신을 위해 필수

볼륨 디렉토리 생성:

mkdir -p /data/caddy/data
mkdir -p /data/caddy/config

스택 배포:

docker stack deploy -c caddy.yml uman

6단계: Cloudflare Tunnel Public Hostname 등록

Zero Trust → Tunnels → uman → 게시된 애플리케이션 경로 추가

각 서비스별로 추가합니다.

하위 도메인 도메인 유형 URL
who miestas.co.kr HTTP caddy:80
caddy miestas.co.kr HTTP caddy:80
URL을 localhost:80이 아닌 caddy:80으로 설정해야 합니다.
Docker 컨테이너끼리는 localhost를 공유하지 않습니다. 같은 overlay 네트워크에서 서비스명으로 통신합니다.

등록하면 Cloudflare가 자동으로 CNAME DNS 레코드를 생성합니다.


7단계: SSL/TLS 암호화 모드 변경

Cloudflare SSL/TLS → 개요에서 암호화 모드를 Flexible로 변경합니다.

모드 설명
Full Cloudflare → 원본 서버도 HTTPS 요구 → caddy가 HTTP만 받으면 502 발생
Flexible 사용자 ↔ Cloudflare는 HTTPS, Cloudflare → 원본은 HTTP 허용

Tunnel 구조에서는 Flexible이 적합합니다.


동작 확인

curl -v http://who.miestas.co.kr
< HTTP/1.1 200 OK
< via: 1.1 Caddy
< Server: cloudflare
< CF-RAY: ...

via: 1.1 Caddy 헤더가 보이면 성공입니다.


새 서비스 추가 방법

이후 서비스를 추가할 때는 두 가지만 하면 됩니다.

1. caddy.yml에 서비스 추가

  myservice:
    image: myimage
    networks:
      - proxy
    deploy:
      replicas: 1
    labels:
      caddy: "http://myservice.miestas.co.kr"
      caddy.reverse_proxy: "{{upstreams 8080}}"

2. Cloudflare Tunnel에 hostname 추가

  • myservice.miestas.co.kr → HTTP → caddy:80

끝입니다. 포트포워딩, 인증서 발급, DNS 수동 설정 모두 불필요합니다.


트러블슈팅

502 Bad Gateway

  • Cloudflare SSL 모드가 Full인지 확인 → Flexible로 변경
  • cloudflared URL이 localhost:80으로 되어 있는지 확인 → caddy:80으로 변경

DNS NXDOMAIN (인증서 발급 실패)

  • caddy에 caddy.auto_https: "off" 설정 확인
  • 서비스 label에 http:// prefix 확인

cloudflared → caddy 통신 불가

두 컨테이너가 같은 overlay 네트워크에 있는지 확인

docker inspect [container_id] | grep -A 10 Networks

마무리

Cloudflare Tunnel을 사용하면 공인 IP나 포트포워딩 없이도 안전하게 서비스를 외부에 노출할 수 있습니다. 특히 사무실처럼 네트워크 제약이 있는 환경에서 매우 유용합니다.

lucaslorentz/caddy-docker-proxy와 조합하면 새 서비스를 label 몇 줄로 바로 추가할 수 있어 관리도 편합니다.