這個站花了兩天架起來:一台 Vultr 東京的 1GB VPS 跑 WordPress,前面掛 Cloudflare,後台用 Cloudflare Access 搭 Entra ID 保護,寄信交給自己的 Exchange Online。這篇把過程中的選擇、踩到的坑和驗證方式整理下來,算是本站的第一篇「建站紀錄」。
前言:為什麼最後選了自架 WordPress
一開始其實沒打算用 WordPress。比較過的路線大概有這幾條:
| 路線 | 優點 | 我最後沒選的原因 |
|---|---|---|
| Astro / Hugo + Cloudflare Pages | 免費、沒有後台可以被打、內容就是 Markdown | 寫文章要走 Git 流程,想要開瀏覽器就能寫 |
| WordPress.com 等託管平台 | 主機、更新都不用管 | 低階方案限制多,綁平台 |
| Wix 這類視覺化建站 | 拖拉就有好看的頁面 | 內容鎖在平台,匯出困難 |
| WordPress 自架在 VPS | 有後台、外掛多、整台機器自己掌控 | 維運與資安要自己扛 |
最後選 VPS 的理由很實際:VPS 按月計費、隨時能砍掉重建,成本可控;DNS 本來就在 Cloudflare,手上也有現成的 Exchange Online Plan 1 可以處理寄信。維運成本對 IT 人來說不算負擔,反而是這個 Lab 的學習價值。
整體架構總覽

一般訪客要先經過 Cloudflare 的 WAF,再被 Vultr 防火牆檢查來源是不是 Cloudflare,最後由 Nginx 確認網域;後台另外多一道 Access + Entra ID。WordPress 自己不對外寄信,而是透過 Graph API 交給 Exchange Online。
主機選型:Vultr、Ubuntu 24.04、Marketplace Nginx 版
主機是 Vultr 東京機房的 1 vCPU / 1GB RAM / 25GB SSD,OS 用 Ubuntu 24.04 LTS,WordPress 用 Vultr Marketplace 的原廠 WordPress(Nginx 版)。
為什麼不直接上 26.04? 新 LTS 剛出,第三方套件與 WordPress 對新版 PHP 的相容性還在追。24.04 先穩穩跑,之後用「開新機搬家」的方式升級:另開一台乾淨的 26.04,匯入 DB 和 wp-content,確認沒問題再把 Cloudflare 的 A 記錄切過去。回滾只要把 DNS 切回來,順便還能驗證備份是否真的還原得起來。
Marketplace 裡兩個 WordPress 怎麼選? 我選 Nginx 版,關鍵不是效能,而是 PHP 由誰維護:
| WordPress(Nginx) | OpenLiteSpeed WordPress | |
|---|---|---|
| PHP 來源 | Ubuntu 官方 php8.x-fpm | LiteSpeed 自家 LSPHP |
| 安全修補 | 跟著 Ubuntu USN,unattended-upgrades 自動套用 | 要自己盯 LiteSpeed 的發布節奏 |
| 額外管理介面 | 無 | WebAdmin(預設 7080 埠) |
| 快取強項 | 交給 Cloudflare | LSCache 整合很好 |
前面已經有 Cloudflare 快取,LSCache 的優勢就不明顯了;少一條要盯的修補鏈比較重要。
兩個小坑
- Marketplace 的 app 只能在建立 instance 時套用,不能當 addon 裝到現有主機。原本開好的空主機要嘛用 Change Application 重裝(會清空硬碟),要嘛重開一台。
- 1GB 跑 MySQL + PHP-FPM 偏緊,尖峰可能被 OOM killer 砍掉,裝完先加 2GB swap:
fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
free -h # 驗證:Swap 應顯示 2.0Gi
部署完先盤點一次,確認拿到的是自己要的版本:
lsb_release -a
nginx -v
dpkg -l 'php8.*-fpm'
Cloudflare 前置設定與鎖住 Origin
Cloudflare 擋在前面只是第一步;如果 VPS 的 80/443 對全網開放,別人直接打 IP 就能繞過 WAF 和 Access。所以這段的重點是「Origin 只認 Cloudflare」。
1. SSL 模式選 Full (strict)
不要用 Flexible:WordPress 會出現無限 redirect,而且 Cloudflare 到 Origin 這段是明文。Origin 憑證用映像內建 certbot 申請的 Let’s Encrypt;申請時 DNS 先設灰雲,拿到憑證再切回橘雲。
2. Nginx 還原訪客真實 IP
不設定的話,Nginx log 和 WordPress 看到的來源全是 Cloudflare 的 IP。建立 /etc/nginx/conf.d/cloudflare-realip.conf,IP 段依 Cloudflare 官方清單 完整填入:
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ...其餘 IPv4 / IPv6 段
real_ip_header CF-Connecting-IP;
3. Vultr Firewall Group:80/443 只放行 Cloudflare
| 方向 | 協定/埠 | 來源 | 說明 |
|---|---|---|---|
| IPv4 | TCP 22 | 自家固定 IP /32 | SSH,最先加這條 |
| IPv4 | TCP 80、443 | cloudflare | Vultr 防火牆可直接選 Cloudflare,會自動帶入 IP 段 |
| IPv6 | TCP 80、443 | cloudflare | 同上 |
萬一 SSH 把自己鎖在外面,Vultr 後台還有 Web Console 可以進去,不會完全失聯。
4. 停用 Cockpit
Vultr 映像預設帶了 Cockpit,還用 Nginx 在 9080 埠反向代理它的管理介面。用不到的管理介面就是多一個攻擊面,直接關掉:
sudo systemctl disable --now cockpit.socket cockpit.service
sudo ss -tlnp | grep -E '9080|9090' # 驗證:應該沒有輸出
回滾是 sudo systemctl enable --now cockpit.socket。確定一段時間都用不到,再 apt purge 'cockpit*'。
驗證這一段有沒有做對
curl -k -m 5 https://<VPS_IP> -H "Host: mark-ke.com"應該逾時- 用網域開網站正常,回應標頭有
cf-ray sudo certbot renew --dry-run成功,憑證續約不受防火牆影響tail /var/log/nginx/access.log看到的是訪客真實 IP
回滾:Firewall Group 從 instance 卸除即恢復原狀;網站連不上時,先把 Cloudflare 切灰雲,判斷是 Cloudflare 還是 Origin 的問題。
踩坑:陌生網域指向我的主機
整個過程最意外的發現:Wordfence 紀錄裡出現一個我完全不認識的網域,對我的 /wp-login.php 發請求,而且回應是 200。也就是說,透過那個網域可以直接看到我的登入頁,繞過了只綁在 mark-ke.com 上的 Cloudflare Access。
它怎麼來的? nslookup 查回來全是 Cloudflare 的 IP,代表那個網域掛在別人的 Cloudflare 帳號上,回源指向我這台 VPS。最可能的原因是 Vultr 的 IP 會回收再分配,前一任使用者退租後沒改 DNS,網域就一直指著這個 IP。這種流量也同樣來自 Cloudflare 的 IP,所以 Vultr 防火牆放行了它。
為什麼 Nginx 會回應? 看一下設定就知道:
sudo grep -n "listen\|server_name" /etc/nginx/sites-enabled/*
Vultr 映像的 wordpress_http.conf 是 server_name _; 加上 default_server,任何網域都收;wordpress_https.conf 雖然寫了我的網域,但它也是 443 的 default_server,不認識的網域照樣交給它處理。
解法:Nginx 預設拒絕,只回應自己的網域,其餘一律斷線。
- 先備份整個設定:
sudo tar czf /root/nginx-backup-$(date +%F-%H%M).tgz /etc/nginx - 拿掉原站台的
default_server,並把server_name _;改成自己的網域:
sudo sed -i --follow-symlinks 's/ default_server//g' \
/etc/nginx/sites-enabled/wordpress_http.conf /etc/nginx/sites-enabled/wordpress_https.conf
sudo sed -i --follow-symlinks 's/server_name _;/server_name mark-ke.com www.mark-ke.com;/' \
/etc/nginx/sites-enabled/wordpress_http.conf /etc/nginx/sites-enabled/wordpress_https.conf
- 新增一個專門拒絕的預設站台:
# /etc/nginx/conf.d/00-default-deny.conf
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
server_name _;
return 444;
}
sudo nginx -t && sudo systemctl reload nginx
第一次我直接做第 3 步,nginx -t 報了 a duplicate default server for 0.0.0.0:80。這正是原站台也宣告了 default_server 造成的衝突;好在 nginx -t 沒過,&& 後面的 reload 根本不會執行,線上網站完全不受影響。這也是為什麼改 Nginx 一定要用 nginx -t && reload 這個組合。
驗證:curl -skI https://mark-ke.com 回 200;用陌生網域開 /wp-login.php 只剩 Cloudflare 的 5xx 錯誤頁。
回滾:刪掉 00-default-deny.conf,從備份解回 /etc/nginx,再 nginx -t && reload。
後台保護:Cloudflare Access + Entra ID 與 WAF 自訂規則
WordPress 後台不靠帳密硬扛,而是在 Cloudflare 邊緣就先驗身分:Cloudflare Access 的 IdP 接自己 M365 租戶的 Entra ID,要登入 WordPress 之前得先過 Microsoft 登入與 MFA。攻擊流量根本到不了 VPS。
Access 的保護範圍有三個細節
- 只保護
/wp-admin不夠,/wp-login.php也要涵蓋,否則登入頁照樣能被暴力嘗試。 /wp-admin/admin-ajax.php要另外建一條 Bypass 政策。前台的聯絡表單等功能常會呼叫它,被 Access 擋住會整個壞掉。- 驗證:無痕視窗開
/wp-login.php,應該先跳到 Microsoft 登入頁。
WAF 自訂規則
到 Security → WAF → Custom rules 建立。免費方案規則數有限,所以盡量合併成兩條:
| 規則 | 運算式 | 動作 |
|---|---|---|
| 擋 WordPress 常見攻擊路徑 | (http.request.uri.path eq "/xmlrpc.php") or (http.request.uri.path contains "/wp-content/uploads/" and http.request.uri.path contains ".php") or (http.request.uri.path in {"/wp-config.php" "/readme.html" "/license.txt"}) or (http.request.uri.path contains "/.env") or (http.request.uri.path contains "/.git") | Block |
| 擋使用者列舉 | (starts_with(http.request.uri.path, "/wp-json/wp/v2/users")) or (http.request.uri.query contains "author=") | Block |
另外確認 Managed rules 裡 Cloudflare 的免費受管規則集有開。
驗證:無痕視窗開 /xmlrpc.php 和 /wp-json/wp/v2/users,都應該看到 Cloudflare 的封鎖頁;Security → Events 看得到攔截紀錄。
回滾:誤擋時把該規則切成停用,立即生效。
Wordfence 的去留
原本打算把 Wordfence 砍掉,全部交給 Cloudflare WAF;最後決定保留,但角色從「防火牆」改成「記錄與溯源」。上一節那個陌生網域,就是靠 Wordfence 的紀錄才發現的,這個價值是 Cloudflare 那邊替代不了的。
讓它在小 VPS 上輕一點
- 即時流量維持「只記錄安全相關流量」,不要開「所有流量」
- 掃描類型選「有限掃描(Limited Scan)」
- 不開 Extended Protection(防火牆最佳化)
Extended Protection 會在網站根目錄寫一個 .user.ini,用 auto_prepend_file 讓 Wordfence 在每個 PHP 請求前先執行。效能成本高,還要另外在 Nginx 擋外界讀取 .user.ini;更麻煩的是之後若要移除外掛,沒先解除最佳化就直接刪,整站會出 PHP fatal error。外層已經有 Cloudflare WAF,基本防護就夠了。
讓紀錄記到真實 IP
前面 Nginx 已經用 cloudflare-realip.conf 把真實 IP 寫進 REMOTE_ADDR,所以 Wordfence → 所有選項 →「Wordfence 如何取得 IP」選「使用 PHP 內建的 REMOTE_ADDR」。這樣最可靠,也不會被偽造的 X-Forwarded-For 標頭騙。驗證方式是看選項下方顯示的「偵測到的你的 IP」,應該是自家的對外 IP,而不是 104.x 開頭的 Cloudflare 位址。
順帶一提:LiteSpeed Cache 不用裝。 它最核心的整頁快取需要 LiteSpeed/OpenLiteSpeed 網頁伺服器,在 Nginx 上只剩壓縮和圖片最佳化。這台的快取先交給 Cloudflare,流量大了再考慮 Nginx FastCGI Cache 搭 Nginx Helper。
寄信:交給 Exchange Online 並走 OAuth
WordPress 的密碼重設、留言通知、聯絡表單都要寄信。VPS 自己寄很容易被當垃圾信,Vultr 預設也封鎖對外 port 25,所以交給已經設好 SPF、DKIM、DMARC 的 Exchange Online 寄送(網域郵件的設定過程寫在另一篇 M365 + Cloudflare 教學)。
不要用「帳號密碼 + smtp.office365.com:587」。 微軟已公告,既有租戶的 SMTP AUTH Basic Authentication 會在 2026 年 12 月底預設停用,最終移除日期另行公布(MC786329)。像 SMTP Mailer 這類只支援帳密的外掛,等於兩個月後就要重做。
做法:換成支援 Microsoft 365 OAuth 的 SMTP 外掛(我用 FluentSMTP),信件經 Graph API 走 443 送出,剛好避開 port 25 的限制。
- 在 Entra ID 建一個 App Registration 給 WordPress 用
- 寄件人用專用信箱,例如
noreply@開頭的地址 - 如果用 Graph 的
Mail.Sendapplication 權限,務必用 RBAC for Applications(或 Application Access Policy)限定只能以這個信箱寄信;否則 WordPress 一旦被打穿、client secret 外洩,攻擊者就能冒用租戶內任何人的名義寄信 - Client secret 設到期日,並排進行事曆提醒更新
切換順序:停用舊外掛 → 啟用新外掛並完成 OAuth → 寄測試信成功 → 才刪舊外掛。舊外掛若存過信箱密碼或應用程式密碼,記得到 M365 撤銷。
驗證:寄測試信到 Gmail,「顯示原始郵件」裡 SPF、DKIM、DMARC 都是 PASS;EXO 的 Message Trace 看得到這封信;再登出用「忘記密碼」實際收一次。
回滾:停用外掛即可(WordPress 會退回 PHP mail(),大概率寄不出去,但不影響網站);要撤權就刪除 client secret 或整個 App Registration,立即失效。
外觀與外掛
佈景主題繞了一圈才定案。先用官方 Twenty Twenty-Five 改成藍色系,後來覺得 Astra 比較清爽,改用 Astra 加上自製子主題 mark-ke-astra。目前版面是首頁不放側欄、文章用三欄卡片,頁首深藍、背景淡藍灰。
幾個值得記下的點
- 一定要用子主題:改 CSS 或 PHP 都放子主題裡,主題更新才不會被覆蓋。
- 換主題前先拍 Vultr snapshot。切換後出問題,回「外觀 → 主題」啟用原主題;後台進不去,就 SSH 把
wp-content/themes/<新主題>改名,WordPress 會自動退回預設主題。 - 文章寬度:Astra 單篇文章用「窄版容器」,預設最大寬度 750px,讀技術文時表格和程式碼有點擠。在「外觀 → 自訂 → 全域 → 容器」調「窄版容器最大寬度」,860px 左右是中文段落與程式碼區塊比較平衡的寬度。
- 自訂器和子主題的設定會互相覆蓋:先在自訂器拖到滿意,再把數字寫進子主題,避免兩邊打架。
目前的外掛
| 外掛 | 用途 | 備註 |
|---|---|---|
| Wordfence | 記錄與溯源 | 有限掃描,不開 Extended Protection |
| FluentSMTP | 經 M365 OAuth 寄信 | 取代只支援帳密的 SMTP Mailer |
| Rank Math | SEO、sitemap | sitemap 已提交 Google Search Console |
| Code Block Pro | 程式碼語法高亮 | 主題本身不負責這塊 |
裝外掛的原則是一次裝一個,每裝一個就開首頁和一篇文章確認;裝之前看一下「最後更新」和「已測試相容版本」,太久沒更新的就跳過。外掛出事時,SSH 把 wp-content/plugins/<外掛資料夾> 改名就能強制停用。
SEO 小技巧:頁面標題、選單文字、SEO 標題在 WordPress 是三個分開的欄位。頁面可以用長標題放關鍵字,選單在「外觀 → 選單 → 導覽標籤」維持短短的「服務項目」,搜尋結果的 <title> 再到 Rank Math 的摘要片段手動設,互不影響。
驗收清單、待辦與結語
上線後我用這份清單逐項確認,全部用無痕視窗或外部指令測,不靠「我覺得應該好了」:
| 檢查項目 | 方法 | 預期結果 |
|---|---|---|
| Origin 不能直連 | curl -k -m 5 https://<VPS_IP> | 逾時 |
| 陌生網域被拒 | 以其他網域開 /wp-login.php | Cloudflare 5xx 錯誤頁 |
| 後台有 Access | 無痕開 /wp-login.php | 跳 Microsoft 登入 |
| WAF 規則生效 | 開 /xmlrpc.php、/wp-json/wp/v2/users | Cloudflare 封鎖頁 |
| 管理介面已關 | ss -tlnp | 沒有 9080/9090 |
| 寄信對齊網域 | 測試信寄 Gmail 看原始郵件 | SPF/DKIM/DMARC 皆 PASS |
| 真實 IP | Nginx access log、Wordfence 偵測 IP | 非 Cloudflare IP 段 |
| 整體健康 | 工具 → 網站健康狀態 | 無 critical 項目 |
上線第二天就碰上 PHP 漏洞
10 月底 PHP 核心爆出代號 PHP2Shell 的漏洞,新聞多半寫成 Synology NAS 中招,但它其實是 PHP 本身的問題,Nginx + PHP-FPM 的 WordPress 一樣在範圍內(NCSC-NL 整理、Synology-SA-26:14)。這時候當初選 Ubuntu 官方 PHP 的好處就出來了:先拍 snapshot,apt install --only-upgrade 'php8.3-*',再重啟 php8.3-fpm(舊 worker 會留在記憶體,一定要重啟)。要注意 Ubuntu 是 backport 修補,php -v 仍顯示 8.3.6,比對的是套件版本,不是 PHP 版本號。
還沒做、之後慢慢來的
- Snapshot 以外的備份:DB dump +
wp-content定期送到 Cloudflare R2 或之後的 NAS,並實際還原一次 - 流量變大時評估 Nginx FastCGI Cache
- 等 WordPress 對 PHP 8.5 拿掉 beta 標籤,開新機搬到 Ubuntu 26.04
- Entra ID App Registration 的 client secret 到期前更新
結語
兩天下來最大的感想是:WordPress 本身不難裝,難的是把它周邊的每一層都想清楚,誰能連進來、誰能登入、出事時怎麼查、改壞了怎麼退。每一步都留下驗證與回滾方式,之後不管是自己回頭看,還是幫別人架站,都能直接照著走。
本文為個人 Lab 實作紀錄,與任職單位無關。文中 IP、帳號等資訊均已去識別化,套用到正式環境前請先在測試環境驗證,照做之前記得先備份。