배포만 하던 개발자가 홈서버를 구축하며 배운 것들
2026-06-21 · INFRA · 약 10분 읽기
Cloudflare 기반 프로젝트를 Proxmox 홈서버로 이전하며 Self-Hosted CI/CD 파이프라인을 구축하고, DNS·Runner·Gateway 문제를 해결한 과정을 정리했습니다.
프로젝트를 하나 만들어 지금까지 Cloudflare 기반으로 운영하고 있었습니다. 초기에는 빠른 배포와 간단한 운영이 중요했기 때문에 Cloudflare는 충분히 좋은 선택이었습니다. 실제로 작은 규모의 서비스에서는 빠르게 결과물을 올리고 운영하기에 매우 편리했습니다.
하지만 프로젝트를 계속 운영하면서 몇 가지 아쉬운 점이 보이기 시작했습니다.
가장 크게 느낀 부분은 runtime이 Cloudflare 환경에 종속된다는 점이었습니다. 특정 기능이나 데이터베이스 구조가 Cloudflare 생태계에 맞춰져 있다 보니, 다른 환경으로 이전할 때 수정해야 하는 코드가 많았습니다.
또한 서버 레벨에서 직접 제어하기 어려웠습니다. 클라우드 환경은 많은 부분이 추상화되어 있기 때문에 빠르게 시작할 수 있다는 장점이 있지만, 네트워크나 runtime을 직접 관리하고 싶은 상황에서는 한계가 있었습니다.
장기적으로는 프로젝트가 하나에서 끝나지 않을 것이라 생각했습니다. 여러 서비스를 운영하게 된다면 외부 플랫폼 의존도를 줄이고, 직접 관리할 수 있는 인프라를 만드는 경험이 필요하다고 판단했습니다.
그래서 홈서버 구축을 시작했습니다.
이번 구축의 최종 목표는 아래와 같았습니다.
1. GitHub Private Repository에 코드를 push
2. GitHub Actions가 Self-hosted Runner에서 실행
3. Home Server에서 Docker 기반 서비스 실행즉, GitHub에 코드를 올리면 빌드부터 배포까지 자동으로 이어지는 CI/CD 파이프라인을 직접 구축하는 것이 목표였습니다.
왜 Proxmox를 선택했는가
하드웨어는 기존에 집에서 사용하던 컴퓨터를 활용했습니다.
처음에는 Windows를 그대로 사용할지, 아니면 Hypervisor를 올릴지 고민했습니다. Windows 환경은 익숙했지만, 프로젝트가 늘어날수록 서비스별 격리와 리소스 관리가 어려울 것 같았습니다.
그래서 Hypervisor 기반 구성을 선택했고, Proxmox를 최종 선택하게 되었습니다. 이유는 아래와 같습니다.
- VM과 Container 관리가 편하다는 점입니다. 프로젝트별로 격리된 환경을 만드는 데 적합했습니다.
- Web UI가 잘 되어 있다는 점입니다. 리소스 상태를 직관적으로 확인하기 좋았습니다.설치 과정은 아래 순서로 진행했습니다.
1. Proxmox ISO 다운로드
2. Rufus로 USB 부팅 디스크 생성
3. BIOS에서 USB 우선 부팅
4. Proxmox 설치설치 자체는 어렵지 않았습니다. 이후에는 프로젝트를 VM으로 운영할지 CT(Container)로 운영할지 결정해야 했습니다.
VM 대신 CT를 선택한 이유
VM은 완전한 격리 환경이라는 장점이 있지만, CPU와 RAM 사용량이 큽니다.
반면 CT는 훨씬 가볍습니다. 제가 운영하려는 프로젝트 규모를 고려했을 때 CT로 충분하다고 판단했습니다.
구조는 다음처럼 나눴습니다.
Project CT
- App runtime
- Database runtime
CI CT
- Build
- Artifact 생성
- Deployment
여기서 중요한 포인트는 CI와 Runtime 분리였습니다.
빌드 환경과 실행 환경을 하나로 두면 build dependency가 runtime에 들어오게 됩니다. Docker, compiler, build toolchain 등이 서비스 실행 환경에 섞이는 구조는 유지보수 측면에서 깔끔하지 않았습니다.
또한 홈서버는 리소스가 제한적입니다. 각 컨테이너에 디스크를 크게 할당하는 것도 부담이었습니다.
그래서 build는 CI CT에서만 수행하고, runtime CT는 최대한 가볍게 유지하는 구조를 선택했습니다. (CT는 Ubuntu 24.04 템플릿으로 생성했습니다.)
추가로 네트워크 설정에서 주의할 점이 하나 있었습니다.
IPv4는 처음부터 Static으로 설정하지 않고 DHCP로 생성했습니다. 컨테이너를 먼저 실행해서 할당된 IP를 확인한 뒤 Static IP로 변경했습니다. 이 과정을 거치지 않으면 IP가 바뀔 수 있기 때문입니다.
apt update 지연 원인 분석
Project CT 생성 후 패키지 설치를 위해 apt update를 실행했습니다.
sudo apt update실행 속도가 비정상적으로 느렸습니다. 원인 파악을 위해 먼저 네트워크 상태를 확인했습니다.
첫 번째 가설은 인터넷 연결 문제였고, IP 통신부터 확인했습니다.
ping 8.8.8.8정상 응답이 왔습니다.
인터넷 연결 자체는 살아 있었습니다.
다음은 domain lookup 확인이었습니다.
ping deb.debian.org실패했습니다.
이 결과를 보면 IP 통신은 되지만 domain resolution이 되지 않는 상태였습니다.
즉 DNS 문제라고 판단할 수 있었습니다.
설정을 확인했습니다.
cat /etc/resolv.conf결과는 아래와 같았습니다.
nameserver 100.100.100.100확인해보니 Tailscale DNS였습니다. 초기에 VPN 설정 과정에서 들어간 값이었습니다.
이를 아래처럼 수정했습니다.
nameserver 8.8.8.8
nameserver 1.1.1.1이후 다시 apt update를 실행하니 정상적으로 동작했습니다.
CI는 어디서 돌릴 것인가
다음 고민은 GitHub Actions runner를 어디서 실행할지였습니다.
선택지는 세 가지였습니다.
- 로컬 PC
- 별도 CI CT
- 별도 CI VM
로컬 PC는 단순하지만 항상 켜져 있어야 했습니다.
CI VM은 격리성은 좋지만 RAM과 디스크 사용량이 부담이었습니다.
그래서 별도 CI CT를 선택했습니다. 역할 분리가 명확하고 runtime 환경도 깔끔하게 유지할 수 있기 때문입니다.
github-action CT를 생성했고, 디스크는 32GB 정도 할당했습니다. Docker image와 build artifact 용량을 고려한 결정이었습니다.
GitHub Runner가 Job을 Pickup하지 않았던 이유
GitHub Repository에서 self-hosted runner를 등록했습니다.
Runner 상태는 정상처럼 보였습니다.
Connected to GitHub
Listening for Jobs하지만 workflow 실행 시 문제가 발생했습니다.
Waiting for a runner to pick up this job...Job이 계속 대기 상태였습니다.
처음에는 GitHub와 runner 간 connection 문제를 의심했습니다. 하지만 runner는 online 상태였고, 즉 connection 문제는 아니었습니다.
Workflow 설정을 다시 확인했습니다.
runs-on: [self-hosted, linux, docker, ci]Runner label을 확인했습니다.
self-hosted
Linux
X64여기서 원인을 찾았습니다.
Workflow는 아래 label을 요구했습니다.
- self-hosted
- linux
- docker
- ci
하지만 runner에는 docker와 ci label이 없었습니다.
GitHub Actions는 필요한 label이 모두 일치해야 job을 runner에 할당합니다.
Runner에 custom label을 추가했습니다.
- docker
- ci
추가 후 workflow가 정상적으로 실행되었습니다.
Cloudflare 의존성 제거
기존 프로젝트는 Cloudflare 기반 구조였습니다.
- Cloudflare Workers
- Cloudflare D1
이 구조를 그대로 홈서버 runtime으로 가져오기는 어려웠습니다.
그래서 runtime branching 전략을 도입했습니다.
Cloudflare 환경에서는 기존 로직을 유지하고, Docker 환경에서는 Cloudflare initialization이 실행되지 않도록 분기 처리했습니다.
데이터 마이그레이션도 필요했습니다. Cloudflare 데이터를 dump로 백업했고, 데이터베이스는 SQLite에서 PostgreSQL로 전환했습니다. 이 과정에서 adapter layer를 추가하고 dynamic import(pg)를 활용해 환경별 호환성을 맞췄습니다.
단순한 DB 교체라기보다 runtime abstraction 작업에 가까웠습니다.
Registry 없이 Artifact 전달하기
다음으로 고민한 부분은 build 결과물을 runtime CT로 어떻게 전달할지였습니다.
일반적으로는 Docker Registry를 사용합니다. 하지만 홈서버 환경에서 registry까지 직접 운영하는 것은 부담이 있었습니다.
그래서 아래 방식을 선택했습니다.
docker saveDocker image를 tar artifact로 만든 뒤 SCP로 전송하는 방식입니다.
전체 흐름은 다음과 같습니다.
GitHub Actions
↓
CI CT Build
↓
docker save
↓
artifact tar
↓
SCP
↓
Project CT
↓
docker load
↓
docker compose up이 방식으로 registry 없이도 충분히 배포가 가능했습니다.
Tailscale 연결 문제 분석
배포 이후 마지막 과제는 외부 접속이었습니다.
선택지는 아래 네 가지였습니다.
- LAN direct access
- Port forwarding
- Tailscale VPN Mesh
- Cloudflare Tunnel
저는 Tailscale을 선택했습니다.
포트포워딩 없이 빠르게 외부 접속을 구성할 수 있기 때문입니다.
Project CT에서 설정을 위해서 아래 명령어를 실행했습니다.
tailscale up에러가 발생했습니다.
failed to connect to local tailscaled원인 분석을 시작했습니다.
네트워크 설정을 확인하는 과정에서 default gateway가 없다는 점을 발견했습니다. 같은 subnet 내부 통신은 가능했지만, 외부 네트워크로 나갈 route가 없었습니다.
왜 이런 상태가 되었는지 확인해보니 DHCP에서 Static IP로 전환할 때 gateway 입력이 누락되어 있었습니다.
Gateway를 추가했습니다.
Gateway: 192.168.0.1즉 공유기를 default gateway로 지정했습니다. 이후 외부 IP ping이 정상적으로 동작했습니다.
ping 8.8.8.8Tailscale도 정상 연결되었습니다.
최종 아키텍처
최종적으로 구축한 구조는 아래와 같습니다.
GitHub Private Repo
↓
workflow_dispatch
↓
GitHub Actions
↓
CI CT runner job pickup
↓
docker build
↓
docker save
↓
artifact tar
↓
SSH / SCP
↓
Project CT
↓
docker load
↓
docker compose up -d
↓
health check
↓
service running회고
이번 홈서버 구축 과정에서 클라우드 환경이 얼마나 많은 것을 추상화하고 있는지 다시 체감했습니다.
평소에는 쉽게 지나가던 요소들이 직접 구축 환경에서는 전부 고려 대상이 되었습니다.
- DNS
- Routing
- Gateway
- Build pipeline
- Runtime isolation
특히 이번 구축에서 발생했던 문제들은 대부분 거대한 시스템 장애가 아니었습니다.
대부분 설정 하나의 문제였습니다.
- DNS 설정
- Runner label
- Gateway
작아 보이는 설정이 전체 시스템 동작을 결정했습니다.
이번 경험을 통해 단순히 서비스를 배포하는 것과 인프라를 직접 운영하는 것은 완전히 다른 문제라는 점을 배웠습니다.
아직 남은 작업도 있습니다.
- Custom Domain 연결
- Reverse Proxy 구성
- TLS 자동화
- Monitoring Stack 구축
하지만 최소한 원하는 구조의 기반은 만들었습니다.
이제는 내가 만든 서비스를 내가 통제하는 인프라 위에서 운영할 수 있는 상태가 되었습니다.
이번 홈서버 구축은 단순히 서버 한 대를 만든 경험이 아니라, 인프라 레이어를 더 깊게 이해하는 계기가 되었습니다.
긴 글 읽어주셔서 감사합니다.
같은 카테고리의 글
INFRA 글 더 읽기
이전 글
프론트엔드 개발자 관점에서 인프라를 뜯어보고 이해하기
다음 글
이어지는 글이 없습니다