미니 PC 한 대로 서비스 운영하기: 1인 개발 홈서버 인프라 구성과 지키는 법
들어가며
신나게마을은 아파트 실거래가, 미세먼지, 인구 같은 공공데이터를 동네 단위로 모아 보여 주는 사이트입니다. 혼자 만들고 혼자 운영하고, 서버는 집에 둔 미니 PC 한 대입니다. 웹, 배치, DB, 관리 화면, 배포까지 전부 그 한 대에 올라가 있습니다.
이 글은 그 인프라를 정리한 기록입니다. 서버 안에 무엇이 올라가 있고 각각 어떻게 띄웠는지 먼저 정리하고, 그다음 그 서버를 바깥에 내보내고 지키는 Cloudflare 쪽을 적었습니다.
이 글에서 공유드릴 내용은 이렇습니다.
- 1인 개발 서비스에 필요한 구성 요소와 각자의 역할, 실제 설정
- Docker Swarm 이 연 포트가 방화벽(UFW)을 우회하는 문제
- 공유기 포트를 하나도 열지 않고 웹, 관리 화면, DB, SSH 까지 내보내는 구조
- 방문자, 관리자, 브라우저 없이 들어오는 요청(배치 호출, GitHub 웹훅)을 나눠 접근을 막는 법
- Traefik, Portainer 를 붙이며 밟은 함정
한눈에 보기
손님 브라우저 내 브라우저·PC GitHub
│ │ │
└─────────── Cloudflare ────────┘ DNS, 캐시, Access(누구인지 확인), 점검 페이지
│
│ 터널 (서버가 바깥으로 먼저 연 연결. 공유기 포트는 0개)
│
┌─────────────────── 미니 PC (Debian 12) ───────────────────────────┐
│ cloudflared ─┬─ Traefik ─┬─ 웹 (Spring Boot) │
│ │ ├─ 배치 (Spring Batch) │
│ │ ├─ Portainer (스택 관리) │
│ │ ├─ Dozzle (로그) │
│ │ └─ Traefik 대시보드 │
│ ├─ Netdata (지표) │
│ ├─ webhook (배포) │
│ ├─ PostgreSQL │
│ └─ sshd │
│ │
│ Redis (띄워 두기만 함) UFW · fail2ban · 구글 OTP │
└────────────────────────────────────────────────────────────────────┘
| 분류 | 구성 요소 | 하는 일 | 어떻게 떠 있나 |
|---|---|---|---|
| 기반 | 미니 PC | 전부 이 한 대에서 돕니다. HP EliteDesk 800 G6 (i5-10500T, 메모리 32GB, SSD 1TB) | Debian 12 |
| 기반 | Docker Swarm | 컨테이너를 띄우고, 새 이미지로 바꿔 끼웁니다 | 노드 하나, 스택 여섯 개 |
| 서비스 | 웹 | 사이트 화면과 API | Spring Boot 4, Java 25. Swarm 스택 |
| 서비스 | 배치 | 공공데이터를 받아 모으고 통계를 만듭니다 | Spring Batch. Swarm 스택. 필요할 때 제가 API 로 부릅니다 |
| 서비스 | PostgreSQL 18 | 모든 데이터 | Swarm 스택 |
| 서비스 | Redis 7 | 띄워 두기만 했고 아직 쓰는 곳이 없습니다 | Swarm 스택 |
| 입구 | Traefik v3 | 서브도메인을 보고 알맞은 컨테이너로 보냅니다 | Swarm 스택 |
| 관리·모니터링 | Portainer | 스택과 컨테이너를 브라우저에서 관리합니다 | Swarm 스택(에이전트 포함) |
| 관리·모니터링 | Dozzle | 컨테이너 로그를 브라우저에서 봅니다 | Swarm 스택 |
| 관리·모니터링 | Netdata | CPU, 메모리, 디스크, 컨테이너, DB, Traefik 지표 | Swarm 밖, docker compose |
| 운영 | webhook | GitHub 푸시를 받아 배포 스크립트를 돌립니다 | systemd 서비스 |
| 운영 | 백업 스크립트 | 서버 설정 스냅샷과 DB 덤프를 묶어 클라우드 저장소로 올립니다 | 필요할 때 손으로 |
| 호스트 보안 | sshd, UFW, fail2ban | 서버 접속, 방화벽, 로그인 실패가 쌓인 IP 차단 | 호스트 |
| 바깥으로 잇기 | cloudflared | Cloudflare 와 터널을 잇습니다 | systemd 서비스 |
| 바깥으로 잇기 | Cloudflare | DNS, 캐시, 터널 입구, 접근 제어(Access), 점검 페이지(Worker) | 바깥 |
1부. 서버 안
기반: 미니 PC, Debian 12, Docker Swarm
미니 PC 와 Debian 12
미니 PC 에 Debian 12 를 최소 설치(데스크톱 없이 SSH 서버만)로 올렸습니다. 24시간 켜 두는 장비라 BIOS 에서 정전 뒤 전기가 들어오면 스스로 켜지도록(After Power Loss → Power On) 해 두었는데, 실제로 전기 점검 날 정전이 끝나자 서버가 혼자 켜졌습니다.
Docker Swarm
노드가 하나여도 Swarm 으로 띄우면 편한 점이 있습니다. 스택 파일 하나로 서비스 묶음을 관리할 수 있고, docker service update 로 새 이미지를 끼우면 Swarm 이 알아서 교체해 줍니다.
스택은 여섯 개입니다.
| 스택 | 들어 있는 것 |
|---|---|
| web | 웹 |
| batch | 배치 |
| infra | PostgreSQL, Redis |
| monitor | Dozzle |
| portainer | Portainer, Portainer 에이전트 |
| traefik | Traefik |
네트워크는 overlay 로 나눴습니다. Traefik 과 바깥에 내보낼 서비스는 traefik-public 에 같이 붙고, DB 와 Redis 는 내부 전용 overlay 네트워크로 웹·배치와 이어집니다.
설치와 준비는 이렇습니다.
# Docker 공식 설치 스크립트 (엔진과 compose 플러그인이 같이 깔린다)
curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh
# sudo 없이 docker 를 쓰게 한다 (다시 로그인해야 적용)
sudo usermod -aG docker $USER
# 이 서버를 Swarm 매니저로 만든다
docker swarm init
# Traefik 과 바깥에 내보낼 서비스가 같이 붙는 네트워크
docker network create --driver=overlay traefik-public
# 비밀번호는 환경변수에 쓰지 않고 Swarm 시크릿으로 넣는다
# (컨테이너 안에서는 /run/secrets/<이름> 파일로 보인다)
echo "<DB 비밀번호>" | docker secret create db_password -
스택은 스택 파일 하나를 docker stack deploy -c <파일> <스택 이름> 으로 올립니다. Portainer 를 띄운 뒤에는 Portainer 화면에서 스택 파일을 붙여 넣어 올려도 됩니다.
서비스: 웹, 배치, PostgreSQL, Redis
- 웹은 Spring Boot 4, Java 25 로 만든 사이트입니다. 가상 스레드를 켜 두었습니다.
- 배치는 공공데이터 API 를 불러 모으고 통계 표를 만드는 Spring Batch 앱입니다. 정해진 시각에 도는 스케줄러는 없고, 필요할 때 제가 배치 API 를 호출합니다.
- PostgreSQL 18 하나에 모든 데이터가 있습니다.
- Redis 는 띄워 두기만 했고, 아직 웹이나 배치에서 쓰는 곳이 없습니다.
PostgreSQL 과 Redis 는 infra 스택 하나로 올립니다. PostgreSQL 부분만 줄여서 보면 이렇습니다.
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: app
POSTGRES_DB: app_db
# 비밀번호를 값이 아니라 시크릿 파일 경로로 준다
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
TZ: Asia/Seoul
command:
- "postgres"
# 메모리 32GB 서버 기준. 공유 버퍼 4GB, 정렬·해시용 128MB
- "-c"
- "shared_buffers=4GB"
- "-c"
- "work_mem=128MB"
# 60초마다 keepalive 를 보내 오래 조용한 연결이 overlay 에서 끊기지 않게 한다
- "-c"
- "tcp_keepalives_idle=60"
ports:
# 터널이 DB 로 붙는 자리 (아래 방화벽 이야기와 이어진다)
- "127.0.0.1:5432:5432"
volumes:
# PostgreSQL 18 부터는 /var/lib/postgresql 을 붙인다 (안에 18/data 가 생긴다)
- /srv/postgres:/var/lib/postgresql
# 병렬 쿼리용 공유 메모리. Swarm 은 shm_size 를 지원하지 않아 tmpfs 로 키운다
- type: tmpfs
target: /dev/shm
tmpfs:
size: 1073741824 # 1GB (상한이라 쓰는 만큼만 메모리를 먹는다)
networks:
- internal # 웹·배치와만 닿는 내부 네트워크
secrets:
- db_password
deploy:
resources:
limits:
memory: 16G # 서버 메모리의 절반을 넘지 않게
networks:
internal:
driver: overlay
attachable: true
name: internal # 스택 이름이 앞에 붙지 않게. 웹·배치 스택이 이 이름으로 참조한다
secrets:
db_password:
external: true # 위에서 docker secret create 로 만든 것
sudo mkdir -p /srv/postgres
docker stack deploy -c infra-stack.yml infra
써 보며 알게 된 것
- Swarm 에서는
shm_size가 먹지 않습니다. Docker 기본/dev/shm은 64MB 라서 병렬 쿼리가could not resize shared memory segment로 죽었습니다. 위처럼 tmpfs 를/dev/shm에 붙이는 것이 Swarm 에서 키우는 방법입니다. - 오래 조용한 DB 연결이 15분쯤에서 끊겼습니다. Swarm 의 overlay 네트워크는 IPVS 를 거치는데, IPVS 는 15분(900초) 동안 패킷이 없는 연결을 잊어버립니다. 그 뒤 첫 패킷에서 연결이 끊기고 배치가 응답 없이 멈췄습니다. PostgreSQL 이 60초마다 keepalive 를 보내게 해서 막았습니다(
tcp_keepalives_idle은 900보다 작아야 합니다). - PostgreSQL 18 은 붙이는 경로가 바뀌었습니다. 예전처럼
/var/lib/postgresql/data를 붙이면 안 되고, 한 단계 위인/var/lib/postgresql을 붙입니다.
입구: Traefik
바깥에서 들어온 HTTP 요청은 전부 Traefik 의 80번 하나로 들어옵니다. Traefik 은 Swarm 에 떠 있는 서비스의 라벨을 읽어 "이 주소는 이 컨테이너"를 스스로 알아냅니다. 그래서 새 서비스를 띄울 때 Traefik 설정 파일을 고칠 필요가 없고, 그 서비스에 라벨만 붙이면 됩니다.
services:
traefik:
image: traefik:latest
command:
# 대시보드를 인증 없는 내부 포트(8080)에 그대로 열지 않는다
- "--api.insecure=false"
# 대시보드는 켜 두되, 라우터(Host 규칙)를 거쳐서만 연다
- "--api.dashboard=true"
# Swarm 서비스의 라벨을 읽어 라우팅을 스스로 만든다
- "--providers.swarm=true"
# traefik.enable=true 라벨이 붙은 서비스만 연결한다
- "--providers.swarm.exposedbydefault=false"
# 입구는 80 하나 (HTTPS 는 Cloudflare 에서 끝난다)
- "--entrypoints.web.address=:80"
# 요청 수 같은 지표를 Prometheus 형식으로 낸다
- "--metrics.prometheus=true"
ports:
- "80:80" # 터널이 들어오는 입구
- "8082:8080" # 지표 포트 (Netdata 가 가져간다)
volumes:
# Docker 에 서비스 목록과 라벨을 묻는 통로. 읽기 전용
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- traefik-public # 내보낼 서비스와 같이 붙는 overlay 네트워크
deploy:
placement:
constraints:
- node.role == manager # Swarm 정보를 읽으려면 매니저 노드에 있어야 한다
labels:
# Traefik 자기 대시보드도 라벨로 연다
- "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.entrypoints=web"
# 쓰이지 않는 값이지만 없으면 port is missing 오류가 난다
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
networks:
traefik-public:
external: true # docker network create 로 미리 만든 것
docker stack deploy -c traefik-stack.yml traefik
- 입구는
web(80) 하나입니다. HTTPS 는 바깥(Cloudflare)에서 끝나기 때문에 Traefik 은 HTTP 만 받고, 서버에서 인증서를 발급하거나 갱신할 일이 없습니다. exposedbydefault=false라서traefik.enable=true라벨을 붙인 서비스만 연결됩니다. DB 나 Redis 처럼 바깥에 내보내면 안 되는 서비스는 라벨이 없으니 Traefik 이 모른 척합니다.
새 서비스는 이 다섯 줄로 붙습니다. Swarm 에서는 라벨을 deploy.labels 에 넣어야 Traefik 이 읽습니다.
services:
dozzle:
image: amir20/dozzle:latest
volumes:
# 컨테이너 로그를 읽어 오는 통로. 읽기 전용
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- traefik-public # Traefik 이 닿으려면 같은 네트워크에 있어야 한다
deploy:
labels: # Swarm 에서는 deploy.labels 에 넣어야 Traefik 이 읽는다
# 이 서비스를 Traefik 에 연결한다
- "traefik.enable=true"
# 네트워크가 여럿이면 이 네트워크의 IP 로 보낸다
- "traefik.docker.network=traefik-public"
# 이 주소로 온 요청을 받는다 (주소만 바꿔 쓰는 줄)
- "traefik.http.routers.dozzle.rule=Host(`dozzle.example.com`)"
# 80 입구로 들어온 것만
- "traefik.http.routers.dozzle.entrypoints=web"
# 컨테이너 안에서 앱이 듣는 포트로 보낸다 (앱마다 다른 줄)
- "traefik.http.services.dozzle.loadbalancer.server.port=8080"
routers.dozzle, services.dozzle 의 dozzle 은 제가 정하는 이름이라 서비스마다 겹치지 않게 붙이면 됩니다. 웹, 배치, Portainer, Dozzle 이 전부 같은 모양이고 rule 의 주소와 port 만 다릅니다.
써 보며 알게 된 것
- 네트워크가 두 개인 서비스는
traefik.docker.network가 필요합니다. Portainer 는traefik-public과 에이전트용 네트워크 두 곳에 붙어 있어 IP 가 둘인데, 라벨이 없으면 Traefik 이 닿지 않는 쪽 IP 를 골라 브라우저가 무한 로딩합니다. 고른 주소는docker service logs traefik_traefik --tail 20 | grep servers로 보입니다.networks:는 "컨테이너를 이 네트워크에 붙여라", 라벨은 "Traefik 은 이 네트워크의 IP 로 보내라"라서 둘 다 필요합니다. - Traefik 버전을 고정했다가 안 떴습니다. 특정 v3 판은
client version 1.24 is too old. Minimum supported API version is 1.40을 내며 Docker 와 연결하지 못했습니다. 최신 Docker 엔진이 오래된 API 를 받지 않게 바뀌었기 때문이라 최신 이미지로 올렸습니다. - Traefik 대시보드 라우터에도
port라벨이 있어야 합니다. 쓰이지 않는 값이라도 없으면port is missing오류가 납니다. - v3 에서는
PathPrefix에 여러 경로를 한 번에 못 씁니다. 경로마다 따로 쓰고||로 잇습니다(이 목록 아래 코드). - 관리 화면은 경로가 아니라 서브도메인으로 나눴습니다. Dozzle 같은 앱은 자바스크립트·CSS 를
/assets/…처럼 루트 기준 주소로 불러와서,/dozzle같은 경로로 나누면 그 요청이 라우터에 안 걸려 빈 화면이 됩니다.
# v2 문법: 한 PathPrefix 에 경로 여러 개 (v3 에서 안 됨)
- "traefik.http.routers.dash.rule=PathPrefix(`/api`, `/dashboard`)"
# v3 문법: 경로마다 PathPrefix 를 따로 쓰고 || 로 잇는다
- "traefik.http.routers.dash.rule=Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
관리·모니터링: Portainer, Dozzle, Netdata
| 도구 | 언제 여나 |
|---|---|
| Portainer | 스택을 다시 배포하거나, 서비스가 몇 개 떠 있는지, 어떤 이미지인지 볼 때 |
| Dozzle | 웹이나 배치 로그를 실시간으로 볼 때. SSH 로 들어가 docker logs 를 치지 않아도 됩니다 |
| Netdata | 서버가 느려졌을 때 CPU, 메모리, 디스크, DB 연결 수, Traefik 요청 수를 한 화면에서 볼 때 |
Netdata 만 Swarm 밖에 있습니다. 컨테이너별 지표를 보려면 privileged 와 호스트 PID 가 필요한데, Swarm 스택은 privileged 를 지원하지 않습니다. 그래서 docker compose 로 호스트 네트워크에 띄웠습니다. overlay 네트워크에 붙지 않으니 Traefik 도 거치지 않습니다.
services:
netdata:
image: netdata/netdata:latest
container_name: netdata
restart: always # 서버가 다시 켜지면 같이 뜬다 (Swarm 밖이라 직접 적는다)
pid: host # 호스트의 프로세스를 볼 수 있게
privileged: true # 컨테이너별 지표를 읽으려면 필요. Swarm 이 지원하지 않아 compose 로 띄운다
network_mode: host # 호스트 네트워크를 그대로 쓴다. 터널이 127.0.0.1:19999 로 바로 온다
volumes:
- /proc:/host/proc:ro # 호스트 CPU·메모리·프로세스
- /sys:/host/sys:ro # 호스트 장치·cgroup
- /etc/os-release:/host/etc/os-release:ro # 호스트 OS 이름
- /var/run/docker.sock:/var/run/docker.sock:ro # 지표에 컨테이너 이름을 붙이려고
- /srv/netdata:/var/lib/netdata # Netdata 자체 데이터
# compose 파일이 있는 폴더에서
docker compose up -d
Traefik 지표는 Traefik 이 따로 연 Prometheus 포트(8082)에서 가져갑니다.
Portainer 는 서버마다 도는 에이전트와 화면 하나로 나뉩니다.
services:
agent:
image: portainer/agent:<판> # 에이전트와 화면은 같은 판으로 맞춘다
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/volumes:/var/lib/docker/volumes
networks:
- agent_network
deploy:
mode: global # 노드마다 하나씩 (지금은 노드가 하나)
portainer:
image: portainer/portainer-ce:<판>
command: -H tcp://tasks.agent:9001 --tlsskipverify # 에이전트에게 Docker 정보를 받는다
volumes:
- /srv/portainer:/data # 계정·설정 보관
networks:
- traefik-public # Traefik 이 닿는 쪽
- agent_network # 에이전트와 이야기하는 쪽
deploy:
placement:
constraints: [node.role == manager]
labels:
- "traefik.enable=true"
# 네트워크가 둘이라 이 줄이 없으면 Traefik 이 닿지 않는 IP 를 고를 수 있다
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.portainer.rule=Host(`portainer.example.com`)"
- "traefik.http.routers.portainer.entrypoints=web"
- "traefik.http.services.portainer.loadbalancer.server.port=9000"
networks:
traefik-public:
external: true
agent_network:
driver: overlay
sudo mkdir -p /srv/portainer
docker stack deploy -c portainer-stack.yml portainer
Portainer 는 처음 뜬 뒤 5분 안에 관리자 계정을 만들지 않으면 스스로 잠깁니다. 저는 관리 화면 앞에 Cloudflare Access 로 구글 로그인을 두고 있어서, 로그인을 거치다 보면 5분을 넘기기 쉬웠습니다. 그래서 브라우저 대신 같은 네트워크에 있는 Traefik 컨테이너 안에서 API 로 계정을 만들었습니다.
# 비밀번호는 12자 이상
docker exec $(docker ps -q -f name=traefik) \
wget -O- \
--post-data='{"Username":"admin","Password":"<12자 이상>"}' \
--header='Content-Type: application/json' \
http://portainer_portainer:9000/api/users/admin/init
# 이미 잠겼으면: docker service update --force portainer_portainer 뒤 5분 안에 다시
운영: 배포와 백업
배포: GitHub 푸시 한 번
git push origin main:deploy_web
│
▼
GitHub ── 웹훅 ──▶ (Cloudflare 터널) ──▶ webhook (서명·브랜치 확인)
│
▼
배포 스크립트
├─ 서버의 저장소에서 main 을 받는다
├─ 이미지 빌드 (JDK 로 빌드, JRE 만 담은 작은 이미지로 실행)
└─ docker service update 로 교체
서버의 webhook 은 데비안 패키지로 깔고 systemd 서비스로 띄웁니다.
sudo apt install -y webhook
sudo mkdir -p /etc/webhook # 여기에 hooks.json 을 둔다
# /etc/systemd/system/webhook.service
[Unit]
Description=Webhook Server
After=network.target
[Service]
# 배포 스크립트가 git 과 docker 를 쓰니 docker 그룹에 든 계정으로 돌린다
User=me
# hooks.json 을 읽고 9000번에서 기다린다 (터널이 127.0.0.1:9000 으로 온다)
ExecStart=/usr/bin/webhook -hooks /etc/webhook/hooks.json -port 9000 -verbose
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
sudo journalctl -u webhook -f # 푸시가 들어오는지 본다
GitHub 저장소의 Settings → Webhooks 에는 이렇게 넣습니다.
| 칸 | 값 |
|---|---|
| Payload URL | https://webhook.example.com/hooks/<훅 id> (hooks.json 의 id) |
| Content type | application/json |
| Secret | hooks.json 에 넣은 비밀값과 같은 값 |
| 이벤트 | Just the push event |
webhook 은 요청을 받으면 두 가지를 확인하고, 둘 다 맞을 때만 배포 스크립트를 실행합니다. 하나는 GitHub 이 비밀값으로 만든 서명(X-Hub-Signature-256)이고, 다른 하나는 브랜치 이름입니다. 안 맞으면 Hook rules were not satisfied. 로 거절합니다.
hooks.json 의 확인 규칙입니다. 첫 번째 match 가 서명, 두 번째가 브랜치 이름입니다.
"trigger-rule": {
"and": [
{ "match": { "type": "payload-hmac-sha256", "secret": "<GitHub 에 넣은 비밀값>",
"parameter": { "source": "header", "name": "X-Hub-Signature-256" } } },
{ "match": { "type": "regex", "regex": "^refs/heads/deploy_(web|batch)$",
"parameter": { "source": "payload", "name": "ref" } } }
]
}
main에 푸시해도 배포되지 않습니다. 배포는deploy_web,deploy_batch브랜치에main을 밀어 넣는 것이 신호입니다.- 함정이 하나 있었습니다. 서버는 신호가 온 브랜치가 아니라
main을 받아 빌드합니다. 그래서main에 올리지 않고deploy_batch만 밀면, 배포는 성공으로 끝나는데 서버에는 옛 코드가 올라갑니다. 지금은 항상main을 먼저 푸시하고, 배포된 이미지 태그(커밋 해시)로 확인합니다.
백업
서버 설정을 묶은 스냅샷과 DB 전체 덤프를 만들어 클라우드 저장소로 올리는 스크립트가 있습니다. 묶음 안에 복구 순서를 적은 문서도 같이 넣습니다. 지금은 큰 작업 전이나 생각날 때 손으로 돌립니다. 정기 백업으로 바꾸는 것은 할 일로 남아 있습니다.
호스트 보안: 방화벽, SSH, fail2ban
방화벽: UFW, 그리고 UFW 를 우회하는 Swarm 포트
sudo apt install -y ufw
sudo ufw default deny incoming # 들어오는 것은 기본으로 막는다
sudo ufw default allow outgoing # 나가는 것은 허용 (터널은 서버가 바깥으로 여는 연결)
sudo ufw allow 2200/tcp # SSH 포트. SSH 포트를 바꾸기 전에 먼저 열어 둔다
sudo ufw enable
sudo ufw status
허용한 것은 SSH 포트 하나뿐입니다. 터널은 나가는 연결이라 들어오는 포트가 필요 없습니다.
그런데 Swarm 이 연 포트는 UFW 를 거치지 않습니다. PostgreSQL 스택에 127.0.0.1:5432:5432 로 서버 안에서만 열리길 기대하고 적어 두었는데, docker service ls 를 보면 이렇게 나옵니다.
infra_postgres *:5432->5432/tcp
traefik_traefik *:80->80/tcp, *:8082->8080/tcp
Swarm 의 기본 포트 공개 방식(ingress)은 앞의 127.0.0.1 을 무시하고 모든 주소에 엽니다. 게다가 Docker 가 연 포트는 iptables 에 Docker 자기 규칙을 직접 만들어서 UFW 를 거치지 않습니다. 같은 시각 ufw status 에는 SSH 포트 하나만 허용으로 나왔지만, 5432·80·8082 는 그와 상관없이 열려 있습니다.
인터넷에서 이 포트들에 닿지 않는 이유는 UFW 가 아니라 공유기에 포트 포워딩이 없기 때문입니다. 집 안 네트워크에서는 서버 IP 의 5432 로 닿습니다. 집 안까지 막는 방법(Docker 의 DOCKER-USER 체인에 규칙을 넣는 것 등)이 있지만, 집 안에만 열려 있어서 아직 고치지 않았습니다.
그러니 "공유기 포트 0개"는 말 그대로 지켜야 하는 마지막 벽입니다. 공유기 설정을 손댈 때마다 포트 포워딩 목록이 비어 있는지 봅니다.
SSH: 구글 OTP, root 금지, fail2ban
sshd 설정. Debian 12 는 /etc/ssh/sshd_config.d/ 의 파일을 본문보다 먼저 읽고, sshd 는 같은 설정이 여러 번 나오면 처음 값을 씁니다. 그래서 본문을 고치지 않고 그 폴더에 파일을 하나 둡니다.
sudo tee /etc/ssh/sshd_config.d/10-local.conf > /dev/null <<'EOF'
# 22번 대신 다른 포트 (UFW 에 먼저 열어 둔 번호)
Port 2200
# root 로는 SSH 로그인을 못 하게
PermitRootLogin no
# OTP 를 묻는 단계(키보드 입력 인증)를 켠다
KbdInteractiveAuthentication yes
EOF
sudo sshd -t && sudo systemctl reload ssh # 문법을 확인한 뒤 다시 읽는다
sudo sshd -T | grep -Ei '^(port|permitrootlogin|kbdinteractiveauthentication)'
지금 접속한 창은 닫지 말고, 새 창으로 접속되는지 먼저 확인합니다. 설정을 잘못 넣어도 기존 창이 살아 있으면 되돌릴 수 있습니다.
구글 OTP. 비밀번호 앞에 휴대폰 OTP 앱의 여섯 자리 숫자를 한 번 더 묻게 합니다.
sudo apt install -y libpam-google-authenticator
# 접속할 계정으로 실행한다. 나오는 QR 코드를 OTP 앱으로 찍는다
# 질문에는 시간 기반(y), 같은 코드 다시 쓰기 금지(y), 시도 횟수 제한(y)으로 답한다
google-authenticator
그리고 /etc/pam.d/sshd 맨 위에 한 줄을 넣습니다.
auth required pam_google_authenticator.so
끝에 nullok 을 붙이지 않았기 때문에 OTP 를 등록하지 않은 계정은 아예 들어올 수 없습니다.
fail2ban. 로그인을 여러 번 틀린 IP 를 막습니다. 그런데 Debian 12 기본 설정으로는 안 뜹니다. Debian 12 는 rsyslog 를 기본으로 깔지 않아 /var/log/auth.log 가 없는데, fail2ban 기본값은 그 파일을 읽으려다 실패해서 서비스가 죽습니다(Debian 버그 1037437). jail.d 에 파일을 하나 더해 journald 를 읽게 합니다.
# backend = systemd 를 쓰려면 python3-systemd 가 있어야 한다
sudo apt install -y fail2ban python3-systemd
# /etc/fail2ban/jail.d/sshd-systemd.local
[sshd]
# 로그 파일 대신 journald 에서 sshd 기록을 읽는다 (Debian 12 에서 이 줄이 핵심)
backend = systemd
# 차단할 때 막을 포트. SSH 포트를 바꿨다면 바꾼 번호
port = 2200
# 3번 틀리면 차단 (기본 5번)
maxretry = 3
# 하루 동안 막는다 (기본 10분)
bantime = 1d
# 다시 걸린 IP 는 차단 시간을 1배, 2배, 4배로 늘린다
bantime.increment = true
sudo systemctl restart fail2ban
sudo systemctl is-active fail2ban # active
sudo fail2ban-client status sshd # 감시 중인지, 차단된 IP 목록
2부. Cloudflare: 서버를 바깥에 내보내고 지키기
Cloudflare 가 맡은 일
| 역할 | 하는 일 |
|---|---|
| DNS | 도메인과 서브도메인 주소 |
| 캐시 | 바뀌지 않는 페이지를 Cloudflare 가 대신 응답해서 서버까지 안 내려오게 합니다 |
| 터널 | 공유기 포트 없이 바깥 요청을 서버로 넣는 통로 |
| Access | 관리 화면 앞에서 "누구냐"를 먼저 묻습니다 |
| Worker | 서버를 끄는 동안 점검 안내 페이지를 503 과 Retry-After 로 돌려줍니다. 200 으로 주면 검색 엔진이 사이트 내용이 바뀐 것으로 읽을 수 있습니다 |
터널: 공유기 포트 0개
서버의 cloudflared 가 Cloudflare 쪽으로 연결을 먼저 열어 두고, 모든 요청은 그 연결을 타고 들어옵니다. 그래서 공유기에서 서버로 들어오는 포트를 열 필요가 없고, 서버 안의 서비스는 127.0.0.1 에만 붙어 있으면 됩니다. cloudflared 는 대시보드에서 터널을 만들 때 보여 주는 설치 명령으로 깝니다. 마지막 줄이 systemd 서비스로 등록하는 명령이고, 끝의 토큰이 이 터널의 열쇠라 남에게 보이지 않게 합니다.
sudo cloudflared service install <터널 토큰>
systemctl is-active cloudflared # active
경로(어느 주소를 서버의 어디로 보낼지)는 서버가 아니라 대시보드에서 관리합니다.
터널에는 "이 주소로 온 요청은 서버의 어디로"를 경로로 적습니다. 아래 메뉴 이름은 2026년 10월 한국어 화면 기준입니다. Cloudflare 화면은 자주 바뀌니, 못 찾겠으면 Zero Trust 대시보드 위쪽 검색창에 "터널"이나 "애플리케이션"을 치면 됩니다.
경로 넣는 곳: 터널 목록에서 쓰는 터널을 고르고 경로 목록에서 경로 추가 → 게시된 애플리케이션을 고릅니다. 공개 애플리케이션 추가 화면에 서브도메인, 도메인, 경로(비워 둠), 서비스 URL 을 넣고, 추가 애플리케이션 설정은 기본값으로 둡니다. 순서는 경로 목록에서 줄 왼쪽 손잡이를 끌어 바꿉니다.
| 순서 | 대상 | 서비스 URL |
|---|---|---|
| 1 | db.example.com | tcp://127.0.0.1:5432 |
| 2 | ssh.example.com | ssh://127.0.0.1:2200 |
| 3 | webhook.example.com | http://127.0.0.1:9000 |
| 4 | netdata.example.com | http://127.0.0.1:19999 |
| 5 | *.example.com | http://127.0.0.1:80 (Traefik) |
| 6 | example.com | http://127.0.0.1:80 (Traefik) |
- 5번 와일드카드 한 줄이 Traefik 으로 가는 서브도메인을 다 받습니다. 새 관리 화면은 Cloudflare 대시보드에 갈 필요 없이 서버에서 라벨만 붙이면 됩니다. 와일드카드는
example.com자체는 덮지 않아서 6번을 따로 둡니다. - 순서가 중요합니다. 경로는 위에서 아래로 처음 맞는 것이 받으므로, Traefik 을 거치지 않는 1~4번은 와일드카드보다 위에 둡니다.
localhost대신127.0.0.1. 처음에http://localhost:80으로 적었더니 로그인을 마친 뒤 브라우저가 계속 로딩만 했고 Traefik 로그에는 아무것도 없었습니다.sudo tcpdump -i lo ip6 and port 80으로 보니cloudflared가 IPv6::1:80으로 붙으려 하고 있었습니다. 요즘 리눅스에서localhost는::1로 먼저 풀리는데 Swarm 은 IPv4 에만 포트를 엽니다.- 예전 화면의 "서비스 유형" 칸은 없어졌습니다. 지금은 서비스 URL 앞에
http://,ssh://,tcp://를 붙여 한 칸에 씁니다.
내 PC 에서 DB 와 SSH 로 붙기
PC 에도 cloudflared 를 깔고, 내 PC 의 포트 하나를 터널 입구로 엽니다. 처음 한 번 브라우저에서 구글 로그인을 하면 그다음부터 그 포트로 붙습니다.
# DB: 이 창을 켜 둔 채로 DB 도구에서 127.0.0.1:15432 로 접속
cloudflared access tcp --hostname db.example.com --url 127.0.0.1:15432
SSH 는 ~/.ssh/config 에 넣어 두면 ssh home, sftp home 한 줄로 붙습니다.
# ssh home 으로 부를 이름
Host home
# Access 를 건 터널 주소
HostName ssh.example.com
User me
# 터널을 거쳐 붙는다. %h 는 위 HostName 으로 바뀐다
ProxyCommand cloudflared access ssh --hostname %h
옮긴 뒤 알아 둘 것이 하나 있습니다. 터널로 들어온 SSH 는 서버 안의 cloudflared 가 대신 붙어서 sshd 에는 출발지가 전부 127.0.0.1 로 보입니다. fail2ban 은 기본값(ignoreself = true)으로 자기 주소를 막지 않으니, 터널 쪽은 fail2ban 이 아니라 아래의 Access 가 지켜야 합니다. SSH 경로를 Access 없이 열어 두면 포트를 닫기 전보다 오히려 약해집니다.
Access: 누구인지 먼저 묻기
터널만 있으면 주소를 아는 누구나 관리 화면까지 옵니다. Access 는 Cloudflare 앞단에서 "누구냐"를 먼저 묻고, 통과한 요청만 터널로 보냅니다. 서버까지 오기 전에 걸러지니 서버 쪽 로그인 화면에 무차별 대입이 닿지 않습니다.
앱 만드는 곳: 애플리케이션 화면에서 새 애플리케이션 만들기 → 자체 호스팅을 고릅니다. 애플리케이션 세부 정보 탭의 대상 → 공개 호스트 이름에 하위 도메인과 도메인을 넣고(와일드카드면 하위 도메인에 *), 아래 Access 정책에서 새 정책 만들기로 정책을 만들어 붙입니다. 정책 화면에서는 정책 이름, 작업(허용·우회·서비스 인증 같은 동작), 정책 규칙의 포함 칸에서 선택기(이메일, 모든 사람 등)를 고릅니다. 만든 정책은 다른 앱에서 기존 정책 추가로 다시 붙일 수 있습니다.
들어오는 쪽을 세 종류로 나눴습니다.
| 누가 | 무엇 | 정책 |
|---|---|---|
| 나 (브라우저) | Portainer, Dozzle, Traefik 대시보드, Netdata, DB, SSH | Allow(허용), 포함 규칙: 내 구글 이메일 |
| 나 (배치 호출) | 배치 API | Service Auth(서비스 인증), 서비스 토큰 |
| GitHub | 배포 웹훅 | Bypass(우회). 대신 서버의 webhook 이 서명을 확인 |
배치는 제가 손으로 부르지만 브라우저가 아니라 curl 이라서 구글 로그인을 할 수 없습니다. 그래서 서비스 토큰을 헤더에 넣습니다.
curl -H "CF-Access-Client-Id: <클라이언트 ID>" \
-H "CF-Access-Client-Secret: <클라이언트 비밀값>" \
https://batch.example.com/...
GitHub 웹훅은 헤더를 마음대로 넣을 수 없어서 Access 는 통과시키고, 서명 확인은 서버의 webhook 에 맡깁니다.
Access 앱을 서브도메인마다 따로만 만들면 빈틈이 생깁니다. 터널의 와일드카드 경로와 합치면 이렇게 됩니다.
- 새 관리 화면을 띄우고 Traefik 라벨을 붙입니다.
- 터널의
*.example.com경로가 받아 Traefik 으로 보내니 바로 접속됩니다. - 그 서브도메인용 Access 앱은 아직 없으니 로그인 없이 인터넷에 열립니다.
라벨만 붙이면 끝나는 편리함이 그대로 빈틈이 됩니다. 그래서 *.example.com 앱을 하나 더 만들어 내 이메일만 허용하는 정책을 겁니다. 이러면 앱이 없는 서브도메인은 기본으로 막힙니다.
이때 순서를 지켜야 합니다. 와일드카드 앱을 먼저 저장하면 웹훅까지 구글 로그인에 막혀 배포가 멈춥니다. 웹훅용 앱을 먼저 만들어 정책을 우회(Bypass), 포함 규칙을 모든 사람(Everyone)으로 둔 다음 와일드카드 앱을 저장합니다. *.example.com 은 example.com 자체를 덮지 않아서 손님이 보는 사이트에는 영향이 없습니다.
와일드카드 앱을 저장한 뒤에도, 원래 앱이 있던 서브도메인은 로그인 주소에 붙는 앱 구분값(kid)이 저장 전과 같았습니다. 와일드카드 앱이 생겨도 더 구체적인 주소의 앱이 그대로 적용됩니다. Cloudflare 문서에서 이 우선순위를 적어 둔 곳은 찾지 못해 직접 확인했습니다.
확인은 밖에서 curl 로 합니다. Access 가 걸려 있으면 Cloudflare 로그인 주소로 302 를 돌려줍니다. DB 와 SSH 처럼 HTTP 가 아닌 경로도 HTTP 로 물어보면 똑같이 302 가 옵니다.
for h in portainer netdata db ssh webhook nothing-here; do
printf '%-14s ' "$h"
curl -s -o /dev/null -w '%{http_code}\n' "https://$h.example.com/"
done
portainer 302
netdata 302
db 302
ssh 302
webhook 200
nothing-here 302
새 서비스를 붙인 뒤 한 번씩 돌리면 됩니다.
정리
| 구성 | 맡은 일 | 지키는 것 |
|---|---|---|
| Docker Swarm | 웹, 배치, DB, Redis, 관리 화면 | Swarm 포트는 UFW 를 우회하니 공유기가 마지막 벽 |
| Traefik | 서브도메인별 연결 | 라벨 붙은 서비스만 |
| Netdata, Dozzle, Portainer | 지표, 로그, 관리 | Access 뒤 |
| webhook | 배포 | 서명과 브랜치 확인 |
| sshd | 서버 접속 | 구글 OTP, root 금지, fail2ban (터널 쪽은 Access) |
| 백업 | 설정과 DB | 손으로 (정기화는 할 일) |
| 공유기 | 집 인터넷 | 포트 포워딩 0개 (SSH 포트를 닫음) |
| cloudflared | 터널 | 서버에서 바깥으로만 연결 |
| Cloudflare | DNS, 캐시, 터널 입구, Access, 점검 페이지 | 브라우저는 구글 로그인, 배치 호출은 서비스 토큰, 웹훅은 서명. 앱이 없는 서브도메인은 와일드카드 앱이 막음 |
마치며
혼자 운영하는 인프라는 한 번 만들어 두면 다시 들여다볼 일이 잘 없습니다. 그래서 설정 파일을 다시 읽는 것보다, 지금 실제로 돌고 있는 값을 가끔 확인하는 습관이 더 쓸모 있었습니다. docker service ls(무엇이 어느 포트에 열려 있나), systemctl --failed(죽어 있는 서비스가 있나), 밖에서 보내는 curl(로그인 없이 열리는 주소가 있나) 세 가지면 대부분 드러납니다.
덧붙임: 신나게마을 구경 오세요
이 서버에서 도는 사이트가 신나게마을입니다. 우리 동네 아파트 실거래가와 전월세, 미세먼지, 인구 변화 같은 공공데이터를 동네 단위로 한 화면에 모아 보여 줍니다. 이사 갈 동네를 알아보시거나, 지금 사는 동네가 숫자로는 어떤지 궁금하실 때 한번 들러 주세요.
https://sinnake-town.com
참고
- Connect to SSH with client-side cloudflared (Cloudflare 문서)
- Arbitrary TCP, cloudflared access tcp (Cloudflare 문서)
- Service tokens (Cloudflare 문서)
- Application paths, 와일드카드가 덮는 범위 (Cloudflare 문서)
- Changes to system logging, Debian 12 릴리스 노트 5.1.7
- Debian 버그 1037437: bookworm 에서 fail2ban sshd jail 이 rsyslog 없이 동작하지 않음
- fail2ban jail.conf (ignoreself 설명)