공모전 시연용 AWS 환경을 거의 다 세우고 마지막으로 도메인과 HTTPS만 붙이면 되는 단계였다. api.example.com에 A레코드를 넣고 HTTPS 접속을 검증하는 스크립트를 돌렸는데 exit 35로 죽었다. 급한 마음에 컨테이너 로그부터 보려다 컨테이너 이름을 잘못 짐작해서 로그 확인 자체가 또 실패했다. 원인은 Caddy가 DNS가 준비되기도 전부터 인증서를 발급하려다 실패 이력을 쌓았고, 그 끝에 프로덕션이 아니라 스테이징 CA로 넘어가 있었다는 것이다. 이 글은 Caddy 인증서 발급 실패를 로그로 추적해 재시작 한 번으로 되돌린 과정이다
처음 마주친 에러
HTTPS 검증 스크립트의 출력은 이랬다
curl: (35) OpenSSL/3.0.2: error:0A000438:SSL routines:ST_CONNECT:tlsv1 alert internal error
tlsv1 alert internal error는 서버가 TLS 핸드셰이크 중에 내부적으로 문제를 만나 연결을 끊었다는 뜻이다. 여기서 알 수 있는 건 “포트는 열려 있는데 서버가 내밀 인증서가 정상 상태가 아니다”라는 사실뿐이다. 왜 인증서가 준비되지 않았는지는 이 메시지만으로는 알 수 없다. Caddy 로그를 봐야 했다
첫 번째 실수 – 컨테이너 이름을 짐작했다
로그를 보려고 컨테이너 이름을 project-infra-caddy-1이라고 넣었는데 그런 컨테이너는 없었다. docker compose는 컨테이너 이름을 <프로젝트명>-<서비스명>-<번호> 형식으로 만들고, 프로젝트명 기본값은 compose 파일이 있는 디렉터리명이다. 실제 디렉터리는 infra였다.
docker ps -a # infra-caddy-1 ... <- 실제 이름은 이것이었다
사소해 보이지만 진단을 한 스텝 늦췄다. 이후로는 컨테이너 이름을 짐작하지 않고 docker ps -a로 먼저 확인한다. compose 프로젝트명을 명시적으로 고정하지 않았다면 이름은 항상 디렉터리에서 유도된다
로그가 말해준 것
이름을 맞추고 전체 로그를 받자 두 가지가 한꺼번에 드러났다
첫째, Caddy는 EC2 셋업 직후부터, 즉 내가 A레코드를 넣기도 전부터 인증서 발급을 시도하고 있었다. 도메인이 아직 IP를 가리키지 않으니 당연히 전부 NXDOMAIN으로 실패했다. 실패가 반복되며 재시도 간격이 지수적으로 늘어났다
retrying ... backoff 60s retrying ... backoff 120s retrying ... backoff 300s retrying ... backoff 600s
둘째, 더 중요한 지점이었다. 반복 실패 끝에 Caddy가 Let’s Encrypt 프로덕션이 아니라 스테이징 CA로 넘어가 있었다.
"ca":"https://acme-staging-v02.api.letsencrypt.org/directory"
Caddy는 프로덕션 발급이 계속 실패하면 프로덕션 CA의 rate limit을 소진하지 않으려고 재시도 과정에서 스테이징 엔드포인트로 전환한다. 문제는 이 상태에서 DNS가 뒤늦게 맞춰져 발급이 “성공”해버리면, 브라우저가 신뢰하지 않는 테스트 인증서가 발급된다는 것이다. 접속은 되는데 브라우저에 경고가 뜨는, 진단하기 성가신 상태가 될 뻔했다.
이 자동 전환 동작은 문서화가 두텁지 않아서, 로그에 스테이징 URL이 찍힌 걸 처음 보면 “왜 내가 스테이징을 쓰고 있지?” 하고 당황하기 쉽다. Caddy 저장소에도 한 번 스테이징으로 넘어간 뒤 프로덕션으로 잘 돌아오지 못하고 눌러앉는 사례가 이슈로 올라와 있다
시도한 것과 기각한 것
가장 먼저 떠오른 선택지는 그냥 기다리는 것이었다. 다음 백오프 주기가 돌아오면 어차피 재시도할 테니까. 하지만 이때 백오프는 이미 최대 10분까지 벌어져 있었고, 시연 준비 중이라 10분을 흘려보내기 아까웠다. 게다가 스테이징 CA로 넘어간 상태에서 자동 재시도가 돌면 스테이징에서 성공해버릴 위험이 그대로 남는다
그다음 후보는 인증서 캐시를 지우고 처음부터 다시 하는 것이었다. 이건 과했다. 이미 발급받은 다른 도메인 상태까지 날릴 수 있고, 캐시는 볼륨(caddy-data)에 안전하게 남아 있어서 지울 이유가 없었다
결국 컨테이너 재시작을 골랐다
docker restart infra-caddy-1
재시작은 백오프 상태를 초기화한다. 실패 카운터와 늘어난 재시도 간격이 리셋되면서 Caddy는 다시 프로덕션 Let’s Encrypt부터 시도한다. 인증서 캐시는 볼륨에 남아 있으니 재시작해도 잃는 게 없다. 이때는 DNS A레코드가 이미 전파돼 있었기 때문에, 재시작 직후 tls-alpn-01 챌린지가 곧바로 통과했다
결과 확인
재시작 뒤에는 systemd나 컨테이너 상태만 보고 끝내지 않았다. 발급기관이 정말 정식 Let’s Encrypt인지가 이번 사고의 핵심이었으니 그것부터 확인했다.
curl https://api.example.com # 200 응답 openssl s_client -connect api.example.com:443 -servername api.example.com # issuer=... Let's Encrypt (staging 아님) # 유효기간 2026-07-20 ~ 2026-10-18
openssl s_client로 issuer가 스테이징이 아닌 정식 Let’s Encrypt임을, 유효기간까지 눈으로 확인하고 나서야 이 단계를 닫았다. 상태 코드 200만 봤다면 스테이징 인증서였어도 통과로 착각했을 것이다
순서만 바꿨어도 안 겪을 일이었다
이 문제의 뿌리는 디버깅 실력이 아니라 순서였다. 도메인이 필요한 인프라라면 컨테이너를 띄우기 전에 DNS A레코드부터 넣어두면 된다. 그러면 Caddy가 처음 시도할 때 이미 도메인이 IP를 가리키고 있어서 실패 이력 자체가 쌓이지 않는다. 백오프도, 스테이징 전환도 애초에 발생하지 않는다. 다만 이건 순서를 처음부터 지킬 수 있을 때의 이야기다. 이미 실패 이력이 쌓인 상태라면 다음 백오프를 기다리는 것보다 재시작이 빠르다
정리하면, Caddy 인증서 발급 실패를 만나면 컨테이너 이름부터 docker ps -a로 정확히 확인하고, 로그에서 백오프와 CA 엔드포인트를 본 다음, 스테이징으로 넘어가 있다면 캐시를 지우지 말고 재시작으로 깨끗한 상태에서 프로덕션부터 다시 시도시키면 된다. 그리고 다음 프로젝트에서는 DNS를 먼저 맞추고 컨테이너를 띄운다. 발급 성공 여부는 상태 코드가 아니라 openssl s_client의 issuer로 확인한다
참고
Caddy Automatic HTTPS 문서, ACME staging fallback 관련 이슈 #6925