사내망 안에 있는 운영서버에 스택을 설치해야 하는데, 접속 경로가 문제였다. 공인 IP도 포트포워딩도 없는 서버에 어떻게 밖에서 들어가는가. Cloudflare Tunnel로 이 문제를 없앤 과정과, 그 사이에 만난 530 에러의 진짜 원인을 정리한다.

1. 무엇을 대체했나

지금까지 개발 서버는 클라우드 VM이었다. 공인 IP가 있으니 nginx를 세우고 방화벽에 포트를 열면 끝이었다. 그런데 운영 환경은 사내망 안의 물리 서버다. 공인 IP가 없고, 방화벽에 포트를 열려면 결재가 필요하고, 열어봐야 그 자체가 공격 표면이 된다.

그래서 임시로 쓰던 경로가 이랬다.

[내 노트북] → 크롬 원격데스크탑 → [사내 Windows PC] → PowerShell → ssh your-user@192.168.1.50

동작은 한다. 문제는 이게 화면 공유 위에서 터미널을 치는 구조라는 것이다. 파일을 올릴 수 없고, 포트 포워딩이 안 되고, IDE의 원격 편집도 붙지 않는다. compose 파일 하나 올리려고 화면 너머로 내용을 옮겨 적고 있으면 뭔가 잘못됐다는 생각이 든다.

목표 구조는 이렇다.

[내 노트북] --ssh ssh.example.com--> [Cloudflare 엣지] <--아웃바운드 터널-- [사내 서버] --> localhost:22

핵심은 방향이다. 전통적인 포트포워딩은 "밖에서 안으로" 들어오는 연결을 허용해야 한다. Cloudflare Tunnel은 반대로 서버가 밖으로 나가서 연결을 잡아둔다. 사내망 방화벽 입장에서는 평범한 아웃바운드 HTTPS 연결 하나가 늘어난 것뿐이라, 인바운드 규칙을 건드릴 일이 없다.

재미있는 점은 사설 IP(192.168.1.50)가 최종 설정 어디에도 등장하지 않는다는 것이다. cloudflared가 서버 안에서 localhost:22로 접속하기 때문이다. 사내망 IP 대역이 바뀌어도 터널 설정은 손댈 필요가 없다.

2. 사전 준비 — 도메인을 Cloudflare에 올린다

터널을 쓰려면 도메인이 Cloudflare에서 관리되어야 한다. 대시보드에서 사이트를 추가하고, 도메인 등록기관에서 네임서버를 Cloudflare가 지정한 값으로 바꾼다. 존 상태가 Active가 되어야 이후 단계가 동작한다. 전파에 수 분에서 수 시간이 걸린다.

무료 플랜으로 충분하다. Tunnel도, 공개 호스트명도, 뒤에 나올 Access 인증도 무료 범위다.

3. 서버에 터널 올리기

Ubuntu 기준이다.

curl -L https://pkg.cloudflare.com/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb

# 계정 인증. URL이 한 줄 출력된다.
cloudflared tunnel login

여기서 작은 문제가 있다. 헤드리스 서버에는 브라우저가 없다. 출력된 URL을 복사해서 아무 브라우저에서나 열어 승인하면 된다. 나는 어차피 원격데스크탑으로 들어와 있었으니 그 안의 브라우저에 붙여넣었다. 승인하면 서버에 ~/.cloudflared/cert.pem이 생긴다.

cloudflared tunnel create <터널이름>

# 토큰 추출
sudo mkdir -p /etc/cloudflared
cloudflared tunnel token <터널이름> | sudo tee /etc/cloudflared/token > /dev/null
sudo chmod 0600 /etc/cloudflared/token

# 서비스 등록
sudo cloudflared service install
sudo systemctl enable --now cloudflared
systemctl status cloudflared

Registered tunnel connection 로그가 뜨면 터널이 엣지에 붙은 것이다. 나는 서울 PoP 두 곳에 커넥션이 잡혔다.

4. 토큰 방식과 로컬 관리형 — 나중에 발목을 잡는 구분

여기서 한 번 멈추고 정리해야 할 것이 있다. cloudflared 터널은 운영 방식이 두 가지이고, 어느 쪽이냐에 따라 라우팅 규칙을 고치는 장소가 다르다. 이걸 모르면 뒤에 나올 530에서 한참 헤맨다.

로컬 관리형 토큰 방식(원격 관리형)
실행 cloudflared tunnel run <이름> cloudflared tunnel run --token-file <파일>
서버에 필요한 것 자격증명 JSON + config.yml 토큰 파일 하나
라우팅 규칙 위치 서버의 config.yml Cloudflare 서버(대시보드)
변경 반영 파일 수정 후 재시작 대시보드 저장 즉시, 재시작 불필요

토큰을 디코드해 보면 왜 그런지 바로 보인다.

sudo cat /etc/cloudflared/token | base64 -d
{"a":"<계정 태그>","t":"<터널 ID>","s":"<비밀키>"}

신원 증명 세 개뿐이고 라우팅 정보가 없다. 그러니 cloudflared는 접속하면서 Cloudflare 쪽에 저장된 설정을 내려받을 수밖에 없다. 토큰 방식으로 도는 터널의 로컬 config.yml을 아무리 고쳐도 아무 일도 일어나지 않는 이유다.

운영서버에는 토큰 방식이 유리하다. 서버에 남는 비밀이 토큰 하나뿐이고, 라우팅을 바꾸는 데 SSH 접속이 필요 없다.

5. 호스트명 연결

Zero Trust 대시보드 → Networks → Tunnels → 해당 터널 → 공개 호스트 이름 탭 → 추가.

필드
서브도메인 / 도메인 ssh / example.com
경로 비워 둔다
서비스 URL ssh://localhost:22

첫 번째 함정이 여기 있다. 새 UI에는 유형(Type) 드롭다운이 따로 없고 서비스 URL에 스킴을 직접 쓴다. localhost:22만 넣으면 SSH로 라우팅되지 않는다. ssh://를 반드시 붙인다.

경로 필드는 비운다. 경로 매칭은 HTTP 전용 개념이라 SSH에는 해당이 없다.

저장하면 DNS 레코드가 자동으로 생긴다.

ssh.example.com  →  CNAME  <터널ID>.cfargotunnel.com   (Proxied, 주황 구름)

이 CNAME은 일반적인 DNS 레코드와 의미가 다르다. cfargotunnel.com은 실제로 리졸브되는 주소가 아니라, Cloudflare 내부에서 "이 호스트명의 트래픽을 이 터널로 보내라"를 기록하는 포인터다. 그래서 반드시 Proxied 상태여야 하고, 회색 구름(DNS only)으로 바꾸면 즉시 끊긴다.

A 레코드는 만들지 않는다. 서버에 공인 IP가 없고, 애초에 IP를 DNS에 넣을 일이 없다.

6. 클라이언트 설정

클라이언트에도 cloudflared가 필요하다.

brew install cloudflared

~/.ssh/config:

Host prod-ssh
  HostName ssh.example.com
  User your-user
  ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h

이후 ssh prod-ssh 한 줄이다. SSH 입장에서 ProxyCommand는 그냥 stdin/stdout 파이프이므로, scp, rsync, VS Code Remote-SSH가 전부 그대로 동작한다. 원래 목적이었던 작업 채널이 이 시점에 복원된다.

config 등록 없이 쓰는 변형도 있다.

# 1회성 접속이나 스크립트용
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" your-user@ssh.example.com

# cloudflared 옵션을 모르는 GUI 도구용 — 도구에게는 localhost:2222가 평범한 SSH 서버로 보인다
cloudflared access tcp --hostname ssh.example.com --url localhost:2222
ssh -p 2222 your-user@localhost

7. 막힌 지점 — 530은 무엇을 뜻했나

여기까지 하고 접속했더니 이게 나왔다.

websocket: bad handshake
Connection closed by UNKNOWN port 65535

서버에서 systemctl status cloudflared는 완벽하게 정상이었다. 헬스체크 로그가 전부 성공이고 커넥션도 잡혀 있었다. 그런데 접속이 안 된다.

이 메시지의 위치부터 파악해야 한다. cloudflared 클라이언트는 엣지와 웹소켓으로 연결하는데, 엣지가 101 Upgrade 대신 HTTP 에러를 반환하면 이 메시지가 난다. 즉 실패 지점은 내 노트북과 Cloudflare 엣지 사이이고, 서버 쪽 터널 상태와는 독립이다. 터널(서버→엣지)과 접속 (클라이언트→엣지)은 별개의 두 다리이고, 한쪽 다리가 건강하다고 전체 경로가 보장되지 않는다.

두 명령으로 구간을 좁혔다.

dig +short ssh.example.com
# 104.21.x.x
# 172.67.x.x            ← Cloudflare IP. 존도 레코드도 정상

curl -s -o /dev/null -w '%{http_code}\n' https://ssh.example.com
# 530                   ← Cloudflare 에러 1033

DNS 층이 통째로 용의선상에서 빠지고, 원인이 "CNAME이 가리키는 터널이 이 호스트명을 서빙하지 않음"으로 좁혀졌다.

원인 — 반쪽짜리 설정 두 개

터널 목록을 봤더니 두 개가 있었다.

ID          NAME      CREATED               CONNECTIONS
1a2b3c4d-…  tunnel-a  2026-08-04T12:43:31Z  2xicn05, 2xicn06
9f8e7d6c-…  tunnel-b  2026-08-05T05:16:35Z
  • tunnel-a: 전날 만들어져 실행 중. 하지만 라우팅이 하나도 없다.
  • tunnel-b: 오늘 CLI로 만들면서 DNS를 연결했다. 하지만 실행되지 않는다.

CNAME은 tunnel-b를 가리키는데 살아 있는 건 tunnel-a다. 트래픽이 죽은 터널로 가고 있었다.

DNS가 가리키는 곳:  tunnel-b (죽어 있음)
실제로 도는 프로세스: tunnel-a (라우팅 없음)
→ 1033 / 530

530의 원인은 설정이 없어서가 아니라 설정이 두 갈래로 만들어졌기 때문이었다. 전날의 대시보드 흔적과 당일의 CLI 작업이 각각 반쪽으로 존재했고, 어느 쪽도 혼자서는 완성이 아니었다. 인프라 문제의 상당수가 이렇다. "없는 설정"이 아니라 "경합하는 설정"에서 나온다.

중간에 죽은 가설들

기록을 위해 남긴다. 진단이 곧게 진행되지 않았다.

가설 판정 무엇이 뒤집었나
실행 중인 터널은 대시보드에서 만든 것이다 기각 Zero Trust가 온보딩조차 안 된 환영 화면 상태였다. 대시보드에서 만들 수가 없었다
납품 장비라 벤더가 원격 지원용으로 심어둔 터널이다 기각 토큰을 디코드하니 계정 태그가 내 계정과 일치했다
서비스를 지우고 새로 깔면 된다 보류 후 폐기 정체를 모르는 상태에서 지우면 남의 통로를 끊을 수 있었다. 확인이 먼저였다

두 번째 가설 때문에 한동안 "기존 서비스는 건드리지 말고 별도 systemd 유닛을 추가하자"는 우회 계획까지 세웠다. 기각 비용은 base64 -d 한 줄이었다. 가설의 무게에 비해 검증 비용이 이렇게 싸다면 검증을 먼저 해야 한다.

여담이지만 처음 sudo cloudflared service install이 "이미 설치되어 있다"고 거부했을 때, 그 자리에서 service uninstall을 실행하지 않은 것이 결과적으로 옳았다. 에러 메시지가 친절하게 uninstall 방법을 알려주더라도, 지우기 전에 그게 무엇인지 확인하는 순서는 바뀌지 않는다.

해법

살아 있는 쪽에 라우팅을 붙이는 것이었다. 대시보드 터널 목록에서 tunnel-a는 "정상 / 경로 없음"이었다. 살아 있지만 아무것도 서빙하지 않는 빈 터널이라는 뜻이라, 재사용해도 잃을 게 없었다.

순서가 중요하다.

  1. DNS에서 죽은 터널을 가리키는 기존 CNAME을 먼저 삭제한다. 안 지우면 새 공개 호스트명 추가가 충돌하거나, 성공해도 기존 레코드가 계속 죽은 쪽으로 라우팅한다.
  2. 살아 있는 터널에 공개 호스트명을 추가한다. CNAME이 올바른 터널 ID로 자동 재생성된다.
  3. curl로 확인한다.
  4. 쓰지 않는 터널과 잔재 파일을 정리한다.

터널을 늘리는 게 아니라 경로를 늘리는 것. 이게 이 도구의 문법이다.

8. Access — 인증 게이트 얹기

여기서 끝내면 안 된다. 이 상태는 호스트명을 아는 사람 누구나 sshd 앞까지 도달할 수 있고, 방어선은 SSH 인증 하나뿐이다. 포트 스캔에 안 잡힐 뿐, 사실상 22번 포트를 인터넷에 공개한 것과 노출 수준이 비슷하다.

오해하기 쉬운 지점이 있다. Cloudflare Access는 브라우저 접근용 기능이 아니다. 모든 접근 경로 앞에 붙는 인증 게이트이고, 브라우저 터미널은 그 안의 부가 기능 하나일 뿐이다.

Access 없이도 터널 연결은 완전히 동작한다. 명령 이름이 cloudflared access ssh라서 Access 설정이 필수처럼 보이지만, 해당 호스트명에 Access 앱이 없으면 인증 요구 없이 그냥 통과시킨다. 실제로 앱을 만들기 전에는 클라이언트가 이렇게 답했다.

failed to find Access application at https://ssh.example.com

이 메시지는 실패가 아니다. 브라우저 인증창이 뜨지 않는 것이 곧 Access 미적용의 증거이고, 반대로 창이 뜨기 시작한 것이 Access가 실제로 걸렸다는 확인이 된다.

설정

Zero Trust 온보딩(Free 플랜)을 먼저 마친다. 온보딩이 팀 도메인을 발급하는데, 자동 생성값이 배정되므로 의미 있는 이름을 쓰려면 이때 정하는 게 좋다. 나중에 바꾸면 기존 애플리케이션의 로그인 주소가 함께 바뀐다.

Access → Applications → Add an application → Self-hosted:

항목
Application name prod-ssh
Public hostname ssh / example.com / Path 비움
Session Duration 24 hours
Policy Action Allow, Include → Emails → 운영자 이메일

Identity provider를 따로 설정할 필요가 없다. Cloudflare가 기본 제공하는 One-time PIN이 있어서, 허용된 이메일로 6자리 코드를 보내 소유를 증명시킨다. Google SSO 등은 나중에 붙여도 애플리케이션 설정은 그대로다.

저장 즉시 적용되고, 서버도 클라이언트도 재시작하거나 설정을 바꾸지 않는다. ~/.ssh/config는 한 글자도 건드리지 않았는데 SSH 핸드셰이크 앞에 인증 층이 하나 생긴다. 클라이언트 재배포 없이 접근 정책을 바꿀 수 있다는 뜻이다.

적용 확인은 curl 응답이 바뀌는 것으로 한다.

curl -s -o /dev/null -w '%{http_code}\n' https://ssh.example.com
# 302   ← 인증 페이지로 리다이렉트

이 구축에서 curl 코드 세 개를 모두 관측했는데, 각각이 다른 층을 가리킨다.

코드 의미
530 라우팅 실패. CNAME이 가리키는 터널이 이 호스트명을 서빙하지 않음
302 라우팅 정상 + Access 인증 페이지로 리다이렉트
200 / 404 라우팅 정상, Access 없음(그대로 통과)

9. 인증 흐름의 함정 두 개

Access 적용 후 ssh prod-ssh를 실행하면 ProxyCommand의 cloudflared가 캐시된 토큰이 없음을 확인하고 자동으로 브라우저를 연다. 별도 명령을 몰라도 인증창은 뜬다. 그런데 이렇게 됐다.

Connection timed out during banner exchange
Connection to UNKNOWN port 65535 timed out

SSH에는 배너 교환 타임아웃이 있다. 메일함을 열어 코드를 확인하는 동안 세션이 먼저 죽는다. 그래서 첫 인증이나 세션 만료 후에는 인증 전용 명령을 따로 쓴다.

cloudflared access login https://ssh.example.com

두 번째 함정은 잠금이다. 인증 대기 중인 프로세스가 있는 상태에서 다른 터미널이 같은 호스트명에 접속하면 이렇게 막힌다.

Another cloudflared process (pid 66519) is already waiting for authentication.

에러가 아니라 잠금 충돌이다. 열려 있는 브라우저에서 인증을 마치면 해소되고, 프로세스가 비정상 종료해 스테일 잠금이 남았다면 ~/.cloudflared/*.lock을 지운다.

성공 판별은 토큰 파일의 존재로 한다. ~/.cloudflared/에 확장자 없는 …-token 파일이 생겨야 인증이 끝난 것이다. -token.url.lock만 있으면 아직 미인증이다. 이걸 모르고 "브라우저는 떴는데 왜 안 되지"를 반복하기 쉽다.

여기까지 오면 세 층이 각각 다른 메시지를 낸다는 게 정리된다.

출력 실패한 층
websocket: bad handshake / 530 엣지 라우팅
banner exchange 타임아웃 Access 인증
Permission denied (publickey,password) SSH 인증 (앞의 두 층은 통과했다는 뜻)

마지막 줄이 특히 유용하다. 배치 모드로 접속했을 때 이 메시지가 나오면 성공 신호다. 터널과 Access를 모두 통과해 서버의 sshd 인증 단계까지 도달했다는 뜻이고, 단지 비밀번호를 넣지 않았을 뿐이다.

10. 최종 상태와 남은 일

서버에 남은 cloudflared 관련 파일은 토큰 하나다. 정리하면서 지운 것들:

지운 것
config.yml 토큰 방식에서는 읽히지 않는다
폐기한 터널의 자격증명 JSON 터널 자체를 삭제했다
~/.cloudflared/cert.pem 아래 참조

cert.pem은 계정 관리용 인증서로, 터널을 만들고 지우고 DNS를 연결하는 관리 명령에만 쓰인다. 토큰 방식으로 도는 터널은 이 파일을 전혀 읽지 않으므로 지워도 터널은 멀쩡하다. 반대로 이게 서버에 있으면 서버에 접근한 누구든 계정의 터널과 도메인 DNS를 조작할 수 있다. 운영 호스트에는 가동에 필요한 최소 자격만 남기는 편이 맞다.

삭제 순서에 주의한다. cloudflared tunnel deletecert.pem을 필요로 하므로, 터널 정리를 끝낸 뒤 마지막에 지운다. 제거 후에는 그 서버에서 관리 명령이 안 되는데, 고장이 아니라 의도한 상태다. 필요하면 다시 로그인하거나 대시보드에서 하면 된다.

남은 일:

  1. SSH 키 인증 전환. 지금은 비밀번호로 로그인한다. 공개키를 올리고 안정화 후 PasswordAuthentication no로 잠글 계획이다. Access가 앞을 막고 있어 급하진 않지만, 방어선은 자격 기반이 낫다.
  2. 회귀 경로 유지. 원격데스크탑 경로는 터널이 며칠 안정적으로 붙는 걸 확인한 뒤에 정리한다. 지금 지우면 터널 장애 시 서버에 들어갈 방법이 없다.
  3. 재부팅 생존 테스트. systemctl is-enabled가 enabled인 것까지만 확인했다. 정전이나 업데이트 후 무인 복구가 운영서버의 요건이므로 실제로 한 번 재부팅해 봐야 한다.

그리고 나중에 채팅 서버를 외부에 열 때는 터널을 새로 만들지 않는다. 같은 터널에 공개 호스트명을 하나 더 추가하면 된다.

필드
호스트명 chat.example.com
서비스 URL http://localhost:8065

TLS는 Cloudflare가 종단하므로 인증서 발급이 필요 없고, 웹소켓도 터널을 그대로 통과한다. 다만 전 직원이 쓰는 호스트명에 Access를 걸면 §9의 One-time PIN 흐름을 모두에게 강요하는 꼴이 되니, 거기엔 걸지 않거나 느슨하게 잡아야 한다.

관련 글