홈서버 세팅의 핵심은 Ubuntu와 Docker 설치보다 운영 경계를 정하는 일이었다. SSH는 어떻게 들어갈 것인가, HTTP 요청은 어디로 모을 것인가, 각 앱은 어떤 Docker network에 붙일 것인가, 서버에는 소스코드를 둘 것인가 이미지만 둘 것인가를 먼저 정해야 했다.
이 글은 그 세팅 과정에서 헷갈렸던 질문을 바탕으로 처음 홈서버를 만드는 사람이 따라갈 수 있는 운영 기준을 정리한 기록이다.
요약
- 문제: 처음에는 Cloudflare Tunnel, SSH, Termius, Docker, Caddy, Docker Compose가 각각 무엇을 책임지는지 섞여 보였다.
- 판단: 홈서버는 "개발 머신"이 아니라 "런타임 머신"으로 보고 접근 경계와 서비스 경계를 나눴다.
- 기준 구조: SSH 접근은 Cloudflare Tunnel/Access 같은 별도 접근 경계로 관리하고, HTTP 서비스는 공용 Caddy가
:80에서 받아 Dockerproxy-net에 붙은 서비스로 라우팅한다. - 배운 점: 여러 서비스를 운영하려면 앱마다 포트를 열기보다 공용 reverse proxy 하나와 서비스별 compose 프로젝트를 두는 구조가 훨씬 관리하기 쉽다.
상황
처음 준비한 것은 USB, 랜선, Ubuntu 설치 이미지였다. 여기서 바로 막히는 질문이 많았다.
- Cloudflare Tunnel을 쓰면 공인 IP나 포트포워딩이 필요한가?
- 유선 키보드와 마우스가 꼭 필요한가?
- SSH는 왜 Cloudflare로 열어야 하나?
- Termius는 Cloudflare Tunnel을 대체하는가?
- Docker GPG key와 apt repository 등록은 왜 필요한가?
- 여러 서비스를 한 서버에 올릴 때 Caddy를 서비스마다 둬야 하나?
먼저 정한 원칙
홈서버를 서버답게 운영하려면 역할을 나눠야 한다.
접근 계층: SSH, Cloudflare Tunnel, 물리 콘솔
진입 계층: 공용 Caddy
서비스 계층: 앱별 docker compose
데이터 계층: volume, data, secrets, database
배포 계층: GHCR image pull, docker compose up이렇게 나누면 "뭐가 안 된다"는 말도 더 작게 쪼개진다. SSH가 안 되는지, Cloudflare에서 origin까지 못 오는지, Caddy가 Host를 못 맞추는지, Docker network에서 서비스명을 못 찾는지, 앱 health check가 실패하는지 따로 확인할 수 있다.
장비와 첫 부팅
Cloudflare Tunnel을 쓴다고 해서 물리 콘솔이 필요 없어지는 것은 아니다. Tunnel과 SSH가 작동하려면 먼저 서버가 정상 부팅하고 네트워크에 붙어야 한다.
처음 세팅할 때는 아래가 필요하다.
- USB 설치 디스크
- 유선 키보드와 유선 마우스
- HDMI 모니터 또는 TV
- 랜선
- 선택: UPS
서버가 부팅하지 않거나 네트워크가 죽으면 SSH와 Tunnel은 모두 쓸 수 없다. 그때 BIOS, 콘솔, 부팅 로그를 보기 위해 유선 입력 장치와 화면이 필요하다.
Ubuntu 설치 후 첫 명령
Ubuntu 설치 단계에서는 OpenSSH server를 켜두는 것이 좋다. 설치가 끝나면 물리 콘솔에서 LAN IP를 확인하고, 이후부터는 작업용 노트북에서 접속한다.
ip a
ssh user@192.168.x.x그 다음은 기본 패키지와 Docker 설치다. Docker는 공식 문서 기준으로 Docker의 apt repository를 등록해서 설치하는 방식을 쓴다.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin처음에는 GPG key 단계가 불필요한 의식처럼 보였다. 하지만 이 단계는 "이 Docker 패키지가 Docker가 서명한 공식 패키지인지"를 apt가 검증하게 만드는 과정이다. 서버에 설치되는 런타임은 신뢰 경계 안에 있어야 한다.
설치 확인은 간단하다.
docker --version
docker compose version
docker run --rm hello-worlddocker를 매번 sudo 없이 쓰려면 사용자를 docker 그룹에 넣을 수 있다.
sudo usermod -aG docker "$USER"
newgrp docker다만 이 권한은 사실상 Docker daemon 제어 권한이다. 운영 서버에서는 누가 Docker 그룹에 들어가는지 신중하게 봐야 한다.
Cloudflare Tunnel과 SSH
처음에는 "Termius를 쓰면 Cloudflare Tunnel이 필요 없는가?"가 헷갈렸다. 둘은 같은 층의 도구가 아니다.
- Termius는 SSH 클라이언트다.
- Cloudflare Tunnel/Access는 네트워크 경로와 접근 정책을 만든다.
즉 Termius는 ssh를 보기 좋게 쓰는 도구이고, Cloudflare Tunnel은 서버가 외부에서 어떻게 도달 가능한지를 바꾼다.
Cloudflare 공식 문서 기준으로 Tunnel은 origin에서 Cloudflare 네트워크로 outbound-only 연결을 만든다. 이 방식은 서버에 공개적으로 routable한 IP가 없어도 Cloudflare를 통해 리소스를 제공할 수 있게 해준다. SSH use case도 따로 문서화되어 있고, client-side cloudflared를 통한 native terminal 접속, browser-rendered SSH, Access for Infrastructure 같은 선택지가 있다.
처음 세팅 흐름은 대략 이랬다.
cloudflared tunnel login
cloudflared tunnel create home-server
cloudflared tunnel route dns home-server ssh.example.com
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared운영에서는 cloudflared를 systemd 서비스로 실행한다.
cloudflared.service
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel runSSH ingress는 이런 형태로 둘 수 있다.
ingress:
- hostname: ssh.example.com
service: ssh://localhost:22
- service: http_status:404여기서 중요한 점은 "서버 안에서 SSH daemon이 사라진다"가 아니다. 서버는 여전히 localhost:22 또는 내부 SSH daemon을 갖고 있고, Cloudflare Tunnel이 그 앞에 접근 경계를 하나 더 둔다. 라우터, 방화벽, Cloudflare Access 정책을 어떻게 잡느냐에 따라 실제 노출 표면은 달라진다.
또한 Tunnel DNS route만 만든다고 SSH 접근 정책이 끝나는 것은 아니다. Cloudflare Access를 쓴다면 접근 가능한 사용자, 인증 방식, 클라이언트 cloudflared 또는 브라우저 SSH 방식을 별도로 정해야 한다.
HTTP 서비스는 공용 Caddy로 모은다
여러 서비스를 한 서버에 올릴 때 가장 쉬운 실수는 서비스마다 host port를 열어버리는 것이다.
service A -> 8000
service B -> 8001
service C -> 3000
service D -> 5000이렇게 하면 어느 포트가 외부에 열려야 하는지, 어떤 서비스가 어떤 도메인을 받는지, TLS와 CORS와 로그가 어디서 처리되는지 금방 흐려진다.
기준 구조는 더 단순하다.
Cloudflare or client
-> host :80
-> infra-caddy
-> Docker proxy network
-> service containersCloudflare Tunnel로 public HTTP까지 붙인다면 Tunnel ingress는 app.example.com -> http://localhost:80처럼 공용 Caddy로 보내면 된다. Cloudflare DNS proxy와 라우터 포트포워딩을 쓰는 경우에도 Caddy가 Host 기준으로 각 서비스에 넘기는 origin 쪽 패턴은 같다. 다만 이 경우에는 Cloudflare SSL/TLS mode, origin certificate, 포워딩 포트를 별도로 맞춰야 한다. Tunnel 내부 HTTP entrypoint와 DNS proxy 기반 origin TLS 설정을 같은 문제로 보면 안 된다.
공용 Caddy compose는 별도 infra 디렉터리에 있고, host :80만 받는다.
services:
caddy:
image: caddy:2
container_name: infra-caddy
ports:
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
networks:
- proxy-net
restart: unless-stopped
networks:
proxy-net:
external: trueCaddyfile은 :80에서 모든 요청을 받고 Host matcher로 분기한다.
Docker에서 app container port를 host에 직접 publish하면 방화벽 정책과 실제 노출 범위가 헷갈릴 수 있다. 처음에는 앱별 host port를 열지 말고, 공개 진입점은 공용 Caddy 하나로 제한하는 편이 관리하기 쉽다.
:80 {
@service-a host app-a.example.com
handle @service-a {
reverse_proxy service-a:8000
}
@service-b host app-b.example.com
handle @service-b {
reverse_proxy service-b:3000
}
respond 404
}Caddy 공식 문서 기준으로 reverse_proxy는 matched request를 upstream으로 프록시하는 directive다. 여기서 upstream을 IP로 쓰지 않고 Docker service/container name으로 쓰는 것이 핵심이다.
Docker network를 서비스 경계로 쓴다
Docker 공식 문서 기준으로 Compose는 기본적으로 프로젝트별 bridge network를 만들고, 같은 network에 붙은 컨테이너는 서비스명으로 서로를 찾을 수 있다. 또한 custom network를 정의해서 여러 Compose 프로젝트가 같은 network를 공유할 수도 있다.
이 그림에서 핵심은 infra-caddy만 host의 공개 진입점에 붙고, 공개 앱 컨테이너는 proxy-net과 앱 내부 network에 동시에 붙는다는 점이다. Docker network가 서로를 포함하는 것은 아니다. 컨테이너가 여러 network에 가입해서 Caddy와도 만나고 같은 앱의 DB와도 만난다. 데이터베이스와 파일 경계는 앱 내부 network와 mount로 따로 둔다.
이 원칙을 적용하면 network는 이렇게 나뉜다.
proxy-net
infra-caddy
app-a-web
app-a-api
app-b-api
app-a-net
app-a-web
app-a-api
postgres공개 reverse proxy가 붙어야 하는 컨테이너만 proxy-net에 붙인다. app-a-web과 app-a-api처럼 외부 Host route를 받아야 하는 컨테이너는 proxy-net과 app-a-net에 함께 붙을 수 있다. DB처럼 외부에서 직접 접근할 필요가 없는 컨테이너는 앱 내부 network에만 둔다.
예시는 이런 형태다.
services:
api:
image: ghcr.io/example/app-api:latest
env_file:
- .env
volumes:
- ./data:/data
networks:
- proxy-net
- app-net
restart: unless-stopped
postgres:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
networks:
proxy-net:
external: true
app-net:
driver: bridge
volumes:
postgres_data:이렇게 하면 Caddy는 api:8000으로 접근할 수 있고, DB는 proxy-net에 노출되지 않는다. host port를 열어야 하는 경우도 줄어든다. 관리 목적으로 DB host port를 열어야 한다면 public interface가 아니라 로컬 loopback에만 묶는 편이 안전하다.
127.0.0.1:5432 -> container 5432서버는 런타임 전용이어야 한다
서버는 소스코드를 개발하는 곳이 아니라 이미지를 실행하는 곳이다.처음에는 서버 안에 repo를 clone하고, Dockerfile을 빌드하고, node_modules와 Python dependency를 직접 만지기 쉽다. 하지만 서비스가 여러 개가 되면 이 방식은 금방 지저분해진다.
더 나은 기준은 이렇다.
~/infra/
caddy/
docker-compose.yml
Caddyfile
~/services/
app-a/
docker-compose.yml
.env
data/
secrets/
app-b/
docker-compose.yml
.env
data/
~/backups/앱 이미지는 GHCR 같은 registry에서 가져온다.
cd ~/services/app-a
docker compose pull
docker compose up -d
docker compose ps
docker compose logs -f운영 루틴은 이 패턴으로 표준화한다.
docker compose pulldocker compose up -ddocker compose psdocker compose logs -fdocker restart infra-caddydocker exec infra-caddy wget http://service:port/health
이 루틴이 좋은 이유는 서버에서 코드를 빌드하지 않아도 되기 때문이다. 배포는 image tag를 바꾸고 compose를 다시 올리는 문제로 줄어든다.
헷갈렸던 지점들
Cloudflare Tunnel과 HTTP reverse proxy는 같은 것이 아니다
Tunnel은 Cloudflare와 origin 사이의 경로를 만든다. Caddy는 origin 안에서 Host, path, upstream을 나누는 reverse proxy다. 둘을 섞어서 생각하면 "Cloudflare 설정을 바꿔야 하는 문제"와 "Caddyfile route를 바꿔야 하는 문제"가 섞인다.
Termius는 네트워크 경로가 아니다
Termius는 SSH client다. 공인 IP, VPN, Cloudflare Tunnel, Zero Trust Access 중 어떤 경로를 쓰든 그 위에서 접속을 편하게 해주는 앱에 가깝다.
Docker GPG key는 귀찮은 단계가 아니라 신뢰 경계다
Docker를 설치할 때 keyring과 apt source를 등록하는 작업은 패키지 서명을 검증하기 위한 단계다. 베어메탈 서버에 올리는 런타임일수록 curl | sh보다 공식 repository 기반 설치가 설명 가능하다.
docker-proxy는 웹 서버가 아니다
host에서 ss -lntp를 보면 docker-proxy가 포트를 잡고 있는 것처럼 보일 수 있다. 이것은 host port를 container port로 넘기는 Docker의 중계 경로다. 진짜 확인할 것은 그 뒤 컨테이너가 해당 port에서 listen 중인지, 그리고 Caddy가 upstream service name을 해석할 수 있는지다.
Caddy에서 검증하면 문제가 빨리 좁혀진다
브라우저에서 안 된다고 바로 앱을 의심하지 않는다. Caddy 컨테이너 안에서 upstream을 직접 확인하면 Docker network 문제인지 앱 문제인지 빨리 갈린다.
docker exec infra-caddy wget -qO- http://app-api:8000/health이 명령이 실패하면 Cloudflare나 브라우저보다 안쪽 문제다. 성공하면 외부 Host route, CORS, Cloudflare 설정 쪽을 본다.
처음 하는 사람을 위한 순서
내가 다시 처음부터 세팅한다면 순서는 이렇게 잡는다.
- Ubuntu 설치 때 OpenSSH server를 켠다.
- 유선 키보드/마우스/모니터로 첫 부팅과 LAN IP를 확인한다.
- 작업용 노트북에서 LAN SSH로 접속한다.
- Docker를 공식 apt repository 방식으로 설치한다.
docker run hello-world와docker compose version으로 런타임을 확인한다.proxy-net같은 공용 Docker network를 만든다.~/infra/caddy에 공용 Caddy compose를 만들고 host:80만 연다.- Cloudflare Tunnel 또는 DNS/proxy가 어떤 방식으로 origin에 들어오는지 명확히 정한다.
- SSH 접근은 Cloudflare Access/Tunnel 또는 VPN 등 별도 접근 경계로 관리한다.
- 앱은
~/services/<app>아래 compose, env, data, secrets로 분리한다. - 공개 앱 컨테이너만
proxy-net에 붙이고, DB는 앱 내부 network에 둔다. - 배포는
docker compose pull && docker compose up -d로 표준화한다. - 장애가 나면
docker compose ps,docker logs,docker exec infra-caddy wget .../health순서로 본다.
이 순서의 핵심은 처음부터 "여러 서버를 운영할 수 있는 구조"를 만든다는 점이다. 앱 하나만 띄울 때는 대충 포트를 열어도 되지만, 앱이 둘 이상이 되는 순간 공용 entrypoint와 network 경계가 필요해진다.
배운 점
시간이 지나면 홈서버 운영 품질은 경계를 얼마나 잘 나눴는지에서 갈린다.
- SSH 접근 경계
- HTTP 진입 경계
- Docker network 경계
- 앱 데이터와 secret 경계
- 빌드와 런타임 경계
이 경계를 나눠두면 여러 서비스를 한 서버에서 운영해도 구조가 무너지지 않는다. 반대로 처음부터 모든 앱이 host port를 열고, 서버에 repo를 clone해서 직접 빌드하고, Caddy와 앱과 DB가 한 network에 섞이면 작은 장애도 어디서 봐야 할지 흐려진다.
홈서버는 작은 클라우드처럼 운영할 수 있다. 다만 그렇게 하려면 서버를 노트북처럼 쓰지 않고 런타임 전용 머신으로 대해야 한다.
AWS나 GCP에서 몇 번의 클릭으로 끝내던 배포 기능이 왜 비싼지 이번에 체감했다. VPC에 해당하는 network 경계, reverse proxy, systemd service, cron, volume, secret, health check를 직접 명령어로 다루다 보니 쉽지는 않았지만, 인프라 운영을 훨씬 구체적으로 이해하게 됐다.
