Docker 入门与实战:容器化 React + NestJS 项目

认识 Docker

Docker 解决什么问题

没有 Docker 时经常遇到的问题

假设一个项目需要:

  • Node.js 24;
  • npm 安装依赖;
  • React 前端;
  • NestJS 后端;
  • PostgreSQL 数据库;
  • Nginx 提供静态文件和反向代理。

新同事拿到代码后,必须分别安装 Node.js、PostgreSQL、Nginx,并保证版本和配置正确。很容易出现:

  • "我电脑可以运行,你的电脑不行";
  • 本地 Node.js 版本不同,依赖安装结果不同;
  • 本地 PostgreSQL 端口、用户名或密码不同;
  • 测试环境和生产环境的配置不一致;
  • 部署服务器漏装软件或漏执行某一步;
  • 同一台服务器上的两个项目依赖版本冲突。

这些问题的共同点是:代码虽然一样,运行代码的环境却不一样。

Docker 如何解决这些问题

Docker 的思路很直接:把应用和它需要的运行环境一起整理好,再放到不同电脑或服务器上运行。

刚开始只需要记住三个词:

  • Dockerfile:记录应用需要什么环境、怎样安装依赖、怎样启动;
  • 镜像(Image):按照 Dockerfile 构建出来的完整运行模板;
  • 容器(Container):镜像真正启动后,正在运行的应用。

整个过程如下:

对应到命令只有两步:

bash 复制代码
# 第一步:读取 Dockerfile,把项目构建成镜像
docker build -t my-app .

# 第二步:使用镜像创建并启动容器
docker run my-app

可以把它理解成:

text 复制代码
准备一份可以重复执行的环境说明
            ↓
得到一份可以重复使用的应用镜像
            ↓
在开发电脑、测试服务器或生产服务器上启动容器

这样,不同环境使用同一个镜像时,应用所需的 Node.js、依赖和启动方式也会保持一致,从而减少"我的电脑可以运行,换一台电脑就不行"的问题。

在本文的前后端项目中,React、NestJS 和 PostgreSQL 会分别运行在自己的容器中。后面会逐步解释怎样构建这些镜像,以及怎样让这些容器一起工作。

Docker 与虚拟机

虚拟机

虚拟机通常包含一套完整的客户操作系统:

text 复制代码
物理机
└── 宿主操作系统
    └── 虚拟机软件
        ├── 虚拟机 A:完整操作系统 + 应用
        └── 虚拟机 B:完整操作系统 + 应用

优点是隔离较完整;代价是体积大、启动慢、资源占用高。

容器

容器主要隔离应用进程、文件系统、网络和资源,但共享宿主机内核:

text 复制代码
物理机
└── 宿主操作系统内核
    └── Docker Engine
        ├── 容器 A:应用及其用户空间依赖
        └── 容器 B:应用及其用户空间依赖

因此容器通常:

  • 比虚拟机更小;
  • 启动更快;
  • 更适合交付单个应用或服务。

Docker 的主要组件

Docker 工作时主要涉及三个组件:

  1. Docker Client:接收我们输入的命令;
  2. Docker daemon:在后台执行具体操作;
  3. Registry:在远程保存和分发镜像。

它们之间的关系如下:

Docker Client:接收操作

Docker Client 是我们操作 Docker 的入口。平时在终端里执行的 docker 命令,都属于 Client:

bash 复制代码
# 查看正在运行的容器
docker ps

# 根据 Dockerfile 构建镜像
docker build -t my-app .

# 使用 nginx 镜像创建并启动容器
docker run nginx

Client 接收到命令后,会把请求发送给 Docker daemon。镜像构建和容器启动等工作不是 Client 自己完成的。

Docker Desktop 在这里提供了两种操作入口:

  • Docker CLI:在终端中输入 docker ... 命令;
  • Docker Desktop 图形界面:通过按钮查看和操作镜像、容器、数据卷。

这两个入口最终都会让后台的 Docker daemon 执行具体操作。

Docker daemon:在后台执行操作

Docker daemon 是持续运行的后台服务,也可以把它理解成 Docker 真正干活的部分。

它负责:

  • 拉取和保存镜像;
  • 根据 Dockerfile 构建镜像;
  • 创建、启动、停止和删除容器;
  • 管理容器网络与数据卷。

在电脑上打开 Docker Desktop 时,Docker Desktop 会同时启动并管理 Docker Engine,Docker daemon 就在后台工作。

因此,在电脑终端中虽然可以找到 docker 命令,但 Docker Desktop 没有启动时,Client 无法连接后台 daemon,命令便可能出现:

text 复制代码
Cannot connect to the Docker daemon

Registry:远程镜像仓库

Registry 是保存镜像的远程仓库。这里记住一个公式即可:

bash 复制代码
# 命名空间:用户名 | 团队名
仓库镜像名 = [仓库地址/]命名空间/镜像名:版本
上传位置 仓库镜像名示例
Docker Hub copyer/my-app:1.0
公司 Registry registry.example.com/copyer/my-app:1.0

对应的指令也是同一套:

bash 复制代码
# 登录 Docker Hub;Docker Hub 是默认仓库,所以不需要写仓库地址
docker login

# 给本地 my-app:1.0 镜像添加 Docker Hub 仓库名称
docker tag my-app:1.0 copyer/my-app:1.0

# 上传到 Docker Hub 的 copyer/my-app 仓库
docker push copyer/my-app:1.0

# 从 Docker Hub 的 copyer/my-app 仓库下载
docker pull copyer/my-app:1.0

仓库地址省略时,Docker 默认使用 Docker Hub;写上公司 Registry 地址时,就使用公司仓库。

镜像的完整发布过程如下:

一条 Docker 命令的执行过程

执行:

bash 复制代码
docker run nginx

背后会依次发生:

  1. Docker Client 接收 docker run nginx
  2. Client 把请求发送给 Docker daemon;
  3. daemon 检查本地是否已经存在 nginx 镜像;
  4. 如果本地没有,daemon 从 Registry 下载镜像;
  5. daemon 根据镜像创建并启动容器;
  6. Client 把执行结果显示在终端中。

安装 Docker

安装

使用场景 推荐安装
macOS / Windows Docker Desktop
Linux 服务器 Docker Engine + Docker Compose plugin

对于刚开始学习 Docker 的前端开发者,直接安装 Docker Desktop 最方便。它已经包含 Docker Engine、Docker 命令、Docker Compose 和图形管理界面。

Linux 服务器通常没有桌面环境,因此安装 Docker Engine 和 Docker Compose plugin 即可。

具体安装步骤根据自己的操作系统自行搜索,或者直接查看 Docker 官方安装文档。安装完成并启动 Docker 后,执行下面的命令验证:

bash 复制代码
# 查看 Docker 是否可以正常连接
docker version

# 查看 Docker Compose 是否已经安装
docker compose version

# 运行官方测试容器;--rm 表示运行结束后自动删除容器
docker run --rm hello-world

如果 hello-world 能正常输出成功信息,说明 Docker 已经可以使用。

Docker 核心概念

Image:镜像

镜像是只读模板,包含应用运行需要的文件系统内容和元数据。

例如 node:24-alpine 大致表示:

  • node:镜像仓库名;
  • 24-alpine:标签,表示 Node.js 24 的 Alpine Linux 变体。
bash 复制代码
# 下载镜像,但不创建容器
docker pull node:24-alpine

# 查看本地镜像
docker image ls

同一个镜像可以在开发机、CI 和服务器上重复创建一致的容器。Docker 会根据镜像中保存的文件、依赖和启动配置创建容器。

注意: 标签不是绝对不可变标识,生产环境最好使用明确版本,关键环境还可以固定镜像 digest。不要只依赖 latest

Container:容器

容器是镜像运行后的实例,本质上仍是宿主机上的隔离进程。

bash 复制代码
# 创建并后台运行一个 Nginx 容器
docker run --name web -d -p 8080:80 nginx:1.28-alpine

参数逐项解释:

  • --name web:把容器命名为 web,方便后续按名称操作;不写时 Docker 随机生成名称。
  • -d:detached,后台运行;不写时终端会附着到容器主进程。
  • -p 8080:80:宿主机 8080 端口映射到容器 80 端口。
  • nginx:1.28-alpine:用于创建容器的镜像。

一个镜像可以创建多个互不相同的容器,就像同一个 JavaScript class 可以创建多个实例。

镜像本身只是模板,不会主动运行。使用镜像创建容器后,应用进程才会真正启动。

容器可写层中的数据通常会随容器删除,因此数据库数据不能只保存在容器可写层中,后面会使用 volume 单独保存。

Dockerfile:镜像制作说明书

Dockerfile 是文本文件,描述如何从基础镜像构建自己的镜像。

dockerfile 复制代码
# 使用 Node.js 24 Alpine 镜像作为基础
FROM node:24-alpine

# 后续命令默认在 /app 中执行;目录不存在时会创建
WORKDIR /app

# 把宿主机构建上下文中的 package.json 复制进镜像
COPY package.json ./

# 在构建镜像时执行依赖安装
RUN npm install

# 3000 是容器内部端口,表示应用运行后会监听这个端口
# EXPOSE 只起说明作用,不会自动把容器端口开放给本机
# 镜像构建完成后,可在终端执行下面的命令完成端口映射:
# docker run -p 8080:3000 my-app
# 含义:本机 8080 端口 → 容器 3000 端口
EXPOSE 3000

# 容器启动时执行的默认命令
CMD ["npm", "run", "start"]

Dockerfile 可以理解成一份"创建镜像的步骤清单"。Docker 会按照文件中的顺序准备 Node.js、复制项目、安装依赖并设置启动命令。把这份文件交给其他开发者或服务器,Docker 仍然会执行相同的步骤,不需要每个人重新手动配置环境。

Layer:镜像层与构建缓存

Dockerfile 中的 FROMRUNCOPY 等通常形成镜像层。构建时,如果某一步及其输入没有变化,Docker 可以复用缓存。

因此 Node.js 项目通常这样写:

dockerfile 复制代码
# 先复制依赖清单,使业务代码变化不会影响依赖安装层
COPY package.json package-lock.json ./

# 缓存 npm 的下载目录;必须重新安装依赖时可以减少重复下载
RUN --mount=type=cache,target=/root/.npm npm ci

# 最后复制经常变化的业务代码
COPY . .

这里同时使用了两种缓存策略:

缓存策略 什么时候发挥作用
Docker 镜像层缓存 package.jsonpackage-lock.json 没变化时,直接复用 RUN npm ci 的结果,完全不重新安装依赖。两次 COPY 就是为了保护这一层缓存。
BuildKit 的 npm 下载缓存 依赖清单变化后,npm ci 必须重新执行,但 /root/.npm 中已经下载过的包可以继续使用,只下载新增或变化的包。

可以简单记成:镜像层缓存负责"尽量不重新安装",npm 下载缓存负责"必须重新安装时尽量少下载"。如果一开始就 COPY . .,任何业务代码变化都会让后面的 npm ci 镜像层缓存失效。

Build context:构建上下文

bash 复制代码
docker build -t my-api:1.0 -f backend/Dockerfile ./backend

逐项解释:

  • docker build:构建镜像;
  • -t my-api:1.0:设置镜像名称和标签;
  • -f backend/Dockerfile:指定 Dockerfile 路径;
  • ./backend:构建上下文。

构建上下文明确了哪些本地文件可以参与构建,并把这些文件交给构建器。上下文包含的文件过多时,构建会变慢,还可能把日志、依赖或敏感文件传给构建器。

.dockerignore

.dockerignore 用于从构建上下文排除不需要的文件:

dockerignore 复制代码
# 本地依赖不能复制进 Linux 镜像,且体积很大
node_modules

# 本地旧构建结果应由镜像自己重新构建
dist

# 版本控制文件对运行应用没有必要
.git

# 本地环境变量可能包含密码或密钥
.env
.env.*

# 日志和测试输出不应进入镜像
*.log
coverage

# 操作系统和编辑器文件
.DS_Store
.vscode
.idea

.dockerignore 可以减小构建上下文、加快构建,并降低泄露本地敏感文件的风险。否则,COPY . . 可能把 node_modules.git.env 等无关甚至敏感内容一起复制进镜像。

Port:端口映射

NestJS 虽然已经在容器的 3000 端口运行,但浏览器是在我们自己的电脑上,暂时还访问不到容器里面的应用。

bash 复制代码
# 把本机 3001 端口连接到容器 3000 端口
docker run --rm -p 3001:3000 my-api:1.0

-p 3001:3000 可以理解成给本机和容器之间接了一条通道:

text 复制代码
浏览器访问 localhost:3001
            ↓
Docker 转发请求
            ↓
容器中的 NestJS 3000 端口

其中左边的 3001 是本机端口,右边的 3000 是容器端口。配置完成后,在浏览器访问 http://localhost:3001,就能访问容器中的 NestJS。

EXPOSE 3000 只是说明应用使用容器的 3000 端口;-p 3001:3000 才是真正建立端口映射。

Bind mount:让容器直接使用本机文件

容器默认看不到本机的项目文件。Bind mount 可以把本机项目连接到容器中的一个目录:

text 复制代码
本机项目目录  →  容器中的 /app

在本机进入项目根目录,然后执行:

bash 复制代码
# 启动临时 Node.js 容器,并查看容器 /app 中的文件
docker run --rm \
  --mount type=bind,source="$(pwd)",target=/app \
  node:24-alpine \
  ls /app

核心关键:

指令部分 具体作用
--mount 给容器添加挂载。
type=bind 表示把本机目录挂载到容器。
source="$(pwd)" 本机目录。$(pwd) 表示终端当前所在的项目目录。
target=/app 容器目录。本机项目会出现在容器的 /app 中。

连接成功后,本机和容器看到的是同一套项目文件。你在编辑器中修改代码,容器马上就能看到,不需要重新构建镜像。

Volume:命名数据卷

数据库容器可以删除后重新创建,但数据库中的数据不能跟着消失。Volume 可以理解成一个由 Docker 管理的独立存储空间:

text 复制代码
PostgreSQL 容器  →  postgres_data 数据卷
     删除              继续保留

先创建一个名为 postgres_data 的数据卷:

bash 复制代码
docker volume create postgres_data

查看已经创建的数据卷:

bash 复制代码
docker volume ls

启动 PostgreSQL,并把数据库的数据目录连接到这个数据卷:

bash 复制代码
docker run --name db \
  -e POSTGRES_PASSWORD=example \
  --mount type=volume,source=postgres_data,target=/var/lib/postgresql/data \
  -d postgres:17-alpine

核心指令:

指令部分 具体作用
docker volume create postgres_data 创建一个名为 postgres_data 的数据卷。
docker volume ls 查看本机现有的数据卷。
--mount 给容器添加挂载。
type=volume 表示挂载 Docker 数据卷。
source=postgres_data 使用名为 postgres_data 的数据卷。
target=/var/lib/postgresql/data PostgreSQL 在容器中保存数据的位置。写入这里的数据会进入数据卷。

PostgreSQL 写入 /var/lib/postgresql/data 的内容,实际会保存在 postgres_data 中。以后即使删除原来的 PostgreSQL 容器,只要新容器继续挂载 postgres_data,就能继续使用原来的数据。

Volume 负责让数据不随容器一起删除,但它不是备份。删除 Volume 本身,数据仍然会丢失。

Environment variable:环境变量

bash 复制代码
docker run --rm \
  -e NODE_ENV=production \
  -e PORT=3000 \
  my-api:1.0

环境变量让同一个镜像在不同环境中使用不同配置,而不必修改镜像。

开发、测试、生产的端口和数据库地址通常不同。通过环境变量可以为同一个镜像传入不同配置,避免把配置写死在代码中并为每个环境重新构建镜像。

环境变量不是天然安全的秘密存储。不要把真实密码写进 Git;生产环境应使用部署平台的 secret 管理能力。

Network:容器网络

每个容器都可以理解成一台独立的小电脑。NestJS 容器和 PostgreSQL 容器默认不知道对方在哪里,需要先加入同一个 Docker 网络。

创建并查看网络:

bash 复制代码
# 创建一个名为 `app-network` 的网络
docker network create app-network

# 查看本机现有的 Docker 网络
docker network ls

启动容器时,通过 --network 把它们加入同一个网络:

bash 复制代码
# 把 db 加入到 app-network 网络中
docker run --name db \
  --network app-network \
  -e POSTGRES_PASSWORD=example \
  -d postgres:17-alpine

# 把 api 加入到 app-network 网络中
docker run --name api \
  --network app-network \
  -d my-api:1.0

下图把容器内部访问和本机浏览器访问放在一起对比:

图中的 dbapi 来自启动命令中的 --name。它们只能在 Docker 网络内部使用,因此本机浏览器不能直接访问 api:3000

使用 Docker Compose 时,执行 docker compose up 会自动为项目创建网络。apidb 等 Compose 服务名会自动成为网络中的访问名称,因此通常不需要手动执行 docker network create

Docker Compose

前面的示例需要分别执行多条 docker run 命令,才能启动前端、后端和数据库。Docker Compose 可以把这些容器的配置集中写进一个 compose.yaml 文件,然后统一启动和停止。

可以先记住这个关系:

text 复制代码
compose.yaml       → 记录项目需要哪些容器
docker compose up  → 按照文件内容启动这些容器

在项目根目录创建 compose.yaml

yaml 复制代码
services:
  frontend:
    build: ./frontend

  api:
    build: ./backend

  db:
    image: postgres:18-alpine

文件中的核心内容:

配置 具体作用
services 项目中的服务列表。一个服务通常会运行一个容器。
frontendapidb 三个服务的名称,也可以作为容器之间互相访问的名称。
build: ./frontend 使用 frontend 目录中的 Dockerfile 构建前端镜像。
build: ./backend 使用 backend 目录中的 Dockerfile 构建后端镜像。
image: postgres:18-alpine 数据库不需要自己构建,直接使用现成的 PostgreSQL 镜像。

Docker 常用操作

针对 docker 指令了解即可

镜像命令

bash 复制代码
# 拉取镜像;不启动容器
docker pull nginx:1.28-alpine

# 列出本地镜像
docker image ls

# 查看镜像的详细元数据
docker image inspect nginx:1.28-alpine

# 删除本地镜像;镜像仍被容器引用时可能无法删除
docker image rm nginx:1.28-alpine

容器生命周期命令

bash 复制代码
# 查看正在运行的容器
docker ps

# 查看所有容器,包括已经退出的容器
docker ps -a

# 停止容器,给主进程发送终止信号并等待
docker stop web

# 启动一个已经存在但处于停止状态的容器
docker start web

# 重启容器
docker restart web

# 删除已经停止的容器
docker rm web

# 强制删除仍在运行的容器;可能来不及优雅关闭,应谨慎使用
docker rm -f web

docker run 是"创建新容器并启动",docker start 是"启动已有容器"。反复执行同名的 docker run --name web ... 会因为名字已经存在而失败。

日志和排错命令

bash 复制代码
# 查看容器现有日志
docker logs api

# -f 表示持续跟随新日志;类似 tail -f
docker logs -f api

# 只看最后 100 行并继续跟随
docker logs --tail 100 -f api

# 查看容器完整配置、网络、挂载和状态
docker inspect api

# 查看容器资源使用情况
docker stats

# 在运行中的容器内执行 sh
# -i 保持标准输入;-t 分配终端
docker exec -it api sh

Alpine 镜像通常只有 sh,不一定安装 bash。因此 docker exec -it api bash 失败时应尝试 sh

docker exec 是进入已经运行的容器;docker run 会创建一个新容器,两者不能混为一谈。

数据卷和网络命令

bash 复制代码
# 查看数据卷
docker volume ls

# 查看某个数据卷的详细信息
docker volume inspect postgres_data

# 删除未被容器使用的数据卷;删除前必须确认数据是否需要
docker volume rm postgres_data

# 查看 Docker 网络
docker network ls

# 查看网络中的容器和地址
docker network inspect 项目名_default

清理命令

bash 复制代码
# 清理未使用的停止容器、网络和悬空镜像
docker system prune

# 查看 Docker 占用磁盘情况
docker system df

不要在不了解影响时使用 docker system prune -a --volumes。它可能删除大量未使用镜像和数据卷,数据卷中可能有数据库数据。

容器化 React + NestJS 项目

React + NestJS + PostgreSQL 项目结构是这样的:

如果项目使用 pnpm 或 yarn,需要把 npm ci、锁文件和启动命令替换成对应包管理器的写法,不能混用锁文件。

NestJS 容器化准备

让 NestJS 监听容器网络

backend/src/main.ts

typescript 复制代码
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'

async function bootstrap() {
  // 创建 NestJS HTTP 应用。
  const app = await NestFactory.create(AppModule)

  // 所有后端接口统一带 /api 前缀。
  // 例如 UsersController 的 /users 最终会成为 /api/users。
  // 这样 Nginx 和 Vite 可以统一代理 /api,不会与前端页面路由混淆。
  app.setGlobalPrefix('api')

  // 如果开发时浏览器直接跨域访问后端,需要配置 CORS。
  // 本文优先让浏览器请求同源 /api,再由 Vite/Nginx 代理,因此生产环境通常不依赖宽松 CORS。
  // 如果确实需要跨域,应明确列出允许的 origin,避免无条件开放。
  if (process.env.NODE_ENV !== 'production') {
    app.enableCors({
      origin: 'http://localhost:5173',
      credentials: true,
    })
  }

  // 环境变量本质是字符串,所以要转换成 number。
  // 没有传 PORT 时使用 3000。
  const port = Number(process.env.PORT ?? 3000)

  // 0.0.0.0 表示监听容器的所有网络接口。
  // 如果只监听 127.0.0.1,通常只有容器内部自己能访问,其他容器和宿主机端口映射可能无法连接。
  await app.listen(port, '0.0.0.0')
}

bootstrap()

添加健康检查接口

backend/src/health.controller.ts

typescript 复制代码
import { Controller, Get } from '@nestjs/common'

@Controller('health')
export class HealthController {
  @Get()
  check() {
    // 返回简单结果,供 Docker 或负载均衡器判断 HTTP 服务是否可响应。
    // 更完整的项目可以继续检查数据库、Redis 等关键依赖。
    return {
      status: 'ok',
      timestamp: new Date().toISOString(),
    }
  }
}

backend/src/app.module.ts 注册:

typescript 复制代码
import { Module } from '@nestjs/common'
import { HealthController } from './health.controller'

@Module({
  // Controller 必须注册到模块,NestJS 才会创建对应路由。
  controllers: [HealthController],
})
export class AppModule {}

由于设置了全局前缀,健康检查地址是 /api/health

健康检查用于判断应用是否真的可以接收请求。容器进程存在时,NestJS 可能仍在启动,数据库连接也可能已经失败,因此只查看容器是否运行并不够。

不提供健康检查时:Compose 只能大致知道容器是否运行,无法判断业务服务是否真的可用。

构建 React 生产镜像

Vite 项目执行 npm run build 后,默认生成 dist 静态文件。生产环境不需要 Vite 开发服务器,也不需要把完整 Node.js 开发环境暴露给用户。

为什么使用多阶段构建

前端构建需要 Node.js、npm 和完整依赖,但运行构建结果只需要一个静态文件服务器。

text 复制代码
builder 阶段:Node.js + npm + node_modules + 源码 → 生成 dist
runner 阶段:Nginx + dist → 最终镜像

如果直接用 Node.js 开发镜像上线:

  • 镜像更大;
  • 包含不必要的源码和开发依赖;
  • 攻击面更大;
  • Vite dev server 也不是这里的生产静态服务器方案。

React 生产 Dockerfile

frontend/Dockerfile

dockerfile 复制代码
# syntax=docker/dockerfile:1

# ---------- 第一阶段:构建 React ----------
FROM node:24-alpine AS builder

WORKDIR /app

# 构建参数只在构建过程中使用。
# Vite 会把 VITE_ 前缀变量写入浏览器可下载的 JS,因此绝对不能放密钥。
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

COPY package.json package-lock.json ./

# 使用 BuildKit 缓存 npm 下载目录,加快重复构建。
# 这不是把 node_modules 从宿主机带进镜像,而是缓存下载内容。
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .

# 执行 Vite 生产构建,默认输出 /app/dist。
RUN npm run build

# ---------- 第二阶段:运行静态站点 ----------
# 使用官方 Docker 指南采用的非特权 Nginx 镜像,默认使用非 root 用户。
FROM nginxinc/nginx-unprivileged:alpine3.22 AS runner

# 替换 Nginx 配置,加入 SPA history fallback 和 /api 代理。
COPY nginx.conf /etc/nginx/nginx.conf

# 只从 builder 复制最终静态文件,不复制 node_modules 和源码。
# --chown 确保 Nginx 用户拥有正确文件权限。
COPY --chown=nginx:nginx --from=builder \
  /app/dist /usr/share/nginx/html

# 明确使用非 root 用户运行。
USER nginx

# 非特权用户不能随意绑定 1024 以下端口,因此使用 8080。
EXPOSE 8080

# Nginx 默认会转入后台;daemon off 让它留在前台作为容器主进程。
# 主进程退出时,容器就应该退出,Docker 才能正确管理状态。
CMD ["nginx", "-g", "daemon off;"]

Nginx 配置

frontend/nginx.conf

nginx 复制代码
# Nginx 事件模块,基础配置必须存在。
events {}

http {
  # 根据文件扩展名返回正确 Content-Type,例如 JS、CSS、SVG。
  include /etc/nginx/mime.types;

  server {
    # 非 root Nginx 在容器中监听 8080。
    listen 8080;

    # Vite 构建产物复制到了这个目录。
    root /usr/share/nginx/html;
    index index.html;

    # 后端 API 反向代理。
    location /api/ {
      # api 是 Compose 服务名;这里没有尾部 /,因此原始 /api/... 路径会保留。
      proxy_pass http://api:3000;

      # 把用户请求的 Host 和客户端地址传给 NestJS,便于日志与生成链接。
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
    }

    # React Router history 模式支持。
    location / {
      # 先找真实文件或目录;找不到时返回 index.html,让 React Router 接管路由。
      try_files $uri $uri/ /index.html;
    }

    # 带内容哈希的静态资源通常可以长期缓存。
    # 如果你的构建工具不生成内容哈希,需要调整缓存策略。
    location /assets/ {
      try_files $uri =404;
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
  }
}

如果没有 try_files ... /index.html,直接刷新 /users/1 这样的前端路由时,Nginx 会尝试寻找真实文件 /users/1,最终返回 404。

构建 NestJS 生产镜像

backend/Dockerfile

dockerfile 复制代码
# syntax=docker/dockerfile:1

# ---------- 第一阶段:安装依赖并构建 ----------
FROM node:24-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./

# 构建 NestJS 需要 devDependencies,例如 TypeScript 和 Nest CLI。
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .

# 把 TypeScript 编译为 JavaScript,通常输出到 dist。
RUN npm run build

# 构建已经完成,删除 devDependencies,只保留生产运行依赖。
# 如果应用运行时错误地依赖了 devDependency,这一步会暴露项目依赖分类问题。
RUN npm prune --omit=dev

# ---------- 第二阶段:运行 NestJS ----------
FROM node:24-alpine AS runner

# 告诉 NestJS 及依赖当前是生产环境。
ENV NODE_ENV=production

WORKDIR /app

# 复制生产依赖和编译结果,不复制 TypeScript 源码与开发工具。
COPY --chown=node:node --from=builder /app/package.json ./package.json
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist

# Node 官方镜像已经提供低权限 node 用户。
USER node

EXPOSE 3000

# 直接运行编译后的 JavaScript,不在生产容器里调用 Nest CLI。
CMD ["node", "dist/main.js"]

使用 Docker Compose

Dockerfile 描述"一个镜像如何构建",compose.yaml 描述"多个服务如何一起运行"。

Compose 配置模型

Compose 使用 YAML 书写,但能使用哪些属性由 Compose Specification 规定,并不是随意命名的。作为前端开发者,可以先通过下面这个接近 JSON 结构的 JavaScript 对象认识常用属性:

javascript 复制代码
// 这是帮助理解配置结构的伪代码,不需要在项目中创建这个 JS 文件。
const composeConfig = {
  // services 是固定属性,里面放项目需要运行的服务。
  services: {
    // 服务名由项目自定义,例如 frontend、api、db。
    "<服务名>": {
      // 直接使用已有镜像。
      image: "镜像名称:版本",

      // 使用项目中的 Dockerfile 构建镜像。
      build: {
        context: "构建目录",
        dockerfile: "Dockerfile 文件名",
        args: {
          "<构建参数名>": "构建参数值"
        }
      },

      // 把宿主机端口映射到容器端口。
      ports: ["宿主机端口:容器端口"],

      // 把环境变量传给运行中的容器。
      environment: {
        "<环境变量名>": "环境变量值"
      },

      // 检查容器中的服务是否真正可用。
      healthcheck: {
        test: ["CMD", "检查命令"],
        interval: "10s", // 每隔多久检查一次
        timeout: "5s", // 单次检查最多等待多久
        retries: 5, // 连续失败多少次后标记为 unhealthy
        start_period: "20s" // 容器启动缓冲时间
      },

      // 声明当前服务依赖哪个服务。
      depends_on: {
        "<依赖服务名>": {
          // 等依赖服务的 healthcheck 通过后再启动。
          condition: "service_healthy"
        }
      },

      // 挂载宿主机目录或命名数据卷。
      volumes: ["数据来源:容器目录"],

      // 容器退出后的重启策略。
      restart: "unless-stopped"
    }
  },

  // 声明 services 中使用的命名数据卷。
  volumes: {
    "<数据卷名称>": {}
  }
}

完整属性可以查询 Docker 官方的 Compose 文件配置索引Services 属性列表

Compose 服务连接

执行 docker compose up 时,Compose 会为项目创建一个默认网络,并把 services 中的服务加入这个网络。服务名同时成为网络内部的访问名称:

因此,容器之间使用的是"服务名:容器端口",不需要查询可能变化的容器 IP。ports 只在需要把某个容器端口发布到宿主机时使用,容器之间通信不依赖 ports

完整的 compose.yaml

yaml 复制代码
services:
  # 前端服务:构建 React,并使用 Nginx 提供静态文件和 API 代理。
  frontend:
    build:
      # 构建目录;该目录会成为 Docker 构建上下文。
      context: ./frontend
      dockerfile: Dockerfile
      args:
        # 传给前端 Dockerfile 的 ARG,构建后的前端统一请求 /api。
        VITE_API_BASE_URL: /api

    ports:
      # 宿主机 8080 端口 → frontend 容器 8080 端口。
      - '8080:8080'

    depends_on:
      # 等 api 的 healthcheck 通过后再启动 frontend。
      api:
        condition: service_healthy

    restart: unless-stopped

  # 后端服务:运行 NestJS,并通过 db:5432 连接 PostgreSQL。
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile

    environment:
      NODE_ENV: production
      PORT: 3000

      # db 是下面的服务名,5432 是 PostgreSQL 的容器端口。
      # ${...} 从 compose.yaml 同目录的 .env 文件读取。
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}

    depends_on:
      # 等数据库可以正常连接后再启动 api。
      db:
        condition: service_healthy

    healthcheck:
      # 在 api 容器内部请求 NestJS 健康检查接口。
      test: ['CMD', 'node', '-e', "fetch('http://localhost:3000/api/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"]
      interval: 10s # 每隔 10 秒检查一次
      timeout: 5s # 单次检查最多等待 5 秒
      retries: 5 # 连续失败 5 次后标记为 unhealthy
      start_period: 20s # 启动后的前 20 秒属于初始化时间

    restart: unless-stopped

  # 数据库服务:直接使用 PostgreSQL 官方镜像。
  db:
    image: postgres:18-alpine

    environment:
      # PostgreSQL 首次启动时使用这些值创建数据库和用户。
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

    volumes:
      # postgres_data 数据卷 → PostgreSQL 的数据目录。
      - postgres_data:/var/lib/postgresql

    healthcheck:
      # $$ 把 $ 保留到容器运行时,再读取容器中的环境变量。
      test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
      interval: 10s
      timeout: 5s
      retries: 5

    restart: unless-stopped

# 创建 db 服务使用的命名数据卷。
volumes:
  # {} 表示没有额外配置,使用 Docker 默认方式创建和管理该数据卷。
  postgres_data: {}

depends_on 只负责启动等待,不会替应用建立业务连接。真正让 Nginx 找到 NestJS 的,是 frontend/nginx.conf 中的代理地址:

nginx 复制代码
location /api/ {
    # api 是 Compose 服务名,3000 是 NestJS 的容器端口。
    proxy_pass http://api:3000;
}

运行与检查

bash 复制代码
# 校验配置,但不启动容器;没有输出表示配置通过。
docker compose config --quiet

# 构建镜像,并在后台启动全部服务。
docker compose up -d --build

服务启动后,通过 http://服务器地址:8080 访问前端。

Compose 常用命令

bash 复制代码
# 查看全部服务的运行状态和健康状态
docker compose ps

# 持续查看全部服务的日志;按 Ctrl + C 退出查看
docker compose logs -f

# 只持续查看 api 服务的日志
docker compose logs -f api

# 重启 api 服务;不会重新构建镜像
docker compose restart api

# 进入正在运行的 api 容器
docker compose exec api sh

# 停止并删除项目容器和网络;命名数据卷仍然保留
docker compose down

总结

bash 复制代码
# 验证安装
docker version
docker compose version
docker run --rm hello-world

# 镜像
docker pull nginx:1.28-alpine
docker image ls
docker build -t my-app:1.0 .

# 容器
docker ps
docker ps -a
docker logs -f 容器名
docker exec -it 容器名 sh
docker stop 容器名
docker rm 容器名

# Compose
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs --tail 100 -f
docker compose down

# 磁盘和资源
docker stats
docker system df
docker volume ls
docker network ls

最后记住一句话:Docker 镜像负责"交付什么",容器负责"怎么运行这个交付物",Compose 负责"多个容器怎样一起工作"。

相关推荐
刘马想放假2 小时前
Docker 从入门到进阶:一篇写给工程师的系统化实战指南
运维·docker·容器
zjun30213 小时前
如何搭建TRAE IDE 连接到容器开发环境
docker·容器·ascend·trae
寒冰碧海3 小时前
大模型部署从0到1(一):通用环境全流程准备|NVIDIA驱动+Docker+NVIDIA Container Toolkit
运维·docker·容器
hdsoft_huge5 小时前
SpringBoot 系列 20:Jar 打包 + Docker 容器化 + Compose 一键部署|服务器上线 & 零停机平滑升级全教程
spring boot·docker·jar
你想考研啊5 小时前
七、Docker 容器 + Docker Compose单机部署理发店系统
docker·容器·composer
梦梦代码精5 小时前
BuildingAI:企业级开源智能体搭建平台的破局者
前端·docker·架构·开源·代码规范
未来可期丶7 小时前
【开发环境】Windows+Docker Desktop+WSL2最佳实践
windows·docker·docker desktop·wsl2
AOwhisky19 小时前
下一代容器来了?Docker 宣布原生支持 WebAssembly
java·运维·docker·容器·rust·wasm
念何架构之路1 天前
Docker 容器状态机 学习
java·学习·docker