目录

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/docker/ci-cd/)。

更新于 2026年10月14日