如果你曾经开发过Node.js项目,那你肯定遇到过这样的情况:项目在本地运行得很好,但一旦上传到服务器上,就会出问题。

可能是使用了不同版本的Node.js,也可能是某个环境变量缺失了,又或者系统依赖项不匹配。你可能会花一个小时去调试那些实际上根本与代码无关的问题。

Docker从根源上解决了这些问题。使用Docker,你传输的不再仅仅是代码本身——Node版本、依赖项以及配置信息都会被包含在容器中。无论是在笔记本电脑上、CI服务器上,还是生产环境中,程序的表现都是一样的。再也不会有因为环境差异而导致的意外问题了。

在本教程中,我们将一步步带你了解如何使用Docker:从编写多阶段的Dockerfile开始,到使用Docker Compose搭配PostgreSQL进行本地开发,再到通过GitHub Actions在每次将代码合并到main分支时自动将新的镜像推送到Docker Hub。

本教程的完整代码可以在GitHub上找到。

目录

  1. 先决条件

  2. 示例应用

  3. 编写Dockerfile

  4. .dockerignore文件

  5. .gitignore文件

  6. 在本地构建并测试镜像

  7. 使用Docker Compose进行本地开发

  8. 使用GitHub Actions自动化构建过程

  9. 部署镜像

  10. 总结

先决条件

  • Node.js 18及以上版本

  • Docker Desktop,你可以在docs.docker.com/get-docker下载它。Windows用户需要在安装Docker之前先安装WSL 2。以管理员身份打开PowerShell并运行wsl --install,重启后Docker Desktop就能正常安装了。

  • 一个GitHub账户

  • 一个Docker Hub账户(在hub.docker.com可以免费注册)

  • 具备一些Express.js的开发经验会更有帮助,但不是必需的

示例应用

我们将使用Express和PostgreSQL来构建一个任务管理API。请注意,这个应用程序仅仅是一个用来讲解相关原理的工具而已。我们在这里设置的Dockerfile和开发流程,对于任何Node.js项目来说都是通用的。

创建项目:

mkdir nodejs-docker-cicd && cd nodejs-docker-cicd
npm init -y
npm install express pgdotenv
npm install --save-dev nodemon

创建 `src/index.js` 文件:

const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// 启动时创建表
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// 健康检查——Docker HEALTHCHECK和负载均衡器需要这个功能
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) => {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) => {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: '必须提供标题' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) => {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: '任务未找到' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`服务器运行在端口 ${PORT}`));

打开 `package.json` 文件,更新 `"scripts"` 部分:

"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}

npm start 直接使用 Node 运行应用程序。npm run dev 会使用 nodemon,因此当你编辑文件时,服务器会自动重新启动。

如果不需要使用 Docker,可以创建一个 `.env` 文件:

DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000

请注意,所有的数据库凭据都是从环境变量中获取的,而不是硬编码在代码中的。只需更改这些环境变量的值,同样的应用程序就可以在本地数据库或生产环境中运行,而无需修改任何代码。/health 端点是 Docker 用来检测应用程序是否正在正常处理请求的接口。

编写Dockerfile

在开始编写Dockerfile之前,有两个术语你会经常看到。镜像是指你的应用程序被打包后的、不可变的版本——包括Node运行环境、代码、依赖项等所有内容,全部整合在一个文件中。而容器则是这个镜像的运行实例。一个镜像可以生成多个容器,而且这些容器可以在任何机器上运行。

以下是我们将使用的Dockerfile示例:

# ── 第一阶段:安装依赖项 ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# 首先复制包文件——Docker会单独缓存这一层文件。
# 如果你只修改了源代码(而没有更改package.json文件),Docker在重新构建时就不会执行npm ci命令。
COPY package*.json ./
RUN npm ci

COPY . .


# ── 第二阶段:生成生产环境镜像 ───────────────────────────────────────────────
FROM node:18-alpine AS production

# 创建一个非root用户——在容器中以root身份运行存在安全风险
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# 只复制来自构建阶段的源代码文件(不包括node_modules或开发用文件)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker会每隔30秒检查一次容器的健康状况。如果连续三次检测失败,该容器就会被标记为不健康状态。
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]

这是一个多阶段构建流程。第一阶段(builder)会安装所有必要的组件,包括开发依赖项;第二阶段(production)则会重新开始构建过程,只复制应用程序运行所需的实际文件。像Nodemon这样的工具以及各种仅用于开发环境的配置文件,都不会被包含在最终的镜像中。

镜像大小的差异是显而易见的。node:18版本的Debian镜像大小超过了950MB,而使用node:18-alpine版本并且去掉了开发依赖项后,最终生成的镜像大小大约在150到200MB之间。较小的镜像意味着部署速度会更快。

在CI/CD流程中,选择使用npm ci而不是npm install是经过深思熟虑后的决定。npm ci会严格按照package-lock.json文件中指定的版本来进行安装,如果锁文件中的信息与package.json不一致,构建过程就会失败。这样,在任何机器上进行构建时,都会安装完全相同的依赖项版本,从而避免因为依赖项在夜间悄悄更新而导致的问题。

之所以要创建nodeuser这个用户账户,是因为容器默认是以root身份运行的。不过,这种设置也存在安全隐患——如果攻击者成功入侵了容器,他们也无法为所欲为。因此,使用非root用户可以有效地限制攻击者的权限。

.dockerignore文件

在开始构建之前,请先创建.dockerignore文件:

node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore

node_modules这个文件夹需要被排除在外。你本地安装的模块是针对你的操作系统编译的——macOS或Windows版本的二进制文件在Linux容器中是无法运行的。将它们排除在外意味着Docker在构建镜像时会重新下载适用于正确平台的模块。如果不这样做,要么你会把有问题的二进制文件复制到镜像中,要么就会浪费时间去上传数百兆字节的文件。

千万不要将.env文件放入镜像中。密码、API密钥这类敏感信息应该作为环境变量在运行时才被加载,而绝不应该被包含在镜像本身中。

.gitignore文件

在进行第一次提交之前,还有最后一件事需要处理:创建一个.gitignore文件。你肯定不希望node_modules.env这些文件夹被纳入版本控制范围:

node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/

在本地构建并测试镜像

首先打开Docker Desktop,稍等片刻。在Windows系统中,你会在任务栏上看到一个鲸鱼形状的图标,这个图标会在Docker引擎启动时不断闪烁。当图标停止闪烁后,就可以开始运行Docker命令了。如果你在引擎尚未启动之前就尝试运行Docker,会遇到如下错误:

ERROR: Error response from daemon: Docker Desktop is unable to start

如果出现了这种错误,请关闭Docker Desktop,然后以管理员权限打开PowerShell,运行wsl --update命令重新启动系统。接下来进入“控制面板”→“程序”→“启用或禁用Windows功能”,确保“Hyper-V”和“虚拟机平台”这两个选项都被选中。重启后,Docker Desktop应该就能正常使用了。

另外,还需要了解这样一种错误情况:

docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.

这意味着Docker Desktop可能没有安装,或者根本没有运行。你可以从“开始”菜单中打开它,然后等待一会儿。

现在来构建镜像:

docker build -t nodejs-docker-cicd:latest .

第一次构建镜像大约需要30秒的时间,因为Docker需要从互联网上下载node:18-alpine这个版本。一旦这个版本被缓存下来,后续的构建过程就会快很多。构建过程中的各个步骤会依次显示出来:

[+] 构建中…… 33.1秒(17/17) 完成
 => [构建阶段 1/5] 从docker.io/library/node:18-alpine下载镜像…… 20.9秒
 =>> [构建阶段 4/5] 运行npm ci命令…… 3.5秒
 =>> [生产环境配置阶段 5/7] 运行npm ci --only=production命令…… 3.2秒
 =>> [生产环境配置阶段 7/7] 更改文件所有者权限…… 3.2秒
 =>> 将构建结果导出为镜像…… 1.5秒
 => 给镜像命名…… 0.0秒

当看到“(17/17) 完成”这样的提示时,说明镜像已经构建完成了。接下来可以查看一下镜像的大小。

docker images nodejs-docker-cicd
镜像                          ID            磁盘占用空间    内容大小
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB

内容大小(47.5MB)是指被推送到Docker Hub时的压缩后的大小。磁盘占用空间(198MB)则是该镜像在本地磁盘上实际占用的空间。与体积为950MB以上的node:18 Debian镜像相比,就不难理解为什么使用Alpine基础镜像以及多阶段构建方法如此重要了。

在后续的构建过程中,Docker会重用已缓存的层。只要修改源代码文件而不更改package.json文件,那么执行npm ci命令时就会跳过这一步骤。因此,原本需要33秒才能完成的第一次构建过程现在只需3秒钟即可完成。

用于本地开发的Docker Compose

应用程序需要数据库。如果在本地安装PostgreSQL,那么每个克隆代码库的开发者都必须手动进行这一操作。而Docker Compose可以解决这个问题:只需要一个配置文件就能定义所有相关服务,而通过一条命令就可以启动这些服务。

创建文件docker-compose.yml

services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

有几点需要注意:DB_HOST被设置为postgres,这里指的是服务的名称,而不是localhost。同一Docker网络中的容器是通过服务名称来相互通信的。如果将localhost设置为此值,应用程序就会尝试连接自身。

depends_on配置中的condition: service_healthy这一条款会确保在Postgres通过健康检查之前,应用程序不会启动。如果不使用这一机制,应用程序会试图连接到尚未准备好的数据库,从而导致崩溃。健康检查会每隔5秒发送一次pg_isready命令进行检测;一旦收到肯定的响应,应用程序容器才会开始运行。

命名的卷postgres_data能够确保数据在系统重启后仍然得以保留。即使执行了docker compose down命令,数据也会被保留下来;如果想要彻底清除这些数据,可以添加--volumes参数。

现在来启动这两个服务:docker compose up --build

你会看到PostgreSQL首先开始初始化,随后应用程序也会启动。当日志中显示“服务器已在3000端口运行”时,说明整个系统已经准备就绪了。

打开第二个终端进行测试——第一个终端中让那些用于生成日志的命令继续运行。

Linux/macOS:

curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "学习Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health

Windows PowerShell: 在PowerShell中输入curl实际上会执行Invoke-WebRequest命令,而不是真正的curl工具。因此应该使用curl.exe。如果需要发送JSON数据,可以先将数据写入文件,然后再使用curl.exe进行传输:

'{"title": "学习Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health

@body.json前面加上反引号是必要的。否则PowerShell会将其视为分隔符,而不会将其作为文件名前缀传递给curl工具。

你应该会看到如下类似的响应结果:

# POST /tasks
{"id":1,"title":"学习Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"学习Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}

这个任务会在一个容器中与PostgreSQL进行交互,然后通过应用程序返回结果。在compose终端中输入Ctrl+C就可以停止这两个进程。

使用GitHub Actions自动化构建过程

既然这个镜像在本地可以正常运行,那么我们就应该不再手动执行这些操作了。

步骤1:创建Docker Hub访问令牌

前往hub.docker.com,然后选择“账户设置”→“安全”→“新建访问令牌”。请将权限设置为“读写”,因为仅限读取权限会导致推送操作失败。这个令牌只会出现一次,所以在关闭页面之前一定要将其复制下来。

安全提示:千万不要将这个令牌粘贴到聊天记录、电子邮件或提交信息中。如果不小心泄露了,请立即删除它,然后重新生成一个新的令牌。

步骤2:将访问令牌添加到GitHub仓库中

进入你的仓库设置页面,选择“秘密与变量”→“Actions”,然后添加以下内容:

  • DOCKERHUB_USERNAME — 你的Docker Hub用户名

  • DOCKERHUB_TOKEN — 请将刚才复制的令牌粘贴到这里,其他地方都不行

如果你遇到了“错误:需要输入用户名和密码”的提示,那可能是因为这些秘密信息还没有被保存下来,或者输入的名称有误。需要注意的是,这两个字段都是区分大小写的。

日志中出现的关于Node 20版本即将被弃用的警告是正常的。这个警告来自GitHub Actions的内部机制,并不是你的代码导致的。

步骤3:创建工作流文件

创建一个名为.github/workflows/docker-publish.yml的文件:

name: 构建并推送Docker镜像

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 设置Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: 登录Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: 提取元数据
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: 构建并推送镜像
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.metaoutputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

登录步骤中包含了if: github.event_name != 'pull_request'这一条件。这意味着在处理拉取请求时,系统会跳过认证环节。因为来自分支的拉取请求无法访问你的敏感信息,所以尝试登录会导致失败。不过,对于这些拉取请求,构建过程仍然会正常进行,以便验证你的Dockerfile内容,但镜像不会被推送出去。

metadata-action会在每次将代码合并到main分支时生成两个标签:latest以及一个类似于sha-a1b2c3d的短格式提交SHA值。正是这个SHA标签使得回滚操作成为可能。如果latest标签对应的镜像在生产环境中出现了问题,你可以立即拉取任何一个之前的sha-标签对应的镜像,从而在几秒钟内恢复到正常状态。

cache-from/cache-to: type=gha这一配置使得Docker的层缓存能够存储在GitHub Actions的内置缓存系统中。在首次构建时,所有内容都会从零开始重新生成;之后,如果某个层没有发生变化,系统就会直接从缓存中获取这些层,而不会再次进行重建。对于典型的Node.js应用程序来说,这种设置可以将构建时间从2到3分钟缩短到不到30秒。

推送镜像并观察其运行过程

git add .
git commit -m "添加Docker配置及GitHub Actions工作流"
git push origin main

前往你的仓库的Actions选项卡,你会看到工作流正在实时运行。每当某个步骤完成时,对应的状态栏就会显示为绿色:

✅ 检出代码
✅ 设置Docker Buildx
✅ 登录Docker Hub
✅ 提取元数据
✅ 构建并推送镜像

当所有步骤都显示为绿色时,说明你的镜像已经成功上传到Docker Hub,并且拥有了latest标签以及一个类似于sha-a1b2c3d的提交SHA值。从此以后,每次向main分支推送代码时,镜像都会自动被构建并发布。

部署镜像

将你的镜像上传到Docker Hub后,你可以将其部署到任何基础设施上:

任何VPS或服务器都适用:

docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest

Railway: 在Railway控制台中添加你的Docker Hub镜像,下次推送代码时它就会自动被部署。

Fly.io: 运行`fly launch`命令,并指定你的Dockerfile文件,其余步骤由Fly.io来完成。

Render: 将你的Docker Hub镜像URL输入到Render服务的配置中即可。

每次对“main”分支进行推送时,都会触发相应的部署流程。新的镜像会被上传到Docker Hub,然后平台会自动将其部署到目标环境中——整个部署过程完全由系统完成。

总结

最初只是一个本地运行的Node.js应用,现在它已经被封装在容器中。无论在什么机器上运行,它的行为都保持一致;开发环境使用的是真实的PostgreSQL数据库,而且整个构建流程会自动将生成的镜像上传到Docker Hub,你只需要完成推送操作即可。

多阶段构建机制有助于保持镜像的精简性——开发工具不会被包含在镜像中,非root用户也能正常使用该镜像,同时系统还内置了健康检查功能。对于任何克隆了该项目代码库的人来说,只需执行一个命令就能搭建完整的应用环境。由于每个GitHub Actions构建都会生成一个SHA标签,因此想要回退到之前的版本,只需要拉取对应的旧标签即可。

这些多阶段构建、使用Compose进行本地开发以及自动化发布镜像的做法,在整个行业范围内都被广泛用于生产环境的Node.js应用部署中。掌握这些技巧后,它们会伴随你参与每一个项目。

今后,你还可以进一步扩展这个部署流程:在构建之前添加测试步骤,或者为支持多平台环境而进行相应调整。当Docker Compose在生产环境中显得不够灵活时,通常就可以考虑使用Kubernetes来替代它了。

Comments are closed.