Ghost 블로그 SSL 오류 | 30분 해결, 원인 파악, 구조까지 정리

Ghost 블로그 SSL 오류 | 30분 해결, 원인 파악, 구조까지 정리

요약

  • SSL 인증서 만료 오류가 났는데 인증서 파일은 유효했음
  • 원인: Certbot이 갱신은 했는데 Nginx가 재시작 안 돼서 메모리에 캐시된 이전 인증서를 계속 사용
  • 해결: docker compose restart nginxproxy 실행 + 갱신 스크립트에 Nginx 재시작 명령 추가

어느 날 갑자기 블로그가 안 열렸다

평소처럼 블로그에 글을 쓰려고 접속했는데, 브라우저에서 경고 화면이 떴다. "연결이 비공개로 설정되어 있지 않습니다. NET::ERR_CERT_DATE_INVALID." SSL 인증서 만료 오류였다.

예전 같았으면 가슴이 철렁했을 것이다. 이번엔 그렇게까지 당황하지 않았다. 서버 운영하다 보면 이런 일도 있지. 일단 원인부터 찾아보자. 그런 마음이었다.

그런데 분명 Let's Encrypt 자동 갱신을 설정해뒀는데? certbot 컨테이너가 자동으로 인증서를 갱신하도록 cron 설정을 해놨었다. 그런데 왜 갑자기 이런 오류가 났을까?

내 블로그 구성: Docker Compose로 Nginx와 Certbot을 별도 컨테이너로 운영하고, certbot 갱신 스크립트를 cron으로 자동 실행하는 환경이다.

서버 상태 확인

SSH로 EC2 인스턴스에 접속했다. 일단 서버 자체가 살아있는지부터 확인해야겠다고 생각했다. AWS 콘솔에서 인스턴스 상태를 확인해보니 정상 실행 중이었다. 서버 문제는 아니었다.

터미널에서 sudo certbot certificates 명령어를 입력해봤다. 그런데 "command not found"가 떴다. 아, 맞다. certbot이 시스템에 직접 설치된 게 아니라 Docker 컨테이너로 돌아가고 있으니까 당연히 시스템 명령어로는 안 되지.

docker ps를 실행해서 컨테이너 상태를 확인해봤다. Ghost와 Nginx 프록시 모두 정상적으로 실행 중이었다. 컨테이너가 죽은 것도 아니었다.


인증서 만료일 확인

그래서 실제 인증서 파일의 만료일을 직접 확인해보기로 했다. openssl 명령어로 인증서 정보를 조회했다.

sudo openssl x509 -dates -noout -in certbot-etc/live/your-domain.com/fullchain.pem

결과가 나왔는데 예상과 달랐다.

notBefore=Nov 15 23:07:29 2025 GMT
notAfter=Feb 13 23:07:28 2026 GMT

인증서가 2026년 2월까지 유효하다고? 만료된 게 아니라 오히려 최근에 갱신까지 됐네. 하긴 크론탭 설정해놨으니 당연하다. 그러면 왜 브라우저에서 인증서 만료 오류가 나는 거지?

갱신 로그도 확인해봤다. renew_cert.log 파일을 열어보니 11월에 갱신이 성공했다는 기록이 있었다. 분명히 갱신은 제대로 된 것이다.

뭔가 이상했다. 인증서 파일은 새 것인데, 브라우저는 왜 만료됐다고 할까?


원인: Nginx가 새 인증서를 로드하지 않음

갱신 스크립트를 열어봤다. renew_cert.sh 파일 내용은 단순했다. Docker로 certbot을 실행해서 인증서를 갱신하는 스크립트였다.

그런데 뭔가 빠진 게 있었다. 스크립트는 인증서 갱신만 하고 끝났다. Nginx를 재시작하는 코드가 없었다.

잠깐, Nginx가 뭔지부터 정리하자.

Nginx는 웹 서버이자 리버스 프록시다. 쉽게 말하면 "문지기" 역할이다.

사용자 요청 → [Nginx] → Ghost 블로그
                ↓
           SSL 인증서 처리
           요청 분배
           정적 파일 서빙

HTTPS 처리(SSL 인증서로 암호화), 요청을 뒤에 있는 앱(Ghost)으로 전달, 여러 서버로 트래픽 분산, 정적 파일(이미지, CSS) 빠르게 서빙하는 일을 한다.

왜 Docker Compose에서 Nginx와 Ghost를 따로 띄울까? 이유가 있다. 이 부분은 피상적으로만 알고 있던지라 이번 기회에 좀 더 자세히 알아봤다.

첫째, 각자 잘하는 일만 하게 하는 거다. Ghost는 블로그 글 쓰고 보여주는 것만 집중하고, Nginx는 네트워크 처리, SSL, 트래픽 관리만 집중한다.

둘째, Ghost는 SSL 처리를 제대로 못 한다. Ghost 자체는 HTTP만 지원하기 때문에 HTTPS를 쓰려면 앞에 뭔가를 붙여야 한다. 그게 Nginx다.

인터넷 → [Nginx :443 HTTPS] → [Ghost :2368 HTTP]
                ↓
           암호화 벗기고
           Ghost한테 전달

셋째, 하나의 컨테이너에서 통으로 관리하지 않는다. Docker의 철학이 "1 컨테이너 = 1 프로세스"이기 때문이다. 분리하면 Ghost 업데이트할 때 Nginx 안 건드려도 되고, 반대로 Nginx 설정 바꿀 때 Ghost 안 건드려도 된다. 문제 생기면 뭐가 문제인지 찾기도 쉽다. 그간 블로그 운영 시 여러 오류를 마주했는데 체감상 이게 찐장점 같다.

한편 이번 케이스는 각 컨테이너가 따로 있어서 생긴 이슈이기도 했다. Nginx는 시작할 때 인증서를 메모리에 로드한다. 그 이후로는 파일 시스템에서 인증서를 다시 읽지 않는다. 즉, 인증서 파일이 새로 갱신되어도 Nginx가 재시작되지 않으면 메모리에 캐시된 이전 인증서를 계속 사용하는 것이다.

타임라인을 정리해보면 이랬다.

11월에 인증서가 성공적으로 갱신됐다. 새 인증서는 2026년 2월까지 유효하다. 하지만 Nginx는 재시작되지 않았기 때문에 여전히 이전 인증서(12월에 만료되는)를 메모리에서 사용하고 있었다. 그리고 12월 13일, 이전 인증서가 만료되며 사이트 접속이 불가해졌다.

원인을 알게되니 해결은 쉬웠다. 근데.. 처음에 이걸 왜 빠뜨리게 되었을까?

처음 이 스크립트를 만들 때 certbot 튜토리얼을 따라했고 이후 인증서 갱신이 성공했다고 뜨니까 다 된 줄 알았다. 당시에 Nginx가 뭐하는 역할인지 문서를 보긴 했는데, 솔직히 깊게 제대로 이해를 못 했던 것 같다. 처음 공부시작했던 때여서 읽어도 잘 안 와닿았달까..? Nginx가 인증서를 메모리에 캐시하고 있다는 건 당연히 생각도 못 했다. 그래서 Nginx 재시작 명령어가 빠진 채로 몇 달을 돌렸던 거다.


해결: Nginx 재시작

원인을 알고 나니 해결책은 단순했다. Nginx만 재시작하면 된다.

cd /path/to/project
docker compose restart nginxproxy

시크릿 모드로 브라우저를 열고 블로그에 접속해봤다. 깔끔하게 열렸다. 문제 해결.

하지만 여기서 끝이 아니다. 재발을 방지하려면 갱신 스크립트에 Nginx 재시작 명령을 추가해야 했다.

SSL 갱신 스크립트에 Nginx 재시작 명령 추가

기존 스크립트 밑에 아래 내용을 넣어주었다.

# 인증서 갱신 후 Nginx 재시작
cd /home/ubuntu/my_folder
docker compose restart nginxproxy

이제 cron이 갱신 스크립트를 실행하면 인증서 갱신 후 자동으로 Nginx도 재시작된다.


마무리하며

기술적으로 배운 것도 있지만 이번에 더 크게 느낀 건 '나 자신의 변화'였다.

문제를 해결하고 나니 처음 블로그 만들었을 때가 떠올랐다. 불과 몇 개월 전만 해도 AWS 계정조차 없었는데 직접 만들어보고 운영하니 작고 다양한 이슈를 경험하게 된다. 처음 블로그가 다운되었을 땐 화들짝 놀라서 일단 뭐라도 해보려고 이것저것 만지작거렸다. 로그? 그런 거 볼 겨를이 없었다. 지금 생각해보면 딱히 방문자가 많은 것도 아니었는데 "일단 고쳐야 해"라는 조급함이 앞섰다.

이번에는 달랐다. 블로그에 접속이 안 된다는 걸 발견했을 때, 놀라기는 했지만 예전 같은 당황스러움이 없었다. 그냥 "아, 뭔가 터졌네. 빨리 봐야겠다" 싶었다. 고작 개인 블로그 하나 운영해본 것뿐이지만, 이런 경험들이 쌓이면서 서버 운영이라는 게 어떤 건지 조금씩 배우게 된다.

더 중요한 건 대응 방식이 바뀌었다는 점이다. 예전 같았으면 바로 docker ps 찍어보고 뭔가 이상해 보이면 docker compose down으로 다 내린 다음 docker compose up -d --build로 다시 올리고. 어떤 날은 다른 이슈로 AWS 콘솔에서 재부팅 버튼으로 급하게 해결했었다. 하지만 이번에는 차분하게 로그부터 뜯어봤다. 서버 상태 확인하고, 컨테이너 상태 확인하고, 인증서 파일 확인하고, 갱신 로그 확인하고. 하나씩 문제의 범위를 좁혀가고 상황을 파악하려 했다.

그랬더니 진짜 원인이 보였다. 인증서는 갱신됐는데 Nginx가 새 인증서를 안 읽고 있었구나. 이걸 알았으니까 재발 방지도 할 수 있었다. 갱신 스크립트에 certbot 작업과 함께 Nginx 재시작 명령어를 추가하는 것. 예전처럼 무작위로 고쳤으면 "일단 돌아가니까 됐다" 하고 끝났을 거다. 그리고 3개월 뒤에 또 같은 일을 겪었겠지.

로그를 읽는다는 건 결국 문제를 이해하겠다는 태도인 것 같다. 이해 없이 해결만 하면 같은 문제가 반복된다. 반대로 원인을 제대로 파악하면 그 문제는 두 번 다시 안 생긴다. 귀찮아도 로그부터 보는 습관, 이게 진짜 중요한듯하다.


추가 스터디

이번 오류를 계기로 내가 쓰고 있는 구성, 다른 선택지들은 뭐가 있는지 더 스터디해봤다.

SSL 인증서 관리 방법 비교

왜 EC2를 쓰는가, Vercel 쓰면 안 돼?

요즘 프론트엔드 개발하면 Vercel을 많이 쓴다. 코드 푸시하면 알아서 빌드하고 배포해주고, SSL 인증서도 자동으로 관리해준다. 편하다. 나도 이번 하반기 동안 배포한 앱이랑 웹서비스를 vercel로 연결했다.

근데 Ghost 블로그는 Vercel에서 안 돌아간다. 이유는 둘의 구조가 다르기 때문이다.

Vercel (서버리스)
요청 올 때 → 함수 실행 → 응답 → 함수 종료
(계속 켜져 있는 서버가 없음)

EC2 (서버)
서버 항상 실행 중 → 요청 오면 처리 → 계속 대기
(24시간 돌아가는 컴퓨터)
Ghost가 필요한 것
  • 항상 켜져 있는 Node.js 서버
  • 데이터베이스 (MySQL 또는 SQLite)
  • 파일 시스템 (이미지 업로드 저장)
  • 메모리에 상태 유지
Vercel이 제공하는 것
  • 서버리스 함수 (요청당 제한된 시간만 실행)
  • 정적 파일 호스팅
  • Edge Functions

Ghost는 "계속 실행되는 서버"가 필요한데 Vercel은 서버리스로 "요청 올 때만 잠깐 실행"하는 구조다.

이걸 처음엔 몰랐다. Vercel이 "서버리스"라는 건 알았는데 그게 구체적으로 뭘 의미하는지, 다른 것과 어떤 차이가 있는지는 이번에 더 공부해보고 나서야 이해됐다. 서버리스는 "서버가 없다"가 아니라 "서버 관리를 안 해도 된다"는 뜻이고 대신 제약이 있다. Ghost 같은 전통적인 서버 애플리케이션은 그 제약 안에서 돌아가지 않는다.

Ghost를 호스팅하려면
방식 특징 비용
EC2 직접 운영 (지금 내 방식) 수작업 많고 다양하게 배움 현재 무료
Ghost(Pro) 공식 호스팅, 관리 필요 없음 월 $15~

이외에 중간 정도를 원하면 Railway나 Render 같은 컨테이너 PaaS, EC2랑 비슷한데 UI가 더 친절하다는 DigitalOcean Droplet도 있다고 함. AWS가 어렵게 느껴지면 이쪽이 나을 수도 있겠다. 월 $4부터 시작.

결국 Ghost를 쓰려면 "서버"가 필요하고 서버를 쓰면 SSL 인증서도 관리해야 한다. 덕분에 다양한 케이스도 경험할 수 있었다.


SSL 인증서 관리하는 방법 3가지

방식 비용 자동 갱신 특징
Certbot + cron 무료 스크립트 짜야 함 직접 관리, 배울 게 많음
AWS ACM + ELB 월 $15~20+ 자동 AWS 서비스 필요
Cloudflare 무료 플랜 있음 자동 DNS만 바꾸면 됨

하나씩 알아보자.

1. 내가 쓰는 방식: Certbot + cron

사용자 → EC2 (Nginx + Certbot) → Ghost

Nginx + Certbot + cron 조합. 개인 프로젝트나 사이드 프로젝트에서 많이 보이는 구성이다.

Nginx는 Netflix, Airbnb 같은 기업도 쓸 정도로 검증된 기술이다. 근데 Certbot을 이런 기업들에서 사용하지 않는다. Certbot(Let's Encrypt)은 무료지만 직접 갱신을 관리해야 하고 보증이 없어서 기업에서 쓰기엔 귀찮고 리스크도 있다.

기업 구조
사용자 → CDN/ELB (여기서 SSL 처리) → Nginx → 앱 서버들
         ↑ 유료 인증서 or ACM 사용

개인 블로그 구조
사용자 → Nginx (여기서 SSL 처리) → Ghost
         ↑ Certbot으로 무료 인증서

기업은 서버가 수백~수천 대라서 앞단에 로드밸런서나 CDN을 두고 거기서 SSL을 처리한다. 인증서도 보증이 있는 인증서를 쓴다. Certbot으로 서버마다 일일이 갱신하는 건 현실적으로 불가능하다.

CDN(Content Delivery Network): 전 세계 여러 곳에 서버를 두고 콘텐츠를 복사해두는 시스템이다.

개인 블로그는 서버 1대니까 Nginx에서 직접 SSL 처리해도 된다. 이게 내 개인 블로그에 Certbot을 설정해둔 이유이다.

Certbot 말고 다른 도구는?

찾아보니까 Let's Encrypt 인증서 발급하는 도구가 Certbot만 있는 게 아니었다.

도구 특징
Certbot 가장 유명, 문서 많음
acme.sh 쉘 스크립트, 가벼움
Caddy 웹 서버인데 HTTPS 자동 내장
Traefik 리버스 프록시, 인증서 자동 관리

Certbot이 Let's Encrypt 공식 추천이고 2015년에 제일 먼저 나왔다. 그래서 튜토리얼이 제일 많고 문제 생기면 검색하면 답이 나온다. 근데 Traefik이나 Caddy 쓰면 이번 같은 삽질을 안 해도 된다고 한다. 인증서 발급/갱신/적용을 알아서 해줘서 "재시작 안 해서 적용 안 됨" 같은 문제가 애초에 안 생긴다. 둘 다 오픈소스라 무료고. 나중에 갈아타야하나 고민 된다.


2. AWS ACM은 왜 안 되나?

내 Ghost 블로그는 AWS EC2에서 돌아간다. AWS에는 ACM이라는 게 있는데 SSL 인증서를 웹에서 클릭 몇 번으로 발급받을 수 있다. 무료고 자동 갱신도 된다. 좋아 보여서 찾아봤는데 이건 ELB나 CloudFront에서만 쓸 수 있었다.

ACM(AWS Certificate Manager): AWS에서 SSL 인증서를 발급해주는 서비스다.
ELB(Elastic Load Balancer): AWS에서 제공하는 로드 밸런서 서비스다. 트래픽을 여러 서버에 분산시켜주는 역할.

ELB는 월마다 비용도 나가고 나처럼 개인 블로그 세팅 시에는 서버가 여러 대 있는 것도 아니니 불필요. CloudFront는 CDN인데 없어도 된다.

내 구조
사용자 → EC2 (Nginx) → Ghost  ← ACM 못 씀

ACM 쓰려면
사용자 → ELB → EC2 → Ghost  ← ACM 쓸 수 있음

ACM은 인증서 파일을 안 준다. AWS가 내부적으로 보관하고 ELB/CloudFront에 알아서 연결해주는 방식이라 파일 다운로드가 안 된다.

근데 Nginx는 인증서 파일 경로를 직접 설정해줘야 한다.

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

ACM은 이 파일을 안 주니까 Nginx에서 못 쓴다. 그래서 EC2 + Nginx 조합에서는 Certbot 쓰는 거다.


3. Cloudflare는?

원래: 사용자 → 내 서버
Cloudflare: 사용자 → Cloudflare → 내 서버

DNS를 Cloudflare로 연결하면 SSL 자동 발급/갱신, DDoS 방어, CDN까지 해준다. 무료 플랜도 있다. 기업에서 좋아하는 이유는 "설정 한 번 하면 신경 안 써도 되기 때문"이다.

단 Cloudflare는 SSL 문제가 생기면 대시보드에서 로그 보거나 서포트 문의해야 한다. 위 글에서 내가 직접 로그를 확인했듯 서버에 들어가서 인증서 파일을 열어보는 건 어렵다.

Certbot/Nginx 직접 관리랑 비교하면
  • Cloudflare 엣지 서버에 SSH 접속 불가
  • 인증서 파일 직접 열어보기 불가
  • 내부 처리 과정 직접 확인 불가
  • Cloudflare ↔ 내 서버 사이 문제 생기면 원인 파악이 애매할 때 있음

왜 Certbot을 선택했나

처음에는 "Ghost 블로그 만들기" 강의 튜토리얼을 따라해 만든 거였다. 돌이켜보면 나쁜 선택이 아니었다.

항목 Cloudflare Certbot/Nginx 직접 관리
트래픽 분석 Analytics 대시보드 Nginx 로그 직접 파싱
보안 로그 Security Events fail2ban, 시스템 로그
인증서 상태 대시보드에서 확인 파일 직접 열어보기 가능
요청 추적 Ray ID access.log + error.log
상세 로그 유료 플랜 필요 전부 무료 (직접 설정)
SSH 접속 불가 가능
내부 처리 과정 블랙박스 전부 확인 가능
문제 원인 파악 대시보드 의존 직접 디버깅 가능

Cloudflare 쓰면 편하긴 한데 서버에 직접 들어가서 로그 보거나 인증서 파일 확인하는 식의 디버깅은 안 된다. 대시보드에서 제공하는 정보에 의존해야 해서, 이번처럼 "인증서 파일은 갱신됐는데 왜 적용이 안 되지?" 같은 문제는 직접 파악하기 어렵다.

Certbot + cron으로 직접 하면 SSL, Nginx, Docker에 대해 알게 된다. 이번처럼 터져도 직접 디버깅 가능하고 이 경험이 블로그 글의 주제가 되었다.


SSL 트러블슈팅 명령어

나중에 비슷한 상황이 생겼을 때 쓸 수 있도록 명령어들을 정리해둔다.

인증서 만료일 확인

sudo openssl x509 -dates -noout -in certbot-etc/live/{your-domain}/fullchain.pem

갱신 로그 확인

tail -20 renew_cert.log

인증서 수동 갱신

docker compose run --rm certbot renew

Nginx 재시작

docker compose restart nginxproxy

참고로 docker compose up -d --build와 헷갈릴 수 있는데, restart는 컨테이너만 재시작하는 거고 up --build는 이미지까지 새로 빌드하는 거다. 비유하자면 restart는 재부팅, up --build는 재설치. 난 주로 --build까지 쓰는 편이다. 작은 개인 블로그라 무거운 서비스도 아니고 이왕 하는 김에 클린하게 하려고. 하지만 SSL 인증서 문제는 Nginx가 메모리에 캐시된 인증서를 다시 읽기만 하면 되니까 restart로 충분하다.