程式碼版控與 FTP 部署紀律
無 SSH 環境的 PHP 專案通常沒有版本歷史——程式碼直接透過 FTP 覆蓋伺服器上的檔案,每次上傳就是一次不可回溯的覆寫。接手這類專案時,第一步是在本地建立 Git repo 作為程式碼的唯一事實來源,第二步是把 FTP 上傳從「隨手改隨手傳」轉成有紀錄、可回退的部署流程。本篇聚焦在程式碼端的版控與部署;資料庫的備份與變更紀律見資料庫備份與變更管理;帳號與存取的安全管理見Legacy PHP 的安全盤點。
從 FTP 拉下來建立 Git repo
用 FTP client 把整個站台完整下載到本地目錄,這份下載就是 production 的快照。下載完成後在該目錄初始化 Git:
1cd /path/to/downloaded-site
2git init在第一次 commit 之前先處理 .gitignore。PHP 專案需要排除的檔案分三類:套件依賴(由 Composer 或 npm 管理、可重建)、執行期產物(快取、session、上傳檔案)、以及含有機密值的設定檔。
1# 套件依賴
2vendor/
3node_modules/
4
5# 執行期產物
6cache/
7tmp/
8sessions/
9*.log
10
11# 使用者上傳內容(通常很大、且屬於資料不屬於程式碼)
12uploads/
13media/
14wp-content/uploads/
15
16# 機密設定(下一節處理)
17.env
18config.local.php
19wp-config.php使用者上傳的內容(uploads/、media/)不進 Git 的理由是它屬於資料層:檔案數量可能成千上萬、總容量可能數 GB,Git 不適合管理這類大量二進位檔案。這些檔案的備份策略跟程式碼不同——用 FTP mirror 或 rclone 定期同步到本地即可。
設好 .gitignore 後做第一次 commit:
1git add -A
2git commit -m "production snapshot $(date +%Y-%m-%d)"這個 commit 就是「接手時 production 長什麼樣」的基準線。後續所有改動都從這裡開始有版本歷史。
Config 分離:讓 Git repo 不含機密值
無 SSH 環境的 PHP 專案常把資料庫密碼、API key、SMTP 憑證直接寫在 config.php 或 wp-config.php 裡。這些檔案如果進了 Git,機密值就跟著 repo 走——推到 GitHub 就等於公開。
分離的模式是把設定拆成兩份:一份進 Git(結構與預設值)、一份不進 Git(實際機密值)。
模式一:.env 檔案
使用 vlucas/phpdotenv 套件或手動解析,讓程式碼從 .env 檔案讀取環境變數:
1// config.php — 進 Git
2$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
3$dotenv->load();
4
5$db_host = $_ENV['DB_HOST'];
6$db_name = $_ENV['DB_NAME'];
7$db_user = $_ENV['DB_USER'];
8$db_pass = $_ENV['DB_PASS'];1# .env — 不進 Git(.gitignore 已排除)
2DB_HOST=localhost
3DB_NAME=mysite_prod
4DB_USER=mysite_user
5DB_PASS=actual-password-here同時在 repo 裡放一份 .env.example(進 Git),列出所有需要的環境變數但不填實際值:
1# .env.example — 進 Git,作為範本
2DB_HOST=
3DB_NAME=
4DB_USER=
5DB_PASS=
6SMTP_HOST=
7SMTP_USER=
8SMTP_PASS=模式二:config.local.php
如果專案不使用 Composer、引入 phpdotenv 成本太高,用 PHP include 分離:
1// config.php — 進 Git
2if (file_exists(__DIR__ . '/config.local.php')) {
3 require __DIR__ . '/config.local.php';
4} else {
5 die('config.local.php not found. Copy config.local.example.php and fill in values.');
6}1// config.local.php — 不進 Git
2$db_host = 'localhost';
3$db_name = 'mysite_prod';
4$db_user = 'mysite_user';
5$db_pass = 'actual-password-here';WordPress 的處理
WordPress 的 wp-config.php 同時包含機密值和非機密設定。把整份排除再 include 一份 local 版是最簡單的做法,但也可以只把機密值抽到 .env、wp-config.php 本身保留在 Git 裡:
1// wp-config.php — 進 Git(機密值從 .env 讀)
2$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
3$dotenv->load();
4
5define('DB_NAME', $_ENV['DB_NAME']);
6define('DB_USER', $_ENV['DB_USER']);
7define('DB_PASSWORD', $_ENV['DB_PASSWORD']);
8define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');分離完成後,用 grep 確認 repo 裡沒有殘留的明文密碼:
1git grep -in "password\|passwd\|secret\|api_key\|smtp" -- '*.php' ':!*.example*'任何命中都要評估:是真的機密值(要移到 .env)還是變數名稱(可以保留)。
FTP 部署的風險控制
FTP 上傳是逐檔覆寫,沒有交易性——上傳到一半斷線、或上傳了有語法錯誤的 PHP 檔案,站台會立刻出問題。風險控制的核心是「每次上傳前知道在改什麼、上傳後知道改了什麼」。
上傳前的比對
FileZilla 的目錄比較功能(「檢視 → 目錄比較 → 啟用」)可以在上傳前看到本地與遠端的差異:哪些檔案是本地較新、哪些是遠端較新、哪些只存在於一邊。上傳前先跑比較、確認差異清單符合預期——如果出現預期外的「遠端較新」檔案,代表有人在伺服器上直接改了東西,要先下載回來合併再上傳。
只上傳改過的檔案
一次上傳整個站台目錄既慢又危險。只上傳 Git diff 顯示的改動檔案:
1# 列出相對於上次部署 tag 改了哪些檔案
2git diff --name-only deploy-2026-06-25 HEAD把這份清單對照 FileZilla 的比較結果,逐一上傳。量大時用 lftp 的 mirror 指令加 --only-newer flag 只傳新檔。
關鍵檔案的額外保護
index.php、.htaccess、設定檔這類檔案壞掉會讓整個站台無法存取。上傳這些檔案之前,先從伺服器下載一份當前版本存到本地的 _backup/ 目錄(gitignored)。如果上傳後站台出問題,可以立刻把備份版本傳回去。
部署前後的驗證
部署前檢查
| 項目 | 確認方式 |
|---|---|
| 本地測試通過 | 在本地環境跑過改動的頁面 / 功能 |
| Git 已 commit | git status 顯示 clean |
| 要上傳的檔案清單已確認 | git diff --name-only 輸出符合預期 |
| 關鍵檔案已備份 | _backup/ 有當前版本 |
部署後驗證
上傳完成後立刻驗證:
- 首頁能正常載入(HTTP 200、頁面內容正確)
- 本次改動涉及的功能可正常操作
- 如果是電商站:結帳流程、金流 callback 測試
- 檢查 PHP error log(cPanel → 錯誤日誌、或 FTP 下載
error_log檔案)
如果驗證失敗,回退方式是從 Git 歷史取出上一個版本的受影響檔案重新上傳:
1# 取出上一個部署 tag 的特定檔案
2git show deploy-2026-06-25:path/to/file.php > _rollback/file.php
3# 用 FTP 上傳 _rollback/file.php 覆蓋 prodCI 化 FTP 部署
手動 FTP 部署的問題是它依賴特定人的 FTP client 和操作紀律。用 GitHub Actions 把 FTP 上傳自動化,可以讓部署變成「push 到 main → CI 跑測試 → CI 上傳到伺服器」的流程,不依賴任何人的本地環境。
1name: Deploy via FTP
2on:
3 push:
4 branches: [main]
5
6jobs:
7 deploy:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 with:
12 fetch-depth: 2
13
14 - name: Deploy to FTP
15 uses: SamKirkland/FTP-Deploy-Action@v4
16 with:
17 server: ${{ secrets.FTP_HOST }}
18 username: ${{ secrets.FTP_USER }}
19 password: ${{ secrets.FTP_PASS }}
20 server-dir: /public_html/
21 exclude: |
22 **/.git*
23 **/.git*/**
24 **/node_modules/**
25 **/.env
26 **/config.local.phpFTP 憑證存在 GitHub repo 的 Secrets 裡(Settings → Secrets and variables → Actions),不寫在 workflow 檔案裡。
CI 化後的改變
| 面向 | 手動 FTP | CI 化 FTP |
|---|---|---|
| 部署紀錄 | FTP client 的 log(通常不保留) | GitHub Actions 的 run history(永久保留) |
| 部署觸發 | 某人手動操作 | push 到 main 自動觸發 |
| 上傳前測試 | 依賴個人紀律 | CI 可加 lint / test step |
| 多人協作 | 需要共用 FTP 帳密 | 帳密在 GitHub Secrets、workflow 共用 |
限制
FTP 部署沒有原子性(atomic deployment)——檔案逐一上傳的過程中,伺服器上同時存在新舊版本的檔案混合狀態。如果上傳的檔案之間有依賴關係(新的 A.php 引用新的 B.php,但 B.php 還沒上傳完),短暫的錯誤窗口無法避免。流量高的站台如果需要零停機部署,需要升級到 SSH + symlink 切換的部署方式,那屬於 VPS 遷移之後的能力。
Git tagging 部署紀錄
每次部署前在 Git 打一個 tag,讓「這次部署的是哪個版本」有明確的錨點:
1git tag deploy-$(date +%Y-%m-%d-%H%M)
2git push origin --tagstag 的命名用日期時間戳而非版號,因為這類專案通常沒有語意化版號的概念。tag 的作用是:
- 回退時知道要退到哪個版本(
git diff deploy-previous deploy-current看這次改了什麼) - 多次部署之間的差異可追蹤
- CI 化後可以用 tag 觸發部署而非每次 push 都部署
資料庫變更的回退跟程式碼獨立處理——程式碼可以靠 Git 回退,資料庫要靠 SQL dump 回退,兩者的回退點要對齊但機制不同。資料庫的備份策略見資料庫備份與變更管理。
跨分類引用
- → 無 SSH 的 FTP / 面板管理環境接管:本篇的母文章,涵蓋接手的完整流程
- → 資料庫備份與變更管理:資料庫端的備份、migration 紀律與回退策略
- → Legacy PHP 的安全盤點:credential 分離之後的存取控制與安全掃描
- → 無 SSH 環境的監控與告警:部署後用外部監控驗證服務正常
- → 模組七:infra 走 PR 流程:從 FTP CI 化進一步演進到完整的 PR review 流程