국내만 열린 홈서버에 HTTPS 붙이기 — Let’s Encrypt DNS-01 + DuckDNS 와일드카드
iptime의 “국가별 접속 제한(대한민국만 허용)”을 켜 두면 해외에서 들어오는 트래픽이 막혀서, 80포트로 검증하는 HTTP-01 방식은 인증서 발급이 안 된다. 이럴 땐 포트를 열 필요가 없는 DNS-01(DuckDNS TXT) 방식으로 와일드카드 인증서를 받으면 된다. 보안 설정은 그대로 두고 HTTPS만 붙일 수 있다.
이 글은 iptime 공유기에서 DuckDNS + HTTPS 설정하기의 후속편이다. 앞 글에서 DuckDNS 도메인 등록, IP 자동 갱신, 포트포워딩, Docker Compose 기본 구성까지 다뤘으니 여기서는 반복하지 않는다. 대신 “분명히 똑같이 따라 했는데 인증서 발급에서 막히는” 상황, 즉 해외 인바운드가 차단된 환경을 진단하고 DNS-01로 갈아타는 부분만 짚는다.
글에서 쓰는 예시 환경은 이렇다.
- 서버 LAN IP
192.168.0.22, 도메인 접두어mydomain(→mydomain.duckdns.org) - 서비스 세 개:
engwrite(Django),birthplanner(Vite SPA),cdocs(VitePress). 호스트 포트는 각각 8000, 8100, 8200 - 목표는
https://engwrite.mydomain.duckdns.org처럼 서브도메인으로 노출하는 것
먼저 전체 그림부터 보자. iptime, DuckDNS, Let’s Encrypt, nginx, 서버가 어떻게 얽혀 있는지를 그려 보면 이렇다.
graph TD
USER["국내 사용자
브라우저"] -->|"https://*.mydomain.duckdns.org
(443)"| DUCK
LE["Let's Encrypt
(해외 검증 서버)"] -.->|"DNS-01: TXT 조회
_acme-challenge"| DUCK
OVERSEAS["해외 인바운드
(80/443)"] -.->|"국가별 접속 제한
으로 차단"| IPTIME
DUCK["DuckDNS
(DNS + TXT 레코드)"] -->|"도메인 → 공유기 공인 IP"| IPTIME
IPTIME["iptime 공유기
국가별 접속 제한: 한국만 허용
포트포워딩 443"] -->|"443 → 192.168.0.22"| NGINX
subgraph SERVER["리눅스 서버 (192.168.0.22)"]
NGINX["nginx
리버스 프록시 + 와일드카드 TLS 종단"]
CERTBOT["certbot (DNS-01)
duckdns-hook.sh 로 TXT 갱신"]
NGINX -->|":8000"| A["engwrite (Django)"]
NGINX -->|":8100"| B["birthplanner (Vite SPA)"]
NGINX -->|":8200"| C["cdocs (VitePress)"]
end
CERTBOT -->|"TXT add/clean"| DUCK
CERTBOT -->|"와일드카드 인증서 발급
*.mydomain.duckdns.org"| NGINX
style USER fill:#e3f2fd,stroke:#1565c0
style DUCK fill:#fff3e0,stroke:#e65100
style IPTIME fill:#fce4ec,stroke:#c62828
style NGINX fill:#e8f5e9,stroke:#2e7d32
style CERTBOT fill:#ede7f6,stroke:#5e35b1
style LE fill:#f3e5f5,stroke:#8e24aa
style OVERSEAS fill:#ffebee,stroke:#b71c1c,stroke-dasharray: 5 5
요점은 점선으로 표시한 해외 인바운드는 계속 막아 둔 채로, 인증서 검증만 서버에 직접 접속할 필요가 없는 DNS-01(TXT 조회)로 돌린다는 것이다. 보안은 유지하면서 발급 경로만 우회하는 셈이다.
[00] 흔한 가이드대로 했는데 왜 막힐까
블로그에 가장 많이 나오는 방식은 Let’s Encrypt의 HTTP-01이다. certbot이 /.well-known/acme-challenge/ 아래에 검증용 파일을 하나 만들어 두면, Let’s Encrypt가 외부에서 80포트로 그 파일을 읽어 보고 “이 도메인 주인이 맞네” 하고 인증서를 내준다. 앞 글의 STEP 06이 바로 이 방식이다.
문제는 한국 가정용 회선(특히 KT)이나 iptime 보안 설정 탓에 해외에서 들어오는 연결이 막혀 있는 경우다. Let’s Encrypt 검증 서버는 전부 해외에 있어서, 우리 서버 80포트에 닿질 못하고 그대로 발급이 실패한다.
1
2
3
Certbot failed to authenticate some domains (authenticator: webroot).
Detail: <공인IP>: Fetching http://<도메인>/.well-known/acme-challenge/...:
Timeout during connect (likely firewall problem)
이 에러가 뜨면 십중팔구 포트포워딩부터 다시 들여다보게 되는데, 정작 포트포워딩은 멀쩡하고 해외 IP만 차단돼 있는 경우가 꽤 많다. 그러니 원인부터 제대로 가려내자.
[01] 진단 — 정말 밖에서 못 들어오는 게 맞나
1-1. DNS와 공인 IP가 일치하는지
1
2
3
curl -s ifconfig.me; echo # 내 공인 IP
dig +short mydomain.duckdns.org # 위와 같아야 정상
dig +short engwrite.mydomain.duckdns.org # DuckDNS는 서브도메인도 같은 IP로 응답
1-2. 해외에서 포트가 열려 있나 — 여기가 제일 중요하다
국내에서 nc로 찔러 보면 멀쩡히 열린 것처럼 보일 수 있다. 국내는 어차피 허용돼 있으니까. 그래서 반드시 해외 노드 기준으로 봐야 한다. check-host.net을 쓰면 여러 나라 노드에서 한 번에 확인된다.
1
2
3
4
5
6
# 80 포트를 해외 8개 노드에서 검사
RID=$(curl -s -H 'Accept: application/json' \
"https://check-host.net/check-tcp?host=mydomain.duckdns.org:80&max_nodes=8" \
| grep -oE '"request_id":"[^"]+"' | cut -d'"' -f4)
sleep 12
curl -s -H 'Accept: application/json' "https://check-host.net/check-result/$RID"
해외 노드가 죄다 Connection timed out인데 국내만 된다면, 포트포워딩이나 CGNAT 문제가 아니라 지역(해외 IP) 기반으로 인바운드가 막힌 것이다.
구별하는 팁 하나. 80, 443뿐 아니라 이미 포워딩해 둔 아무 고포트나 하나 골라 해외에서 찔러 봤을 때도 timeout이라면, “특정 포트만 막힌 것”이 아니라 “지역 자체가 막힌 것”이다.
1-3. 범인은 iptime 국가별 접속 제한
iptime 관리페이지에서 보안 기능 → 국가별 접속 제한을 열어 보자. 아래처럼 허용 목록에 South Korea(대한민국) 하나만 들어 있으면, 나머지 전 세계 인바운드가 전부 막힌다. 아래 예시에서는 84,856개가 차단으로 잡혀 있다.

이 설정은 보안상 꽤 쓸모가 있으니 끄지 말고 그대로 두자. 끄는 대신 인증서 발급 방식을 바꾸면 된다.
[02] 어떻게 풀까 — 해외 접근이 필요한지부터 정하자
| 상황 | 해법 |
|---|---|
| 국내에서만 쓰면 됨 (개인 홈서버 대부분) | 국가별 제한 유지 + DNS-01 ← 이 글 |
| 해외에서도 접근해야 함 | 국가별 제한을 풀고 HTTP-01, 또는 Cloudflare Tunnel / Tailscale |
DNS-01은 Let’s Encrypt가 우리 서버에 직접 접속하지 않는다. DNS에 박아 둔 TXT 레코드만 읽어서 검증하기 때문에, 인바운드가 막혀 있어도 발급도 되고 자동 갱신도 된다. 실제 서비스(443)는 어차피 국내 사용자만 받으면 되니, 국가별 접속 제한과 부딪힐 일도 없다.
DuckDNS는 도메인 하나당 TXT 슬롯이 딱 1개뿐이다. 그래서 서브도메인이 여러 개라고 각각 발급하려 들면 슬롯이 모자란다. 이럴 땐 와일드카드 *.mydomain.duckdns.org 한 장으로 받는 게 정석이다. 와일드카드는 _acme-challenge.mydomain.duckdns.org TXT 하나로 검증되니 슬롯 문제도 없다.
[03] 사전 준비
- DuckDNS 도메인과 토큰. 앞 글의 STEP 02를 참고해 도메인(
mydomain)을 추가하고 페이지 상단의 token을 복사해 둔다. - iptime 포트포워딩은 외부
443→192.168.0.22:443하나면 된다. DNS-01은 80이 필요 없다. 다만 국내 사용자가 HTTPS로 접속하려면 443은 열려 있어야 한다. 국가별 접속 제한이 켜져 있어도 국내 IP는 이 443으로 잘 들어온다. - 서버에는
docker와docker compose(v2), 그리고curl,dig정도가 있으면 된다.
[04] 인증서 발급 — certbot DNS-01 + DuckDNS hook
핵심은 certbot의 --manual 모드에, DuckDNS TXT 레코드를 넣고 빼 주는 hook 스크립트를 물리는 것이다.
4-1. DuckDNS hook 스크립트
~/myserver/scripts/duckdns-hook.sh를 만들고 실행 권한을 준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh
set -eu
action="${1:-}"
: "${DUCKDNS_DOMAIN:?}"; : "${DUCKDNS_TOKEN:?}"
base="https://www.duckdns.org/update?domains=${DUCKDNS_DOMAIN}&token=${DUCKDNS_TOKEN}"
case "$action" in
add) url="${base}&txt=${CERTBOT_VALIDATION}" ;;
clean) url="${base}&txt=removed&clear=true" ;;
*) echo "usage: duckdns-hook.sh add|clean" >&2; exit 2 ;;
esac
resp="$(curl -fsS "$url" || wget -qO- "$url")"
echo "duckdns-hook($action) -> $resp" >&2
[ "$action" = add ] && [ "$resp" != OK ] && exit 1
[ "$action" = add ] && sleep "${DUCKDNS_PROPAGATION:-30}" # TXT 전파 대기
exit 0
스크립트 안의 CERTBOT_DOMAIN, CERTBOT_VALIDATION은 우리가 채우는 게 아니다. certbot이 hook을 실행할 때 알아서 넣어 주는 환경변수다.
4-2. docker compose의 certbot 서비스
앞 글의 docker-compose.yml에서 쓰던 certbot 서비스에, 방금 만든 hook 스크립트와 토큰을 넣어 준다.
1
2
3
4
5
6
7
8
9
10
11
12
certbot:
image: certbot/certbot
container_name: certbot
restart: unless-stopped
env_file:
- ./duckdns.env # DUCKDNS_DOMAIN / DUCKDNS_TOKEN / DUCKDNS_PROPAGATION
volumes:
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
- ./scripts:/scripts:ro
# 12시간마다 자동 갱신 (저장된 manual hook 을 재사용)
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
토큰은 ~/myserver/duckdns.env에 따로 두고 권한을 600으로 잠가 둔다.
1
2
3
DUCKDNS_DOMAIN=mydomain
DUCKDNS_TOKEN=여기에-토큰
DUCKDNS_PROPAGATION=30
4-3. 발급은 staging으로 리허설부터
Let’s Encrypt 운영 인증서는 주당 5회 발급 한도가 있다. 설정을 디버깅하다 한도를 까먹으면 곤란하니, 먼저 --dry-run(staging)으로 한 번 돌려 본다.
1
2
3
4
5
6
7
8
9
cd ~/myserver
docker compose run --rm --entrypoint certbot certbot certonly \
--manual --preferred-challenges dns \
--manual-auth-hook "/scripts/duckdns-hook.sh add" \
--manual-cleanup-hook "/scripts/duckdns-hook.sh clean" \
--cert-name mydomain.duckdns.org \
-d '*.mydomain.duckdns.org' \
--email you@example.com --agree-tos --no-eff-email --non-interactive \
--dry-run
The dry run was successful.가 보이면 --dry-run만 빼고 다시 돌린다. 이번엔 진짜 발급이다.
1
2
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/mydomain.duckdns.org/fullchain.pem
여기서 두 번 잘 밟는 함정이 있다.
하나, entrypoint가 인자를 삼킨다. 위 compose처럼 certbot에 자동 갱신용 entrypoint(무한 renew 루프)가 걸려 있으면, docker compose run certbot certonly ...를 실행해도 entrypoint가 인자를 먹어 버려서 certonly가 돌지 않는다. No renewals were attempted만 찍고 멈춘다면 이 경우다. 위 명령처럼 --entrypoint certbot으로 덮어써 줘야 한다.
둘, 와일드카드 인증서 경로. --cert-name mydomain.duckdns.org로 lineage 이름을 고정해 두면 인증서가 live/mydomain.duckdns.org/에 저장된다. 그래야 다음 단계 nginx 설정의 경로와 깔끔하게 맞아떨어진다.
[05] nginx — 서브도메인마다 443, 인증서는 와일드카드 한 장
~/myserver/nginx/nginx.conf의 요지만 보면 이렇다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
events {}
http {
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
client_max_body_size 50m;
ssl_protocols TLSv1.2 TLSv1.3; # certonly 는 options-ssl-nginx.conf 를 안 만드므로 인라인
# 80 → 443 리다이렉트 (국내 사용자용)
server {
listen 80;
server_name mydomain.duckdns.org *.mydomain.duckdns.org;
location / { return 301 https://$host$request_uri; }
}
# 서브도메인마다 443 server — 전부 같은 와일드카드 인증서 사용
server {
listen 443 ssl;
server_name engwrite.mydomain.duckdns.org;
ssl_certificate /etc/letsencrypt/live/mydomain.duckdns.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mydomain.duckdns.org/privkey.pem;
location / {
proxy_pass http://192.168.0.22:8000; # 끝 '/' 없음 = 루트 그대로 전달
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# birthplanner(:8100), cdocs(:8200) 도 같은 형태로 server 블록 추가
}
1
cd ~/myserver && docker compose down && docker compose up -d
HTTP-01 때와 달리 certonly는 options-ssl-nginx.conf나 ssl-dhparams.pem 같은 보조 파일을 만들어 주지 않는다. 그걸 include하려 들면 nginx가 파일이 없다며 기동에 실패한다. 그래서 위처럼 ssl_protocols를 conf 안에 직접 적어 줬다.
[06] 앱은 루트(‘/’)에서 돌리자 — 서브도메인의 진짜 장점
서브도메인 방식의 좋은 점은 각 앱이 자기 루트에서 그냥 돌아간다는 것이다. base-path를 건드릴 일이 없다. 반대로 /engwrite/ 같은 경로 방식으로 가면 base를 바꿔야 하는데, 그 순간 정적 파일 경로나 리다이렉트가 줄줄이 깨지기 시작한다. 혹시 전에 경로 방식을 시도해 본 적이 있다면, 그 흔적을 루트로 되돌려 놓아야 한다.
-
Django(engwrite) —
.env에서DJANGO_ALLOWED_HOSTS=engwrite.mydomain.duckdns.org,127.0.0.1,localhostCSRF_TRUSTED_ORIGINS=https://engwrite.mydomain.duckdns.org-
USE_HTTPS=true,FORCE_SCRIPT_NAME=(비워서 루트 서빙) - settings에서
SECURE_SSL_REDIRECT=False(HTTPS 종단은 바깥 nginx가 한다)
-
Vite SPA(birthplanner) —
vite.config.ts의base를 지워 기본값/로 두고, React Router의basename도 빼고 다시 빌드한다. -
VitePress(cdocs) — config의
base: '/cdocs/'를 지워 기본값/로 돌리고, dev 서버 말고 prod 정적 빌드를 서빙한다. dev 서버는 외부에 노출하면 안 되고 라우팅도 깨진다.
[07] 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
# 국내망에서 각 서브도메인 (200/301 정상)
for s in engwrite birthplanner cdocs; do
curl -Iks "https://$s.mydomain.duckdns.org/" | head -1
done
# 인증서가 와일드카드인지
echo | openssl s_client -servername engwrite.mydomain.duckdns.org \
-connect mydomain.duckdns.org:443 2>/dev/null | openssl x509 -noout -subject -enddate
# subject= CN = *.mydomain.duckdns.org
# 자동 갱신 리허설 (저장된 hook 으로)
cd ~/myserver && docker compose run --rm --entrypoint certbot certbot renew --dry-run
# Congratulations, all simulated renewals succeeded
브라우저에서 https://engwrite.mydomain.duckdns.org/를 열어 자물쇠가 뜨고 화면이 정상이면 끝이다.
[08] 트러블슈팅
| 증상 | 원인 / 해결 |
|---|---|
Timeout during connect (likely firewall problem) |
해외 인바운드가 막힌 것(국가별 제한/ISP). HTTP-01 대신 DNS-01로 (이 글) |
No renewals were attempted 찍고 멈춤 |
certbot 서비스 entrypoint가 certonly를 삼킴. --entrypoint certbot 추가 |
nginx 기동 실패: options-ssl-nginx.conf 없음
|
certonly는 이 파일을 안 만든다. nginx에 ssl_protocols 등을 인라인으로 |
호스트에서 live/... 접근 거부 |
certbot이 root 0700으로 만든다. [ -f ] 검사는 컨테이너 안(docker compose run --entrypoint test ...)에서 하거나 sudo |
| DNS-01 발급은 됐는데 외부 접속이 안 됨 | 443도 막혔는지 확인. 국내만 쓰면 정상, 해외가 필요하면 국가 제한 해제나 터널 |
| rate limit (주 5회) | 실패 디버깅은 무조건 --dry-run(staging)으로 |
[09] 정리
한국 홈서버에서 HTTPS가 안 붙을 때, 의외로 진짜 원인은 포트포워딩 실수가 아니라 해외 인바운드 차단인 경우가 많다. ISP가 막았든, iptime 국가별 접속 제한을 켜 뒀든 결과는 같다. 80포트로 들어오는 Let’s Encrypt 검증이 닿질 못하는 것이다.
이럴 때 DNS-01 + DuckDNS 와일드카드를 쓰면, 포트를 더 열지 않고도, 국가별 접속 제한이라는 보안 설정을 그대로 둔 채 HTTPS를 붙일 수 있다. 덤으로 서브도메인 방식은 앱을 루트에 그대로 두니 base-path 때문에 골치 썩을 일도 없다.
반대로 해외 접근이 막히지 않은 평범한 환경이라면, 굳이 DNS-01까지 갈 것 없이 더 간단한 HTTP-01(80포트 webroot) 방식이 낫다. 그 경우는 iptime 공유기에서 DuckDNS + HTTPS 설정하기를 먼저 보면 된다.