홈서버에 포트폴리오 안전하게 배포하기 [with Cloudflare Tunnel & Workers]

서론

개발자로 살아남기 위해서 개인 포트폴리오 관리는 필수적이라고 생각한다.

글을 시작하기에 앞서 본 글은 어떻게 포트폴리오를 작성하느냐가 아닌,
어떻게 홈서버에 안전하고 편리하게 배포할 수 있을까를 중심으로 서술하였음을 미리 안내한다.

필자는 취업 준비를 하며 포트폴리오를 처음 만들었고, 그 이후로는 단 한 번도 유지보수한 적이 없었다.
그런 의미에서, 처음 홈서버 및 도메인을 구매할 때 상상만 하였던 것을 이번 글을 통해 실제로 만들어보고자 한다.

다만 필자는 백엔드 개발자이니만큼, 포트폴리오 디자인에 집착하기보다는
안정적인 배포 파이프라인 구성 및 홈서버 인프라 세팅을 중심으로 하여 다뤄보고자 한다.

참고로 디자인을 비롯한 프론트엔드 최적화는 자신이 없기에 검증된 Astro 프레임워크의 템플릿을 활용하였다.

Bloomfolio | Astro
Astro portfolio template with 🌼 Daisy UI. A modern, responsive, and customizable portfolio template built with Astro and DaisyUI. Perfect for developers, designers, and creatives to showcase their work and skills.

(26.01.18 추가) 참고로 이번 포트폴리오에 사용한 Astro가 Cloudflare에 인수되었다고 한다.

Astro, Cloudflare 에 합류 | GeekNews
콘텐츠 중심 웹사이트 구축에 특화된 Astro 웹 프레임워크 개발사 Astro Technology Company가 Cloudflare에 합류Astro는 계속해서 오픈소스·MIT 라이선스로 유지되며, 다양한 배포 대상을 지원모든 정규 직원이 Cloudflare 소속으로 전환되어, Astro 개발에 전념하게 됨Cloudflare는 인프라 측면, Astro는 프


본론

필자가 포트폴리오를 배포함에 있어 가장 큰 우려가 된 부분은 홈서버를 외부로 개방한다는 점이었다.
이전 글을 통해 다루었다시피, 필자의 홈서버는 LAN 상에 위치하거나 VPN이 연결되어야만 접속이 가능한 구조다.

다만 이번에 개발하는 포트폴리오나, 추후 자체 호스팅을 고려 중인 블로그는 외부로 공개되는 서비스이다.
즉, 방심하는 순간 내 홈서버를 비롯한 내부 시스템이 농락당할 수 있다는 공포감이 엄습하였다.

이에 ChatGPT를 일개 범부로 끌어내리고 대신 그 왕좌를 차지하신 Gemini 님께 자문을 구하였다.

포트폴리오를 공개하기 위해 pfSense를 우회하거나 VPN 설정을 복잡하게 꼬을 필요가 없습니다.
오히려 pfSense와 NPM의 기능을 활용하여 트래픽을 분리하는 것이 가장 안전하고 관리하기 쉽습니다.

현재 구조에서 포트폴리오만 '대문(Public)'을 열어주는 두 가지 확실한 방법을 제안해 드립니다.

방법 1. 정석적인 방법: pfSense 포트포워딩 + NPM 라우팅 (추천)
"VPN 사용자만 내부망 접속"이라는 현재 보안 정책을 유지하면서,
오직 Ghost 블로그 트래픽만 외부에서 들어오게 허용하는 방식입니다.

...(대충 포트포워딩 및 방화벽 승인을 통해 전달한다는 내용)...

방법 2. 더 안전한 방법: Cloudflare Tunnel (강력 추천)
만약 "pfSense의 포트(80/443)를 외부에 개방하는 것 자체가 보안상 찝찝하다"면 이 방법이 최고입니다.

변경된 트래픽 흐름:
[외부 사용자] > [Cloudflare Edge] > (암호화된 터널) > [cloudflared (내부 서버)] > [포트폴리오 컨테이너]

장점 및 설정 원리
1. 포트 개방 0개: pfSense에서 인바운드 포트를 하나도 열지 않아도 됩니다. (보안성 최상)
2. 원리: 내부 서버(Proxmox의 컨테이너 등)에 cloudflared라는 데몬을 설치하면, 이 녀석이 Cloudflare 서버와 아웃바운드 연결을 맺어 터널을 뚫습니다.
3. 설정: 포트폴리오 도메인만 Cloudflare Tunnel을 통해 연결합니다. 기존 내부 서비스는 터널에 연결하지 않으면, 여전히 VPN을 통해서만(또는 기존 방식대로만) 접근 가능합니다.
4. DDNS 불필요: 공인 IP가 바뀌어도 터널이 알아서 연결을 유지하므로 DDNS 설정도 필요 없습니다.


출처 | Google. (2026). Gemini 3 (PRO) [Large language model]. https://gemini.google.com

전지전능하신 Gemini 님께서는 미천한 필자에게 친히 두 가지 방법이나 교지해 주셨다. 영광스럽지 아니한가.
당연하게도, 방화벽에 구멍을 뚫는 것이 싫었던 필자이기에 2번 방법은 정말 신탁과도 같이 느껴졌다.

Cloudflare Tunnel 이란?

그렇다면 Cloudflare Tunnel이 도대체 무엇이길래 당면한 문제를 해결할 수 있다는 것인지 자세히 살펴보자.

Cloudflare Tunnel 설명 | 출처 : Cloudflare Tunnel, Cloudflare Docs

Cloudflare Docs를 살펴보면 아래와 같이 Cloudflare Tunnel에 대해 설명하고 있다.

[!NOTE]
Cloudflared establishes outbound connections (tunnels) between your resources and Cloudflare's global network. Tunnels are persistent objects that route traffic to DNS records. Within the same tunnel, you can run as many 'cloudflared' processes (connectors) as needed. These processes will establish connections to Cloudflare and send traffic to the nearest Cloudflare data center.

출처 : Cloudflare Tunnel, Cloudflare Docs

이를 요약해 보면, 'Cloudflared'는 리소스와 Cloudflare 네트워크 간에 아웃바운드 터널을 설정한다는 뜻이다.

언뜻 보면 차이가 없어 보이지만, 핵심은 인프라 내부에서 외부로 터널(Outbound Tunnel)을 생성한다는 것이다.

우리가 통상적으로 서비스를 외부로 공개할 땐, 공유기나 방화벽의 특정 포트를 열어 외부 접속을 허용한다.
이는 누구나 들어올 수 있도록 대문을 열어두는 것과 같다.

[외부 사용자] > [공유기/모뎀 (Bridge)] > [pfSense (WAN)] > [NPM] > [포트폴리오 컨테이너]

즉, 위와 같은 구조는 내 사설망(공유기/방화벽 포트)을 외부에서 접근할 수 있도록 승인하는 구조이다.

물론 Cloudflare는 Proxy Mode(주황색 구름)를 통해 내 서버의 IP를 숨겨주긴 한다.

하지만 숨겨주는 것에서 그칠 뿐, 결국 내 공유기/방화벽의 포트를 열어야 한다는 본질적인 위험은 그대로다.
악의적인 사용자가 내 공인 IP를 알아낸다면 방화벽을 뚫고 들어올 여지가 생긴다.

반면 Cloudflare Tunnel은 문(Inbound Port)을 걸어 잠그고, 집 안에서 밖으로 땅굴(Outbound)을 파는 구조이다. 즉 공유기/방화벽의 포트 개방 없이 내부에서 직접 외부로 터널을 형성하여 보안 취약점을 줄일 수 있게 되는 것이다.

Cloudflare Tunnel 구성

이제 이해는 했으니 실제 구성을 해보자.

필자는 이미 Cloudflare에 도메인을 등록하였기에 아래와 같은 화면을 볼 수 있다.

저 도메인에 아무것도 안 물려있는데도 이미 약 400명이나 방문했다고 한다.
Cloudflare Dashboard | 출처 : 내 Cloudflare

이후 대시보드에서 'Zero Trust'로 들어가면 팀 이름, 플랜을 선택하라고 나온다. 대충 아무거나 입력하면 된다.

그러면 아래와 같이 좌측 네트워크 탭에서 터널을 생성할 수 있다. 이름도 예쁘게 지어주자.

Cloudflare Network Overview | 출처 : 내 Cloudflare
Cloudflare Tunnel 생성 | 출처 : 내 Cloudflare

생성을 마치면 아래와 같이 각 OS별로 어떻게 터널을 연결할 수 있는지 안내해 준다.
아래 화면에서 터널 연결을 위해 필요한 토큰을 제공해 주니 꼭 복사해 두자.

Cloudflare Tunnel 설치 가이드 | 출처 : 내 Cloudflare

이후 안내해 주는 대로 도커 컨테이너 하나를 띄우면 아래처럼 잘 연결되었음을 확인할 수 있다.

Cloudflare Tunnel 연결 성공 | 출처 : 내 Cloudflare
Cloudflare Tunnel Route | 출처 : 내 Cloudflare

이후 도메인(Domain)과 서비스(Service)를 설정해 주면 터널 생성은 끝이다.
설정한 도메인으로 사용자의 요청이 들어오면, Cloudflared가 해당 서비스로 전달해 주는 것이다.

사용자(User)의 https://knowsu.in 접근 > Cloudflared > http://portfolio:80

필자는 Cloudflared와 포트폴리오 서비스를 컴포즈로 띄워 같은 도커 네트워크를 공유하도록 하였다. 따라서 내부 IP를 일일이 찾을 필요 없이, Docker Container의 서비스 명(portfolio)과 포트를 호스트 주소로 입력하면 도커가 알아서 찾아주도록 하였다.

Github Actions CI/CD

배포 파이프라인이니만큼 CI/CD가 빠질 순 없다.

필자는 Github Actions과 GHCR(GitHub Container Registry)를 활용하여 아래와 같이 구성하였다.

Deploy Pipeline | 출처 : Gemini 3 PRO

보통은 Docker Hub나 GHCR에 이미지가 올라가면 웹훅(Webhook)을 통해 서버로 API를 전송하여 새로운 이미지가 배포되었음을 알리고 알리고 업데이트를 수행한다. 다만 필자의 경우는 폐쇄망(홈서버 내부)에 서비스를 배포할 예정이기에 웹훅을 통한 방식은 불가능하였다.

엄밀히 말하면 불가능한 건 아니다. 웹훅 요청을 보낼 수 있도록 방화벽/포트를 개방하면 된다.
근데 그럴 거면 애초에 Cloudflare Tunnel을 쓸 필요조차 없지 않았는가.

그래서 선택한 대안이 실시간 업데이트는 포기하는 대신 Watchtower 컨테이너를 함께 띄워 서버 내부의 Watchtower가 GHCR로 주기적 요청을 보내 갱신된 이미지를 감지하여 업데이트하도록 구성하였다. 실시간성은 조금 떨어지지만 보안을 위해선 합리적인 방안이라고 생각한다.

Github Actions Workflow

당연하게도 가장 처음 정의해야 할 것은 Github Workflow다.
main 브랜치에 코드 수정사항이 반영되면 이미지를 빌드하여 GHCR에 업로드하는 매우 간단한 구조다.

name: Deploy Portfolio

on:
  push:
    branches: ["main"]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest
            type=sha

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: src
          file: bin/compose/production/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

성공적으로 워크플로우가 수행되면 아래처럼 이미지가 정상적으로 생성되었음을 확인할 수 있다.

Portfolio GHCR | 출처 : 내 Github

Dockge 배포

이제 홈서버의 관리 도구인 Dockge를 통해 컴포즈를 배포하면 정상적으로 수행됨을 확인할 수 있다.

Dockge Compose 배포 오류 | 출처 : 내 Dockge

사실 안된다.
이유인 즉슨, 현재 GHCR이 Private로 되어 있어 Dockge에서 접근이 불가능하기 때문으로, 해결책은 2가지다.

  1. GHCR을 Public 상태로 변경
  2. Private GHCR에 접근할 수 있도록 로그인

필자는 그 중에서 2번 방법으로 하였다.

이를 위해 Github에서 토큰을 생성하였다. 해당 토큰은 최소한의 권한(Package Read)만 부여하였고, 편의상 만료 기간을 설정하지 않았다.

글을 쓰는 지금 느끼는 거지만, 앞서 그렇게 보안을 챙겼으면서 이건 또 귀찮아서 영구로 생성한 나 자신의 양면성이 심히 놀랍다. 뭐 어차피 이 토큰은 내부망에서만 사용할뿐더러, 접근 권한도 최소화하였으니 괜찮겠지라며 스스로를 달래 본다.
Github Token 생성 | 출처 : 내 Github

Auto Redirect Via Language

여기까지만 하고 끝내기엔 뭔가 아쉽다.

기존 외국인 형님께서 만들어주신 포트폴리오를 한글로 작성하면서 문득 떠오른 아이디어가 있다.

포트폴리오에 접근하는 사용자의 언어권에 따라 자동으로 언어를 전환할 순 없을까?

다시 생각해 봐도 기똥찬 아이디언 것 같다. 찾아보니 몇 가지 방안이 있는 것 같다.

  1. Cloudflare Worker를 통해 터널로 진입하기 전 라우팅한다.
    - 사용자의 요청이 우리 집 홈서버(Tunnel)에 도착하기도 전에, Cloudflare Edge 노드에서 가로채 처리한다.
  2. Nginx 설정을 통해 사용자의 언어권에 따라 달리 전달한다.
    - 영어권에서 접속할 경우 서버에서 리다이렉트 응답을 받고 다시 나가는 것은 (내) 리소스 낭비다.
  3. 브라우저에게 역할을 떠넘긴다.
    - 페이지 로딩 직후 navigator.language를 보고 이동시킨다. 구현은 쉽지만, 깜빡임 현상이 발생한다.

따라서 필자는 내 서버의 리소스를 최소화하며, 사용자에게 가장 빠른 속도로 제공할 수 있는 1번을 택하였다.

Cloudflare Worker 생성

아래 Cloudflare Dashboard 내에서 워커(Worker)를 생성할 수 있다.

Cloudflare Worker 설정 | 출처 : 내 Cloudflare

이후 워커를 기본값으로 생성해 주었다. 이름은 대충 'portfolio-redirect'라고 해주자.

Cloudflare Worker Default | 출처 : 내 Cloudflare

이제 배포한 Cloudflare Worker 코드를 수정할 차례이다.

Cloudflare Worker 코드 수정 | 출처 : 내 Cloudflare

대충 사용자가 루트로 진입할 때 언어권을 검사하여 리다이렉트 시키는 매우 간단한 구조이다.

export default {
  async fetch(request, env, ctx) {
    try {
      const url = new URL(request.url);

      // 1. 메인 페이지('/') 검사
      if (url.pathname === '/') {
        
        // 2. 쿠키 확인
        const cookies = request.headers.get('Cookie') || '';
        if (cookies.includes('lang_checked=true')) {
          return fetch(request);
        }

        // 3. 언어 헤더 확인
        const acceptLanguage = request.headers.get('Accept-Language');
        
        if (acceptLanguage && acceptLanguage.toLowerCase().includes('ko')) {
          const newUrl = url.origin + '/ko';

          return new Response(null, {
            status: 302,
            headers: {
              'Location': newUrl,
              'Set-Cookie': 'lang_checked=true; Path=/; SameSite=Lax; Secure; HttpOnly'
            }
          });
        }
      }

      // 그 외의 경우 원래 가려던 곳으로 통과
      return fetch(request);

    } catch (e) {
      return fetch(request);
    }
  },
};

여기까지 설정을 마쳤다면 마지막으로 해당 워커와 도메인을 연결해 주면 끝이다.

Cloudflare Worker Domain Route | 출처 : 내 Cloudflare

위와 같은 과정을 통해 최종적으론 아래와 같은 구조를 만들 수 있었다.

https://knowsu.in 접근
사용자 언어권 감지(Cloudflare Worker)
리다이렉트(/, /ko)
Cloudflare Tunnel
Portfolio Container 도달

이렇게 완성한 내 포트폴리오다.

사실 포트폴리오 쓰기 귀찮아서 인프라부터 구축하였는데, 포트폴리오는 천천히 보완하도록 하겠다.
knowsu.in
About SuIn, Roh
Thumbnail


결론

어차피 정적 화면이니만큼 Vercel 같은 걸로 배포하였으면 쉬웠을 것인데, 
기왕이면 다홍치마라고 오버 엔지니어링이란 걸 알면서도 한 번 해보았다.

다만 이렇게 함으로써 기준 내부 서비스 배포 방식인 pfSense + NPM(Nginx Proxy Manager)과 상이한 Cloudflare라는 외부 관리 포인트가 추가되는게 우려스러우면서도, 달리 생각해 보면 오히려 목적에 따라 관리 포인트가 잘 구분된 것 같기도 하고 아직은 애매하다.

또한 최근에 Cloudflare 관련해서 이슈가 발생하였으니만큼 조심스럽긴 하였으나, 뭐 다운타임이 발생하여도 큰 지장이 없는 서비스이니만큼 Cloudflare를 거치도록 하였다.


참고자료

Cloudflare Tunnel
Cloudflare Tunnel provides you with a secure way to connect your resources to Cloudflare without a publicly routable IP address. With Tunnel, you do not send traffic to an external IP — instead, a lightweight daemon in your infrastructure (cloudflared) creates outbound-only connections to Cloudflare’s global network. Cloudflare Tunnel can connect HTTP web servers, SSH servers, remote desktops, and other protocols safely to Cloudflare. This way, your origins can serve traffic through Cloudflare without being vulnerable to attacks that bypass Cloudflare.
GitHub Container Registry로 Docker 이미지 관리하기(without. Docker Hub)
서론도커 이미지를 배포할 때, 대표적으로 Docker Hub를 사용한다. 하지만 Docker Hub는 무료 계정의 경우, 1개의 private repository만 사용할 수 있다. public으로 사용할 수 있는데, 민감한 정보가 포함된 이미지를 올리기에는 부적합하다. 그래서 GitHub Container Registry를 사용해보기로 했다. GitHub