目錄

用 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 為例:

  1. 用 SSH 登入,建立一般使用者並停用密碼登入(只用金鑰)。

  2. 依照 安裝 Docker 的 Linux 步驟安裝 Docker Engine 與 Compose 外掛。

  3. 防火牆只開放 22(SSH)、80、443:

    sudo ufw allow OpenSSH
    sudo ufw allow 80,443/tcp
    sudo ufw enable

    注意:-p 開放的連接埠會繞過 ufw(見 安全最佳實務),所以下面的 compose.yaml 中,只有反向代理有 ports。

  4. 設定 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/postgresqlPostgreSQL 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 ps

    docker --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日