用 Python 自動化 nginx 虛擬主機與 Let's Encrypt SSL 部署

管理多個靜態網站時,每次新增網域都要手動建立 nginx 設定、建 symlink、申請憑證,流程繁瑣又容易出錯。這篇文章記錄我如何用一支 Python 腳本解決這個問題,以及開發過程中踩過的坑。

為什麼要開發這支腳本

當網站數量從個位數增長到數十個時,手動管理 nginx 虛擬主機設定就會變成一種折磨。每次新增網域的標準流程大概是這樣:

  1. /etc/nginx/sites-available/ 建立設定檔
  2. /etc/nginx/sites-enabled/ 建立 symlink
  3. 執行 nginx -t 測試設定
  4. systemctl reload nginx
  5. 執行 certbot --nginx -d example.com 申請 SSL
  6. 如果是 naked domain,還要額外加上 www.example.com

這六個步驟,每個網域都要重複一次。更麻煩的是,如果之前已申請過 SSL 但忘記了,再次執行 certbot 不會造成問題,但會觸發不必要的 API 呼叫,而 Let’s Encrypt 有 rate limit 限制。

核心需求:

  • 掃描目錄,自動偵測所有待設定的網域
  • 自動產生 nginx 設定並啟用
  • naked domain 自動加上 www alias
  • 申請 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_namedomain 是兩個不同的佔位符。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 要不要加 www
  • obtain_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.comwww.abc.com 無法存取nginx server_name 沒有加 wwwis_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/ 建好目錄,再跑一次腳本就完成了。