目录
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/docker/ci-cd/)。
更新于 2026年10月14日