Certbot 기반 Linux 서버 인증서 자동화

Prev Next

Certbot을 사용하여 Linux 서버 환경에서 인증서 발급 및 갱신을 자동화하는 방법을 안내합니다.
Ubuntu 22.04/24.04 LTS 및 Rocky Linux 8/RHEL 8 환경을 기준으로 작성되었습니다.

참고

본 가이드는 Certbot 기준으로 제공됩니다. RFC 8555를 준수하는 다른 ACME 클라이언트도 사용할 수 있으나, 공식 기술 지원은 Certbot 기준으로만 제공됩니다.

주의

Ncloud Trust CA는 RSA 2048만 허용합니다. ECDSA 또는 RSA 4096으로 요청하면 인증서 발급이 실패합니다. 아래 가이드의 명령어에는 --key-type rsa --rsa-key-size 2048 옵션이 포함되어 있습니다. 임의로 변경하지 마십시오.

시작하기 전에

이 가이드를 진행하기 전 ACME 사용 준비의 모든 단계를 완료해 주십시오.
다음 항목이 준비되어 있어야 합니다.

  • 발급된 EAB Key IDEAB HMAC Key
  • 사용할 도메인 검증 방식 결정 (DNS-01 동적 방식 또는 사전 검증 방식)
  • OV 인증서를 사용하는 경우, Certificate Manager > Organization에서 조직 검증 완료

1단계: Certbot 설치

운영체제 환경에 맞는 명령어를 실행하여 Certbot을 설치해 주십시오. 자세한 설치 방법은 Certbot 공식 설치 가이드를 참조해 주십시오.

Ubuntu 22.04 / 24.04 LTS

sudo apt update
sudo apt install -y curl openssl jq python3
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

Rocky Linux 8 / RHEL 8

sudo dnf install -y epel-release
sudo dnf install -y curl openssl jq python3 snapd
sudo systemctl enable --now snapd.socket
sudo ln -s /var/lib/snapd/snap /snap
# 재로그인 후 실행
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

설치가 완료되면 다음 명령어로 버전을 확인해 주십시오. Certbot은 2.x 이상을 권장합니다.

certbot --version
jq --version
openssl version

2단계: DNS 훅 스크립트 설정

DNS-01 챌린지 자동화를 위해 Certbot의 --manual-auth-hook--manual-cleanup-hook 옵션에 DNS TXT 레코드를
생성·삭제하는 스크립트를 연동합니다.

작업 디렉토리는 다음과 같이 구성됩니다.

/opt/acme-ncp-dns/
├── .env            # 설정 파일 (직접 생성, 외부 노출 금지)
├── ncp-auth.sh     # Certbot 인증 훅 (TXT 레코드 생성)
└── ncp-cleanup.sh  # Certbot 정리 훅 (TXT 레코드 삭제)

디렉토리를 생성해 주십시오.

sudo mkdir -p /opt/acme-ncp-dns

Ncloud Global DNS를 사용하는 경우 훅 스크립트 예시는 부록: Ncloud Global DNS 훅 스크립트 예시를 참조해 주십시오. 다른
DNS 제공자를 사용하는 경우 해당 제공자의 API를 호출하여 동일한 역할을 수행하는 스크립트를 작성해 주십시오.

스크립트 파일을 배치한 후 다음과 같이 설정 파일을 생성하고 실행 권한을 부여해 주십시오.

sudo vi /opt/acme-ncp-dns/.env
sudo chmod 600 /opt/acme-ncp-dns/.env
sudo chmod +x /opt/acme-ncp-dns/ncp-auth.sh
sudo chmod +x /opt/acme-ncp-dns/ncp-cleanup.sh

.env 파일 내용:

# NCP API 인증 키 (마이페이지 > 계정 관리 > 인증키 관리에서 발급)
NCP_ACCESS_KEY="YOUR_NCP_ACCESS_KEY"
NCP_SECRET_KEY="YOUR_NCP_SECRET_KEY"

# NCP Global DNS API 엔드포인트 (변경 불필요)
NCP_DNS_API="https://globaldns.apigw.ntruss.com"

# Global DNS 도메인 ID
# NCP 콘솔 > Global DNS > F12 개발자 도구 > 도메인 클릭 > URL의 숫자 (예: dns/domain/36019)
NCP_DOMAIN_ID="YOUR_DOMAIN_ID"

# Global DNS에 등록된 루트 도메인 (예: example.com)
NCP_ZONE_DOMAIN="example.com"

# DNS TXT 레코드 전파 대기 시간(초). 검증 실패 시 90~120으로 늘려 주십시오.
DNS_PROPAGATION_SECONDS=60
항목 설명 확인 방법
NCP_ACCESS_KEY Ncloud API Access Key Ncloud 포털 > 마이페이지 > 계정 관리 > 인증키 관리
NCP_SECRET_KEY Ncloud API Secret Key NCP 포털 > 마이페이지 > 계정 관리 > 인증키 관리
NCP_DOMAIN_ID Global DNS 도메인 숫자 ID Ncloud 콘솔 > Global DNS > F12 > URL 숫자 확인
NCP_ZONE_DOMAIN DNS 영역 루트 도메인 Global DNS에 등록된 도메인 (예: example.com)
DNS_PROPAGATION_SECONDS DNS 전파 대기 시간 기본 60초. 검증 실패 시 90~120으로 조정

3단계: 인증서 발급

아래 명령어의 대괄호([ ]) 항목을 실제 값으로 교체하여 실행해 주십시오.

파라미터 설명 예시
[ACME_DIRECTORY_URL] ACME 시작 전 준비에서 확인한 ACME 디렉토리 URL https://acme.navercloudtrust.com/acme/directory
[EAB_KEY_ID] EAB 자격증명 발급 시 제공된 Key ID abc123...
[EAB_HMAC_KEY] EAB 자격증명 발급 시 제공된 HMAC Key xyz789...
[ADMIN_EMAIL] 인증서 만료 알림을 받을 이메일 주소 admin@example.com

단일 도메인

sudo certbot certonly \
  --manual \
  --preferred-challenges dns \
  --key-type rsa \
  --rsa-key-size 2048 \
  --manual-auth-hook /opt/acme-ncp-dns/ncp-auth.sh \
  --manual-cleanup-hook /opt/acme-ncp-dns/ncp-cleanup.sh \
  --server [ACME_DIRECTORY_URL] \
  --eab-kid "[EAB_KEY_ID]" \
  --eab-hmac-key "[EAB_HMAC_KEY]" \
  --agree-tos \
  --email [ADMIN_EMAIL] \
  --non-interactive \
  -d example.com

서브도메인 포함 (SAN)

sudo certbot certonly \
  --manual \
  --preferred-challenges dns \
  --key-type rsa \
  --rsa-key-size 2048 \
  --manual-auth-hook /opt/acme-ncp-dns/ncp-auth.sh \
  --manual-cleanup-hook /opt/acme-ncp-dns/ncp-cleanup.sh \
  --server [ACME_DIRECTORY_URL] \
  --eab-kid "[EAB_KEY_ID]" \
  --eab-hmac-key "[EAB_HMAC_KEY]" \
  --agree-tos \
  --email [ADMIN_EMAIL] \
  --non-interactive \
  -d example.com \
  -d www.example.com \
  -d api.example.com

와일드카드 도메인

sudo certbot certonly \
  --manual \
  --preferred-challenges dns \
  --key-type rsa \
  --rsa-key-size 2048 \
  --manual-auth-hook /opt/acme-ncp-dns/ncp-auth.sh \
  --manual-cleanup-hook /opt/acme-ncp-dns/ncp-cleanup.sh \
  --server [ACME_DIRECTORY_URL] \
  --eab-kid "[EAB_KEY_ID]" \
  --eab-hmac-key "[EAB_HMAC_KEY]" \
  --agree-tos \
  --email [ADMIN_EMAIL] \
  --non-interactive \
  -d "*.example.com"
참고

와일드카드(*)를 포함하는 경우 셸의 glob 확장을 방지하기 위해 도메인을 따옴표로 감싸 주십시오.

발급 성공 후 다음 명령어로 인증서를 확인해 주십시오.

sudo certbot certificates
sudo openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem \
  -noout -subject -issuer -dates

4단계: 자동 갱신 설정

Certbot은 인증서 만료 30일 전부터 갱신을 시도합니다. 훅 스크립트 경로는 최초 발급 시 자동으로 저장되므로 certbot renew만 실행하면 DNS 검증이 자동으로 수행됩니다.

먼저 다음 명령어로 갱신 동작을 시뮬레이션하여 확인해 주십시오.

sudo certbot renew --dry-run

방법 1 — systemd 타이머 (권장)

snap으로 Certbot을 설치한 경우 갱신 타이머가 자동으로 등록됩니다. 다음 명령어로 상태를 확인해 주십시오.

sudo systemctl status snap.certbot.renew.timer

# 타이머가 없는 경우 수동 등록
sudo systemctl enable --now snap.certbot.renew.timer

방법 2 — Crontab

  1. 다음 명령어를 실행하여 crontab 편집기를 열어 주십시오.
sudo crontab -e
  1. 다음 줄을 추가해 주십시오. (매일 오전 3시에 갱신 시도)
0 3 * * * /usr/bin/certbot renew --quiet 2>&1 | logger -t certbot-renew
참고
  • --quiet 옵션은 갱신 없이 건너뛰는 경우 출력을 억제합니다.
  • logger -t certbot-renew는 갱신 결과를 시스템 로그에 기록합니다. (journalctl -t certbot-renew로 확인)
  • 갱신 로그는 /var/log/letsencrypt/letsencrypt.log에서도 확인할 수 있습니다.

웹 서버 인증서 적용

발급된 인증서 파일 위치는 다음과 같습니다.

파일 설명
/etc/letsencrypt/live/<도메인>/cert.pem 인증서
/etc/letsencrypt/live/<도메인>/chain.pem 중간 CA 체인
/etc/letsencrypt/live/<도메인>/fullchain.pem 인증서 + 체인 (웹 서버 설정 권장)
/etc/letsencrypt/live/<도메인>/privkey.pem 개인키 (외부 노출 금지)

Nginx

/etc/nginx/sites-available/example.com 파일에 다음 내용을 입력해 주십시오.

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:SSL:10m;

    location / {
        root /var/www/html;
        index index.html;
    }
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}
sudo nginx -t
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo systemctl reload nginx

갱신 후 자동 재로드를 위해 deploy-hook을 설정합니다.

sudo vi /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Apache

/etc/apache2/sites-available/example.com-ssl.conf 파일에 다음 내용을 입력해 주십시오. (Ubuntu 기준)

<VirtualHost *:443>
    ServerName example.com

    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/example.com/cert.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/example.com/privkey.pem
    SSLCertificateChainFile /etc/letsencrypt/live/example.com/chain.pem

    SSLProtocol             all -SSLv3 -TLSv1 -TLSv1.1
    SSLHonorCipherOrder     off

    DocumentRoot /var/www/html
</VirtualHost>
sudo a2enmod ssl
sudo a2ensite example.com-ssl
sudo apache2ctl configtest
sudo systemctl reload apache2
sudo vi /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh
#!/bin/bash
systemctl reload apache2
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh

문제 해결

증상 원인 조치
The key ID was not found EAB Key ID 오류 또는 이미 사용된 키 콘솔에서 EAB 키 재발급 후 명령어 재실행
unauthorized EAB HMAC Key 오류 HMAC Key 값 재확인. 공백·줄바꿈 포함 여부 확인
Error finalizing order :: invalid CSR RSA 2048 외 키 타입 사용 명령어에 --key-type rsa --rsa-key-size 2048 옵션
추가 여부 확인
DNS problem: NXDOMAIN TXT 레코드 미전파 .envDNS_PROPAGATION_SECONDS 값을 90~120으로 늘리거나 `dig +short
TXT _acme-challenge.example.com @8.8.8.8`으로 전파 여부 확인
TXT 레코드 생성에 실패했습니다 NCP API 키 오류 또는 도메인 ID 불일치 .envNCP_ACCESS_KEY, NCP_SECRET_KEY,
NCP_DOMAIN_ID 값 재확인
OV 인증서 발급 실패 조직 사전 검증 미완료 Certificate Manager > Organization에서 검증 상태 확인
갱신 시 훅이 실행되지 않음 renewal 설정 파일에 훅 경로 누락 /etc/letsencrypt/renewal/example.com.conf
[renewalparams] 섹션에 manual_auth_hook, manual_cleanup_hook 경로 직접 추가
sudo tail -100 /var/log/letsencrypt/letsencrypt.log

보안 권고

  • .env 파일은 반드시 권한을 600으로 설정해 주십시오. (sudo chmod 600 /opt/acme-ncp-dns/.env)
  • .env 파일을 Git 저장소나 공유 폴더에 포함하지 마십시오.
  • NCP API 키에는 Global DNS 서비스에만 최소 권한을 부여하는 것을 권장합니다.
  • 인증서 개인키(privkey.pem)가 외부에 노출되지 않도록 주의해 주십시오.

부록: Ncloud Global DNS 훅 스크립트 예시

주의

아래 스크립트는 Ncloud Global DNS 환경에서의 참고용 예시입니다. 스크립트의 수정, 설정, 실행 환경 및 결과에 대한 책임은 사용자에게 있으며, 네이버 클라우드는 해당 스크립트의 동작에 대한 기술 지원을 제공하지 않습니다. 실제 운영 환경 적용 전 충분한 테스트를 거칠 것을 권장합니다.

ncp-auth.sh

#!/bin/bash
# ncp-auth.sh — Certbot DNS-01 인증 훅 (NCP Global DNS)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/.env"

make_signature() {
  local method=$1 uri=$2 timestamp=$3
  # nl 변수($) 할당을 없애고 printf로 줄바꿈을 처리하여 에디터 파싱 오류 방지
  printf "%s %s\n%s\n%s" "${method}" "${uri}" "${timestamp}" "${NCP_ACCESS_KEY}" \
    | openssl dgst -sha256 -hmac "${NCP_SECRET_KEY}" -binary | base64
}

# 와일드카드 제거 후 TXT 호스트 계산
CLEAN_DOMAIN=$(echo "${CERTBOT_DOMAIN}" | sed 's/^\*\.//')
if [ "${CLEAN_DOMAIN}" = "${NCP_ZONE_DOMAIN}" ]; then
  HOST="_acme-challenge"
else
  SUB="${CLEAN_DOMAIN%.${NCP_ZONE_DOMAIN}}"
  HOST="_acme-challenge.${SUB}"
fi

# TXT 레코드 생성
TIMESTAMP=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000)
URI="/dns/v1/ncpdns/record/${NCP_DOMAIN_ID}"
SIGNATURE=$(make_signature "POST" "${URI}" "${TIMESTAMP}")

RESPONSE=$(curl -s --connect-timeout 10 --max-time 30 -X POST \
  "${NCP_DNS_API}${URI}" \
  -H "Content-Type: application/json" \
  -H "x-ncp-apigw-timestamp: ${TIMESTAMP}" \
  -H "x-ncp-iam-access-key: ${NCP_ACCESS_KEY}" \
  -H "x-ncp-apigw-signature-v2: ${SIGNATURE}" \
  -d "[{\"host\":\"${HOST}\",\"type\":\"TXT\",\"content\":\"${CERTBOT_VALIDATION}\",\"ttl\":300,\"lbRegionCode\":\"KR\"}]")

SID=$(echo "${RESPONSE}" | jq -r '.[0].sid // empty')
if [ -z "${SID}" ]; then
  echo "[오류] TXT 레코드 생성에 실패했습니다: ${RESPONSE}" >&2
  exit 1
fi

echo "${SID}" > "/tmp/ncp_sid_${CERTBOT_DOMAIN}.txt"

# 변경 사항 반영
TIMESTAMP=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000)
URI_APPLY="/dns/v1/ncpdns/record/apply/${NCP_DOMAIN_ID}"
SIGNATURE=$(make_signature "PUT" "${URI_APPLY}" "${TIMESTAMP}")

curl -s --connect-timeout 10 --max-time 30 -X PUT \
  "${NCP_DNS_API}${URI_APPLY}" \
  -H "Content-Type: application/json" \
  -H "x-ncp-apigw-timestamp: ${TIMESTAMP}" \
  -H "x-ncp-iam-access-key: ${NCP_ACCESS_KEY}" \
  -H "x-ncp-apigw-signature-v2: ${SIGNATURE}" \
  -d '{}' > /dev/null

echo "DNS TXT 레코드 생성 완료. ${DNS_PROPAGATION_SECONDS}초 대기 중..."
sleep "${DNS_PROPAGATION_SECONDS}"

ncp-cleanup.sh

#!/bin/bash
# ncp-cleanup.sh — Certbot DNS-01 정리 훅 (NCP Global DNS)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/.env"

make_signature() {
  local method=$1 uri=$2 timestamp=$3
  printf "%s %s\n%s\n%s" "${method}" "${uri}" "${timestamp}" "${NCP_ACCESS_KEY}" \
    | openssl dgst -sha256 -hmac "${NCP_SECRET_KEY}" -binary | base64
}

TMP_FILE="/tmp/ncp_sid_${CERTBOT_DOMAIN}.txt"
SID=$(cat "${TMP_FILE}" 2>/dev/null || true)
if [ -z "${SID}" ]; then
  echo "[경고] 삭제할 레코드 ID를 찾을 수 없습니다." >&2
  exit 0
fi

# TXT 레코드 삭제 (SID를 요청 본문에 배열로 전달)
TIMESTAMP=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000)
URI="/dns/v1/ncpdns/record/${NCP_DOMAIN_ID}"
SIGNATURE=$(make_signature "DELETE" "${URI}" "${TIMESTAMP}")

curl -s --connect-timeout 10 --max-time 30 -X DELETE \
  "${NCP_DNS_API}${URI}" \
  -H "Content-Type: application/json" \
  -H "x-ncp-apigw-timestamp: ${TIMESTAMP}" \
  -H "x-ncp-iam-access-key: ${NCP_ACCESS_KEY}" \
  -H "x-ncp-apigw-signature-v2: ${SIGNATURE}" \
  -d "[${SID}]" > /dev/null

# 변경 사항 반영
TIMESTAMP=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000)
URI_APPLY="/dns/v1/ncpdns/record/apply/${NCP_DOMAIN_ID}"
SIGNATURE=$(make_signature "PUT" "${URI_APPLY}" "${TIMESTAMP}")

curl -s --connect-timeout 10 --max-time 30 -X PUT \
  "${NCP_DNS_API}${URI_APPLY}" \
  -H "Content-Type: application/json" \
  -H "x-ncp-apigw-timestamp: ${TIMESTAMP}" \
  -H "x-ncp-iam-access-key: ${NCP_ACCESS_KEY}" \
  -H "x-ncp-apigw-signature-v2: ${SIGNATURE}" \
  -d '{}' > /dev/null

rm -f "${TMP_FILE}"
echo "DNS TXT 레코드 삭제 완료."