目錄
用 Docker 把網站部署到雲端主機(VPS)
這一節把前面學到的東西串起來:把一個用 Docker Compose 管理的網站,部署到一台 Linux 雲端主機(VPS)上。
本節的輸出是在同一台機器上模擬完整流程:用本機的
registry:3代替 Docker Hub 或 GitHub Container Registry(localhost:5000),用 Caddy 為localhost簽發的本機憑證代替正式網域的憑證。實際部署時,只要把映像檔名稱和網域換成你自己的即可。
整體流程
本機或 CI ──docker build / push──▶ Registry ──docker compose pull──▶ 雲端主機
程式碼在本機或 CI 建置成映像檔,推送到 Registry;伺服器只負責下載映像檔並執行,不在伺服器上建置。這樣伺服器不需要原始碼和建置工具,每次部署的結果也完全一致,要回到舊版本只要換回舊的 tag。
1. 準備主機
任何雲端服務商的 Linux 主機都可以,以 Ubuntu 為例:
-
用 SSH 登入,建立一般使用者並停用密碼登入(只用金鑰)。
-
依照 安裝 Docker 的 Linux 步驟安裝 Docker Engine 與 Compose 外掛。
-
防火牆只開放 22(SSH)、80、443:
sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enable注意:
-p開放的連接埠會繞過 ufw(見 安全最佳實務),所以下面的 compose.yaml 中,只有反向代理有ports。 -
設定 Docker 開機自動啟動:
sudo systemctl enable docker。
2. 應用程式
範例是一個 Node.js + PostgreSQL 的小網站,每次請求就記錄一筆造訪,並回傳目前的版本與造訪次數。
myapp/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore
server.js:
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const version = process.env.APP_VERSION || "dev";
app.get("/", async (req, res) => {
await pool.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())");
await pool.query("INSERT INTO visits DEFAULT VALUES");
const { rows } = await pool.query("SELECT count(*)::int AS visits FROM visits");
res.json({ app: "myapp", version, visits: rows[0].visits });
});
const server = app.listen(3000, () => console.log(`myapp ${version} listening on port 3000`));
process.on("SIGTERM", () => server.close(() => pool.end().then(() => process.exit(0))));
package.json 的依賴是 express 和 pg。Dockerfile(寫法說明見 Node.js 應用),最後把版本號以 ARG 傳入、寫進 ENV,讓程式能顯示自己的版本:
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "server.js"]
3. 建置並推送映像檔
在本機或 CI 中,用固定的版本號當 tag:
$ docker build --build-arg APP_VERSION=1.0.0 -t localhost:5000/myapp:1.0.0 .
[+] Building 6.7s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 308B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 63B 0.0s
=> [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 2.4s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5 0.1s
=> => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0 2.1s
=> => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1 0.1s
=> => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 36.48kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 3.5s
=> [5/5] COPY --chown=node:node . . 0.1s
=> exporting to image 0.6s
=> => exporting layers 0.3s
=> => exporting manifest sha256:a0bd86a3b62021adb05a5e710c5a59f9e6287c8c84016ba22f1923bc4aaeeabf 0.0s
=> => exporting config sha256:7f75124e75541f256217e31a844e8c9b687434955d053e47e5dae571aa3944c6 0.0s
=> => exporting attestation manifest sha256:9d6688e6e287f9a0b3d3a95d23765cde8496c89bf6dd0c7fd7a191ac58abe9db 0.0s
=> => exporting manifest list sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 0.0s
=> => naming to localhost:5000/myapp:1.0.0 0.0s
=> => unpacking to localhost:5000/myapp:1.0.0 0.3s
$ docker push localhost:5000/myapp:1.0.0
The push refers to repository [localhost:5000/myapp]
44136fa355b3: Pushed
2297ea3ee753: Pushed
7ce7dcbafb7f: Pushed
fffb74b09a22: Pushed
e2de96513ba9: Pushed
1d73dbdb2af1: Pushed
bd9735416a50: Pushed
02f40d53b06f: Pushed
22ac32835411: Pushed
3c504d5a797b: Pushed
1.0.0: digest: sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 size: 856
實際使用時,映像檔名稱是 Registry 的位址加上你的帳號,例如 Docker Hub 的 yourname/myapp:1.0.0 或 GitHub 的 ghcr.io/yourname/myapp:1.0.0,並先 docker login(參考 推送到 Docker Hub)。
用 Apple Silicon Mac 建置、部署到一般 amd64 主機時,記得加
--platform linux/amd64,否則伺服器上會出現exec format error,詳見 多平台映像檔。
4. 伺服器上的檔案
在伺服器建立資料夾,例如 /srv/myapp,只需要三個檔案,不需要原始碼:
/srv/myapp/
├── compose.yaml
├── .env
└── Caddyfile
compose.yaml:
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
app:
image: localhost:5000/myapp:${APP_VERSION}
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
interval: 5s
retries: 10
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
volumes:
caddy_data:
caddy_config:
pgdata:
.env:
APP_VERSION=1.0.0
DB_PASSWORD=change-this-to-a-long-random-password
Caddyfile(實際部署時把 localhost 換成你的網域,例如 example.com):
localhost {
reverse_proxy app:3000
}
重點:
| 設定 | 說明 |
|---|---|
image: ...:${APP_VERSION} | 版本號由 .env 控制,更新與回滾只要改這一行 |
只有 caddy 有 ports | 應用程式和資料庫外部都連不到 |
| Caddy | 自動申請、更新 HTTPS 憑證,並把 HTTP 轉到 HTTPS,詳見 Nginx 與反向代理 最後一節 |
pgdata:/var/lib/postgresql | PostgreSQL 18 的掛載路徑,詳見 PostgreSQL |
pg_isready -h 127.0.0.1 | 避免資料庫初始化期間就被判定為 healthy |
restart: unless-stopped | 程式當掉或主機重新開機後自動啟動 |
logging | 限制日誌大小,避免塞滿硬碟,詳見 清理硬碟空間 |
.env 中有密碼,請設定權限:chmod 600 .env。
5. 啟動
$ ls -a
.
..
.env
Caddyfile
compose.yaml
$ docker compose pull
[+] pull 35/35
✔ Image caddy:2 Pulled 2.8s
✔ Image localhost:5000/myapp:1.0.0 Pulled 0.3s
✔ Image postgres:18 Pulled 12.2s
$ docker compose up -d
[+] up 7/7
✔ Network srv-myapp_default Created 0.0s
✔ Volume srv-myapp_caddy_config Created 0.0s
✔ Volume srv-myapp_caddy_data Created 0.0s
✔ Volume srv-myapp_pgdata Created 0.0s
✔ Container srv-myapp-db-1 Healthy 6.0s
✔ Container srv-myapp-app-1 Started 6.2s
✔ Container srv-myapp-caddy-1 Started 6.7s
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
srv-myapp-app-1 localhost:5000/myapp:1.0.0 "docker-entrypoint.s…" app 10 seconds ago Up 4 seconds 3000/tcp
srv-myapp-caddy-1 caddy:2 "caddy run --config …" caddy 10 seconds ago Up 3 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
srv-myapp-db-1 postgres:18 "docker-entrypoint.s…" db 10 seconds ago Up 9 seconds (healthy) 5432/tcp
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":1}
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":2}
$ docker compose logs app
app-1 | myapp 1.0.0 listening on port 3000
docker compose pull先下載所有映像檔,包括我們推送的myapp:1.0.0。up -d依照依賴順序啟動:db變成Healthy後才啟動app,最後是caddy。ps中只有 caddy 對外開放 80 和 443。- 透過 HTTPS 請求兩次,
visits從 1 變成 2,資料寫入了 PostgreSQL。(-k是因為這裡用的是本機憑證;正式網域使用 Let's Encrypt 憑證,不需要-k。)
正式部署時,打開 https://你的網域 確認網站正常,並用 docker compose logs -f app 觀察日誌。
6. 更新版本
在本機建置並推送新版本:
$ docker build -q --build-arg APP_VERSION=1.1.0 -t localhost:5000/myapp:1.1.0 .
sha256:aac108fcd63549926e767b781e0b9aa25a86e2f2cd449710a41b5e8d0accda16
$ docker push -q localhost:5000/myapp:1.1.0
localhost:5000/myapp:1.1.0
在伺服器上修改 .env 的 APP_VERSION,然後只拉取並重建 app:
$ grep APP_VERSION .env
APP_VERSION=1.1.0
$ docker compose pull app
[+] pull 1/1
✔ Image localhost:5000/myapp:1.1.0 Pulled 0.1s
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 1.0s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.1.0","visits":3}
$ docker compose images app
CONTAINER REPOSITORY TAG PLATFORM IMAGE ID SIZE CREATED
srv-myapp-app-1 localhost:5000/myapp 1.1.0 linux/amd64 aac108fcd635 251MB 44 seconds ago
pull app只下載app的新映像檔。(這裡因為在同一台機器模擬,映像檔已經在本機,所以只花了 0.1 秒。)up -d app發現映像檔變了,只重建app容器;db和caddy不受影響,網站只中斷約 1 秒。- 回應中的
version變成1.1.0,visits從 3 繼續累加,資料庫的資料完整保留。 docker compose images app確認目前使用的是1.1.0。
回滾
新版有問題時,把 APP_VERSION 改回 1.0.0,再 up -d app:
$ grep APP_VERSION .env
APP_VERSION=1.0.0
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 0.9s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":4}
一個指令就回到舊版本,而且舊映像檔還在伺服器上,不需要重新下載。這就是為什麼要用固定版本號而不是 latest:用 latest 的話,你無法確定伺服器上跑的是哪一版,也沒辦法明確地回到上一版。
資料庫結構的變更(migration)通常無法自動回滾。上線新版本前請先備份資料庫,而且新版本的資料庫變更最好能相容舊版程式。
7. 備份
最少要做到每天備份資料庫,並把檔案存到其他機器。在 /srv/myapp 建立 backup.sh:
#!/bin/sh
set -e
cd "$(dirname "$0")"
mkdir -p backup
docker compose exec -T db pg_dump -U app app | gzip > backup/app-$(date +%F).sql.gz
find backup -name 'app-*.sql.gz' -mtime +14 -delete
$ ./backup.sh && ls -lh backup
total 4K
-rw-r--r-- 1 root root 872 Oct 13 01:17 app-2026-10-13.sql.gz
$ gunzip -c backup/app-*.sql.gz | grep -A 6 "^COPY public.visits"
COPY public.visits (id, at) FROM stdin;
1 2026-10-13 01:16:44.277875+00
2 2026-10-13 01:16:44.390504+00
3 2026-10-13 01:16:57.763022+00
4 2026-10-13 01:17:02.44854+00
\.
- 備份檔以日期命名,
find ... -mtime +14 -delete刪除 14 天前的備份。 - 解壓縮確認內容:
visits資料表的 4 筆紀錄都在。
設定 chmod +x backup.sh,再用 crontab -e 每天凌晨 3 點執行:
0 3 * * * /srv/myapp/backup.sh
備份留在同一台主機上,主機故障時會一起消失,所以還要用 rsync、rclone 等工具把 backup/ 複製到其他機器或雲端儲存。還原方法見 PostgreSQL 備份與還原,並且要定期實際測試還原。
8. 自動化(選用)
手動 SSH 進去更新很快會變得繁瑣,常見的自動化方式:
-
CI/CD:在 GitHub Actions 等 CI 中建置、推送映像檔,再透過 SSH 在伺服器上修改
APP_VERSION並執行docker compose pull app && docker compose up -d app。 -
Docker context:在本機直接操作遠端伺服器的 Docker,不用先登入伺服器:
docker context create prod --docker "host=ssh://user@your-server" docker --context prod psdocker --context prod compose up -d會使用本機目錄中的compose.yaml,在遠端伺服器上執行。
上線前檢查清單
- SSH 只允許金鑰登入,防火牆只開放 22、80、443
- 只有反向代理有
ports,資料庫沒有對外開放 - 使用固定版本號的映像檔 tag,不使用
latest - 所有服務都有
restart: unless-stopped - 設定了日誌大小上限
- 健康檢查寫法正確(
pg_isready -h 127.0.0.1) -
.env權限為 600,且沒有提交到 Git - 資料庫每日自動備份到其他地方,並實際測試過還原
- 定期清理舊映像檔(見 清理硬碟空間),並定期重新建置以取得安全更新(見 安全最佳實務)
重點回顧
- 映像檔在本機或 CI 建置、推送到 Registry,伺服器只拉取與執行。
- 版本號放在
.env,更新與回滾只要改一行再up -d。 - 只有反向代理對外,資料庫健康檢查寫法要正確。
- 每天自動備份到其他機器,並定期測試還原。
動手練習
練習 1:伺服器上的 APP_VERSION 從 1.1.0 回滾到 1.0.0 後,資料庫中新版本寫入的資料還在嗎?
答案
還在。回滾只換了 app 的映像檔,資料庫的 Volume 沒有變動;本章實測 visits 持續累加。但如果新版本修改了資料表結構,舊版程式可能無法使用新結構,所以上線前要先備份。
常見問題
一定要用 Registry 嗎?不能在伺服器上 docker build?
可以,但伺服器就需要原始碼與建置工具,每次部署也可能建出不同的結果。透過 Registry 部署,伺服器只需要拉取已經測試過的映像檔,回滾也簡單。
要用哪種雲端主機?
任何能安裝 Docker Engine 的 Linux 主機都可以。小型網站 1~2 GB 記憶體通常就夠,資料庫較大時再增加。
可以自動部署嗎?
可以,用 GitHub Actions 在打版本 tag 時建置、推送並 SSH 到伺服器更新,見 [GitHub Actions 自動建置](/zh-tw/docker/ci-cd/)。
更新於 2026年10月14日