Let’s Encrypt Nginx HTTPS 설정 완전 가이드: Certbot·Docker·Reverse Proxy 자동 갱신

목차

Let’s Encrypt Nginx HTTPS 설정은 무료 SSL 인증서 발급과 자동 갱신을 위한 가장 보편적 조합이다. ISRG(Internet Security Research Group)가 운영하는 Let’s Encrypt는 DV(Domain Validation) 등급 인증서를 90일 단위로 발급하고, 갱신은 ACME(RFC 8555) 프로토콜로 자동화한다. Nginx는 이 인증서를 읽어 TLS 1.2/1.3 핸드셰이크를 처리하는 웹서버 또는 리버스 프록시 역할을 맡는다.

이 글은 Certbot, acme.sh, Caddy 세 가지 ACME 클라이언트와 HTTP-01, DNS-01, TLS-ALPN-01 세 가지 인증 방식, 그리고 단독 Nginx · Docker · Reverse Proxy 체인 환경에서의 갱신 구조를 비교한다. 실무에서 자주 마주치는 갱신 실패 원인까지 다룬다.

구성 요소와 갱신 흐름의 윤곽

예를 들어, Let’s Encrypt와 Nginx를 연결할 때 등장하는 배우는 네 가지다. 인증기관 Let’s Encrypt, 자동화 프로토콜 ACME, 그 프로토콜을 말하는 클라이언트(Certbot/acme.sh/Caddy), 그리고 인증서를 실제로 서비스에 노출하는 Nginx.

그러나, 흐름 자체는 단순하다. 클라이언트가 Let’s Encrypt에게 "이 도메인 인증서 발급해달라"고 요청한다. Let’s Encrypt는 "그럼 네가 정말 이 도메인 주인인지 증명해라"라며 챌린지를 던진다. 챌린지에 응답하면 인증서가 발급되고, 클라이언트는 받은 PEM 파일을 디스크에 저장한다. Nginx는 그 PEM 파일을 읽어 핸드셰이크에서 사용한다.

갱신은 같은 절차의 반복이다. 차이가 있다면 60일째쯤 자동으로 도는 스케줄러(systemd timer 또는 cron)가 갱신 요청을 다시 던진다는 것뿐이다. Let’s Encrypt 권고 갱신 주기는 만료 30일 전, Certbot 기본값도 30일 미만 남았을 때 실제 갱신을 시도한다(출처: Certbot 공식 문서, 2026-05 기준 v2.10.0).

즉, 이 단순한 흐름이 깨지는 지점은 보통 두 군데다. "챌린지 응답"과 "갱신 후 Nginx reload". 어디서 깨지느냐가 어떤 도구·인증 방식을 골라야 하는지를 결정한다. 그래서 도구 비교보다 인증 방식 선택이 실무에서는 더 큰 영향을 주는 편이다.

ACME 클라이언트 비교 — Certbot, acme.sh, Caddy

세 도구 모두 ACME 프로토콜을 따르지만 철학이 다르다. Certbot은 공식 도구이자 표준 참조 구현, acme.sh는 의존성 최소화에 집중한 셸 스크립트, Caddy는 웹서버 자체에 ACME를 내장해 설정을 거의 없앤 형태다.

항목 Certbot v2.10 acme.sh v3.0.7 Caddy v2.7
구현 언어 Python POSIX Shell Go
설치 의존성 snap/pip/apt curl만 있으면 됨 단일 바이너리
기본 DNS-01 플러그인 약 15종 약 150종 약 100종
Nginx 설정 자동 수정 지원 (–nginx 플러그인) 미지원 자체 웹서버라 N/A
컨테이너 배포 적합도 보통 (이미지 무거움) 높음 (alpine OK) 매우 높음
학습 곡선 중간 (문서 풍부) 낮음 (옵션 직관적) 매우 낮음

물론, Certbot은 표준이라는 점이 가장 큰 무기다. 검색했을 때 나오는 문서·트러블슈팅 자료의 절반 이상이 Certbot 기준이라 막혔을 때 빠져나오기 쉽다. acme.sh는 한 줄 설치(curl ... | sh)와 풍부한 DNS API 지원으로 가벼운 환경에 어울린다. Caddy는 Nginx를 대체할 의향이 있을 때만 의미가 있다. 즉 "Nginx에 인증서를 붙인다"는 전제에서는 Caddy는 후보에서 빠진다.

환경별로 본다면 일반 VPS의 Nginx에는 Certbot, Alpine 기반의 가벼운 컨테이너에는 acme.sh, 신규 프로젝트에서 웹서버까지 재선택할 여지가 있다면 Caddy를 한 번쯤 검토할 만하다(개인적으로 운영 안정성 측면에서는 Certbot 쪽이 손이 덜 가는 것 같다).

인증 방식 — HTTP-01, DNS-01, TLS-ALPN-01

게다가, ACME 챌린지 종류는 세 가지로, 각각 작동 원리와 제약이 다르다. 어떤 것을 골라야 하는지는 "어떤 포트를 열 수 있느냐"와 "와일드카드가 필요한가" 두 질문으로 거의 정해진다.

HTTP-01의 동작과 한계

따라서, HTTP-01은 가장 직관적이다. Let’s Encrypt가 http://도메인/.well-known/acme-challenge/<토큰>로 GET 요청을 보내고, 그 위치에 토큰 파일이 있으면 인증한다. 그래서 80 포트가 외부에서 직접 도달 가능해야 한다.

그러나, 문제는 80 포트가 막혀 있는 환경이 의외로 많다는 점이다. 사내 ALB 뒤, CloudFront 뒤, 사설망 IP에 묶인 서비스 등은 HTTP-01이 통과하지 못한다. 80 포트를 열어둘 수 있는 단순한 구조라면 가장 손쉬운 방법이다.

DNS-01의 강점과 비용

DNS-01은 도메인의 TXT 레코드를 일시적으로 추가해 인증한다. 80 포트를 열 필요가 없고, 와일드카드 인증서(*.example.com) 발급이 가능한 유일한 방식이다.

대신 DNS 공급자 API를 호출할 자격증명이 필요하다. Route53, Cloudflare, GoDaddy 등 주요 공급자는 acme.sh 또는 Certbot 플러그인으로 지원되지만, 자격증명을 어디 저장하느냐(secrets manager? .env? IAM role?)가 새로운 고민거리가 된다. 권한 범위도 좁혀야 한다. Route53이라면 해당 호스팅 존의 ChangeResourceRecordSets만 허용하는 IAM policy가 일반적 패턴이다.

TLS-ALPN-01은 언제 쓰나

TLS-ALPN-01은 443 포트의 TLS 핸드셰이크 중 ALPN 확장으로 챌린지를 응답한다. 80 포트는 막혀 있고 DNS API도 못 쓰는 환경에서 쓴다. 실무 비중은 낮은 편이고, 일반적으로 HTTP-01과 DNS-01 둘 중 하나에서 답이 나온다.

Nginx 단독 환경의 발급과 갱신

특히, VPS 한 대에 Nginx를 직접 설치한 가장 단순한 구성이라면 Certbot의 Nginx 플러그인이 최단 경로다. certbot --nginx -d example.com을 한 번 실행하면 인증서를 발급받고, server 블록에 ssl_certificate, ssl_certificate_key, 443 리스너, 80 → 443 리다이렉트까지 자동으로 추가한다.

갱신은 Certbot이 패키지 설치 시 함께 등록하는 systemd timer 또는 cron이 처리한다. systemctl list-timers | grep certbot로 확인할 수 있다. 12시간마다 한 번 깨어나 30일 미만 남은 인증서만 실제 갱신을 시도한다. 그래서 대부분의 호출은 "할 일 없음"으로 끝난다.

수동으로 확인하고 싶다면 certbot renew --dry-run이 갱신 절차를 실제로 시뮬레이션한다. 이걸 통과하면 실제 갱신도 거의 통과한다고 봐도 된다.

이 구성은 단순한 만큼 깨질 일도 별로 없다. 깨진다면 거의 80 포트 방화벽, DNS 레코드 변경, 또는 Nginx 설정 파일을 사람이 손으로 망가뜨린 경우다.

Docker 환경에서의 인증서 패턴

실제로, 컨테이너 환경으로 넘어가면 두 가지 패턴이 갈린다. 한쪽은 "Nginx 컨테이너 + Certbot 컨테이너 + 공유 볼륨", 다른 쪽은 "nginx-proxy + acme-companion" 같은 통합 솔루션이다.

공유 볼륨 패턴의 흐름

/etc/letsencrypt를 named volume으로 만들어 Certbot 컨테이너와 Nginx 컨테이너가 동시에 마운트한다. Certbot이 갱신을 끝내면 Nginx 컨테이너에 nginx -s reload 시그널을 보내야 새 인증서가 적용된다. 이 reload 신호 전달이 의외로 자주 빠지는 부분이다.

docker compose에서는 Certbot 컨테이너의 --deploy-hook 또는 외부 스크립트에서 docker exec nginx nginx -s reload를 호출하는 식으로 처리한다. systemd timer 대신 cron 컨테이너를 따로 띄우거나, Certbot 이미지를 갱신 명령과 함께 주기적으로 재실행하는 구조가 많이 보인다.

nginx-proxy + acme-companion

jwilder/nginx-proxynginxproxy/acme-companion 조합은 컨테이너 라벨만으로 인증서 발급·갱신·Nginx 설정 생성을 모두 자동화한다. 새 서비스 컨테이너에 VIRTUAL_HOST=app.example.com, LETSENCRYPT_HOST=app.example.com 라벨을 붙이면 나머지는 컴패니언이 알아서 한다.

예를 들어, 처음 보면 마법 같지만, 운영 측면에서는 디버깅 난도가 한 계단 올라간다. 인증서가 발급되지 않을 때 nginx-proxy 컨테이너 로그, acme-companion 컨테이너 로그, 그리고 마운트된 /etc/nginx/certs 디렉터리를 모두 들여다봐야 원인이 잡힌다. 멀티 도메인을 빠르게 관리해야 하는 사이드 프로젝트에는 잘 맞고, SLA가 걸린 프로덕션에서는 호불호가 갈리는 편으로 보인다.

그래서, :::tip 컨테이너 환경에서 Let’s Encrypt 갱신이 갑자기 멈췄다면 가장 먼저 볼 곳은 "마운트된 인증서 경로"와 "Nginx의 reload 시그널 전달 여부"다. 인증서 파일은 갱신됐는데 Nginx가 옛 파일을 메모리에 들고 있는 경우가 가장 흔한 실패 모드다. :::

Reverse Proxy 체인에서의 갱신 흐름

특히, 실제 프로덕션 구성은 단일 Nginx가 아닌 경우가 많다. CloudFront → ALB → Nginx → 앱, 또는 Cloudflare → Nginx → 컨테이너처럼 앞단에 또 다른 L7 프록시가 끼는 경우가 많다. 이런 환경에서 HTTP-01 챌린지가 깨지는 사례를 한 번 겪으면 DNS-01의 가치가 체감된다.

예를 들어 ALB 뒤에 Nginx가 EC2로 떠 있다고 해보자. ALB는 80 포트를 받아 443으로 리다이렉트하는 정책이 기본이고, Let’s Encrypt가 80으로 보낸 챌린지 요청은 ALB에서 가로채여 Nginx까지 도달하지 못한다. ALB 리스너 규칙을 수정해 /.well-known/acme-challenge/* 경로만 80에서 통과시키는 우회가 가능하지만, 인프라 관리자가 매번 이 예외를 유지해야 한다.

한편, 배포 직후 인증서 갱신이 묵묵히 실패하고 있다가 만료 7일 전 Let’s Encrypt 알림 메일이 오고 나서야 알아채는 흐름은 백엔드 개발자라면 한 번쯤 본 적이 있을 것이다. 시행착오 끝에 DNS-01로 갈아탔다는 회고를 자주 본다. Route53 IAM 권한을 별도로 만들어 acme.sh에 박아두고, 80 포트는 닫고, 와일드카드 인증서 하나로 *.api.example.com 전체를 커버하는 방식이다.

결국, 이 전환이 의미 있는 이유는 단순히 "되니까"가 아니라, 갱신이 외부 L7 프록시 구성에 의존하지 않게 된다는 점이다. ALB 규칙이 바뀌든, CloudFront behavior가 바뀌든, DNS-01은 영향받지 않는다.

예를 들어, 다만 DNS 공급자 API 자격증명을 안전하게 관리해야 한다는 새 과제가 생긴다. AWS라면 IAM role을 EC2/ECS task에 붙이는 식, Cloudflare라면 토큰 권한 범위를 zone:DNS:edit로 좁히는 식이다. 자격증명이 도구 설정 파일에 평문으로 남는 구성은 가능하면 피하는 것이 좋다.

자동 갱신 실패의 흔한 원인 6가지

운영 중 갱신이 깨졌을 때 가장 먼저 의심해볼 후보들이다. 여섯 가지 모두 실무에서 흔히 보고되는 항목이다.

또한, 첫째, 80 포트 방화벽. HTTP-01 사용 시 가장 흔하다. 신규 VPC 보안 그룹을 추가하면서 80을 빠뜨리거나, 운영 정책 변경으로 80을 닫아둔 채 잊는 경우가 대표적이다.

또한, 둘째, Nginx reload 누락. 인증서 파일은 갱신됐는데 Nginx가 옛 파일을 메모리에 들고 있는 상태다. Certbot의 --deploy-hook이나 --post-hook에 reload 명령을 등록해두지 않으면 발생한다. 단순 cron 스크립트로 운영할수록 빠지기 쉽다.

따라서, 셋째, rate limit. Let’s Encrypt는 등록된 도메인당 주 50회 발급 한도 등 다양한 제한이 있다(출처: Let’s Encrypt Rate Limits, 2026-05 기준). 테스트한답시고 --force-renewal을 반복 호출하면 한도를 금방 채운다. 테스트는 staging 환경(--staging)에서 해야 한다.

게다가, 넷째, DNS-01의 propagation 지연. TXT 레코드가 전파되기 전에 Let’s Encrypt가 조회하면 실패한다. acme.sh의 --dnssleep 옵션 또는 Certbot 플러그인의 propagation 대기 시간을 조정한다. AWS Route53은 보통 빠르지만, 일부 외부 DNS는 1~2분 이상 걸리기도 한다.

그러나, 다섯째, 컨테이너 재시작 시 볼륨 누락. /etc/letsencrypt를 named volume이 아닌 bind mount로 잘못 잡거나, 새 컨테이너에서 볼륨 선언이 빠지면 인증서가 통째로 사라진다. 이 경우는 복구가 아니라 재발급으로 해결한다.

예를 들어, 여섯째, Certbot 버전 차이. 2.0 미만 버전은 ACME v2를 부분 지원하거나 와일드카드 발급 시 옵션이 다르다. 운영 서버에서 패키지 매니저로 설치한 Certbot이 1.x인 경우가 의외로 남아 있다. certbot --version으로 확인하고 2.x 이상으로 올리는 게 안전하다.

이 여섯 가지 외에 거의 모든 갱신 실패는 위 카테고리의 변형이다. 새 인증 자동화 도구가 등장하더라도 "포트·파일·시간·권한"의 네 축에서 벗어나지는 않는다. 당장 할 수 있는 행동을 꼽는다면 세 가지다. certbot renew --dry-run을 cron으로 주 1회 돌려 사전 감지하기, 인증서 만료 30일 전 알림을 모니터링에 걸어두기, 그리고 새 환경 구성 시 첫 발급은 반드시 --staging으로 검증한 뒤 운영으로 옮기기. Let’s Encrypt Nginx HTTPS 설정에서 이 세 가지를 자동화 파이프라인에 미리 박아두면 90일 단위 갱신 사이클을 거의 손대지 않고 굴릴 수 있다.

관련 글