用 Python 自動化 nginx 虛擬主機與 Let's Encrypt SSL 部署
管理多個靜態網站時,每次新增網域都要手動建立 nginx 設定、建 symlink、申請憑證,流程繁瑣又容易出錯。這篇文章記錄我如何用一支 Python 腳本解決這個問題,以及開發過程中踩過的坑。
為什麼要開發這支腳本
當網站數量從個位數增長到數十個時,手動管理 nginx 虛擬主機設定就會變成一種折磨。每次新增網域的標準流程大概是這樣:
- 在
/etc/nginx/sites-available/建立設定檔 - 在
/etc/nginx/sites-enabled/建立 symlink - 執行
nginx -t測試設定 systemctl reload nginx- 執行
certbot --nginx -d example.com申請 SSL - 如果是 naked domain,還要額外加上
www.example.com
這六個步驟,每個網域都要重複一次。更麻煩的是,如果之前已申請過 SSL 但忘記了,再次執行 certbot 不會造成問題,但會觸發不必要的 API 呼叫,而 Let’s Encrypt 有 rate limit 限制。
核心需求:
- 掃描目錄,自動偵測所有待設定的網域
- 自動產生 nginx 設定並啟用
- naked domain 自動加上
wwwalias - 申請 SSL 前先確認是否已有有效憑證
- 支援
--dry-run預覽模式
整體架構
腳本分為五個區塊:
scan_domains() → 掃描 /var/www/websites/ 下的目錄名稱
write_nginx_config() → 依模板產生 nginx 設定
enable_nginx_config() → 建立 sites-enabled symlink
ssl_is_valid() → 檢查現有憑證是否有效
obtain_ssl() → 呼叫 certbot 申請憑證
程式碼分段解說
1. nginx 設定模板
NGINX_TEMPLATE = """\
server {{
listen 80;
listen [::]:80;
server_name {server_name};
root /var/www/websites/{domain};
index index.html index.htm index.php;
access_log /var/log/nginx/{domain}_access.log;
error_log /var/log/nginx/{domain}_error.log;
location / {{
try_files $uri $uri/ =404;
}}
location ~ /\\.well-known/acme-challenge {{
allow all;
root /var/www/websites/{domain};
}}
}}
"""
這裡有個細節:server_name 和 domain 是兩個不同的佔位符。domain 是目錄名稱(永遠只有一個),而 server_name 可能是 "example.com www.example.com"。模板中雙重花括號 {{ 是 Python .format() 的跳脫寫法,避免被誤判為變數。
.well-known/acme-challenge 區塊是給 Let’s Encrypt HTTP-01 驗證用的,必須確保這個路徑可公開存取。
2. Naked Domain 偵測
def is_naked_domain(domain: str) -> bool:
"""判斷是否為 naked domain(無子網域,如 abc.com)"""
return domain.count(".") == 1
判斷邏輯:只有一個點代表是 domain.tld 結構(naked domain),兩個以上的點代表是子網域(如 sub.example.com)。
這個函式在兩個地方被呼叫:
write_nginx_config():決定server_name要不要加wwwobtain_ssl():決定 certbot 要不要加第二個-d www.xxx
3. 寫入 nginx 設定
def write_nginx_config(domain: str, logger, force: bool, dry_run: bool) -> bool:
config_path = Path(NGINX_AVAILABLE) / domain
if config_path.exists() and not force:
logger.info(f"[{domain}] nginx 設定已存在,跳過(使用 --force 覆蓋)")
return False
if is_naked_domain(domain):
server_name_str = f"{domain} www.{domain}"
else:
server_name_str = domain
content = NGINX_TEMPLATE.format(domain=domain, server_name=server_name_str)
if not dry_run:
config_path.write_text(content)
return True
回傳值 True/False 代表「是否有新寫入設定」,用來決定後續是否需要 reload nginx。
4. 檢查 SSL 有效性(避免重複申請)
這是這次開發中最值得紀錄的部分。
問題: Let’s Encrypt 對同一 domain 每週有 5 次憑證申請的 rate limit。如果腳本每次跑都重新申請,很快就會撞牆。
解法: 直接讀取本機已有的 PEM 憑證,用 openssl 解析到期日後與當前時間比較。
def ssl_is_valid(domain: str, logger) -> bool:
cert_path = Path(f"/etc/letsencrypt/live/{domain}/cert.pem")
if not cert_path.exists():
return False
try:
result = subprocess.run(
["openssl", "x509", "-noout", "-enddate", "-in", str(cert_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
return False
# 輸出格式:notAfter=Apr 5 00:00:00 2026 GMT
date_str = result.stdout.strip().split("=", 1)[1]
expiry = datetime.strptime(date_str, "%b %d %H:%M:%S %Y %Z")
if expiry > datetime.utcnow():
logger.info(f"[{domain}] SSL 憑證有效,到期日:{expiry.date()},跳過申請")
return True
return False
except Exception as exc:
logger.warning(f"[{domain}] 檢查 SSL 有效期時發生錯誤:{exc}")
return False
踩坑筆記:
openssl x509 -enddate輸出的月份縮寫是英文(Apr),且日期單位數時會有空格補位(" 5"而非"05"),解析格式要用"%b %d %H:%M:%S %Y %Z"才能正確處理- 比較時間用
datetime.utcnow()而非datetime.now(),因為憑證時間是 UTC except Exception吞掉所有例外並回傳False,讓腳本退化成「嘗試申請」而不是直接失敗
naked domain 的憑證檔案路徑仍然是 /etc/letsencrypt/live/{domain}/cert.pem(不含 www),certbot 會將 SAN(Subject Alternative Name)一起打進同一張憑證。
5. 申請 SSL
def obtain_ssl(domain: str, email: str, logger, dry_run: bool) -> bool:
domains_args = ["-d", domain]
if is_naked_domain(domain):
domains_args += ["-d", f"www.{domain}"]
cmd = [
"certbot", "--nginx",
*domains_args,
"--non-interactive",
"--agree-tos",
"-m", email,
"--redirect",
]
return run(cmd, logger, dry_run)
--redirect 讓 certbot 自動在 nginx 設定中加上 HTTP → HTTPS 301 redirect,不需要手動編輯。--non-interactive 搭配 --agree-tos 適合無人值守的排程執行。
6. 主流程
# 3. 處理每個網域
for domain in domains:
config_written = write_nginx_config(domain, logger, args.force, args.dry_run)
enabled = enable_nginx_config(domain, logger, args.dry_run)
if not enabled:
results["error"].append(domain)
continue
if config_written:
nginx_changed = True
results["success"].append(domain)
# 4. Reload nginx(如有變更)
if nginx_changed or args.dry_run:
reload_nginx(logger, args.dry_run)
# 5. 申請 SSL
for domain in results["success"]:
if not args.dry_run and ssl_is_valid(domain, logger):
results["ssl_skipped"].append(domain)
continue
if obtain_ssl(domain, args.email, logger, args.dry_run):
results["ssl_ok"].append(domain)
else:
results["ssl_fail"].append(domain)
nginx_changed 是一個 flag,只有在「至少一個設定檔被實際寫入」時才 reload nginx,避免沒有變更時也觸發 reload。
SSL 申請在 nginx reload 之後,確保 HTTP 驗證路徑已經生效。
遇到的問題與解決方式
| 問題 | 原因 | 解法 |
|---|---|---|
naked domain 只設定了 abc.com,www.abc.com 無法存取 | nginx server_name 沒有加 www | is_naked_domain() 偵測後自動加上 |
| certbot 每次都重新申請,偶爾遇到 rate limit | 沒有檢查現有憑證 | ssl_is_valid() 讀取本機 PEM 比對到期日 |
openssl 日期解析失敗 | 單位數日期有空格補位 | 使用 "%b %d %H:%M:%S %Y %Z" 格式(注意 %d 會處理空格) |
| nginx reload 失敗導致後續 SSL 申請卡住 | nginx -t 測試失敗時沒有終止 | reload 失敗時直接 sys.exit(1) |
使用方式
# 預覽(不實際執行)
sudo python3 scan_websites-domain.py --email [email protected] --dry-run
# 正式執行
sudo python3 scan_websites-domain.py --email [email protected]
# 強制重新產生 nginx 設定(已存在的也覆蓋)
sudo python3 scan_websites-domain.py --email [email protected] --force
# 只設定 nginx,不申請 SSL
sudo python3 scan_websites-domain.py --email [email protected] --skip-ssl
執行完成後腳本會自動 reload nginx,不需要手動重啟服務。
小結
這支腳本把「掃描 → 設定 nginx → 啟用 → 申請 SSL」整個流程自動化,並且加入了冪等性設計(跑多次結果相同,不會重複申請憑證、不會覆蓋已有設定)。之後新增網域只需要在 /var/www/websites/ 建好目錄,再跑一次腳本就完成了。