freeCodeCamp.org

How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions

8.5内容质量

TL;DR · AI 摘要

本文系统讲解如何用Docker容器化Node.js应用并结合GitHub Actions实现自动化部署,提供完整实践流程和代码示例。

核心要点

  • 使用多阶段构建Dockerfile可减少最终镜像体积达60%
  • Docker Compose支持本地开发环境与生产环境配置分离
  • GitHub Actions在main分支合并时自动推送镜像到Docker Hub

结构提纲

按章节快速跳转。

  1. 揭示环境不一致性问题及Docker解决方案的核心价值

  2. 详解多阶段构建技巧与镜像优化方法

  3. 演示自动化构建与镜像推送的完整工作流定义

  4. 通过Docker Compose实现PostgreSQL集成开发

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Node.js容器化部署
    • Docker实践
      • 多阶段构建
      • 镜像优化
    • CI/CD流程
      • GitHub Actions
      • 自动化部署
    • 开发环境
      • Docker Compose
      • PostgreSQL集成

金句 / Highlights

值得收藏与分享的关键句。

#Docker#GitHub Actions#Node.js#CI/CD
打开原文

如何使用 Docker 容器化 Node.js 应用并使用 GitHub Actions 部署

2026年7月14日

/

#Docker

Zia Ullah

如果你曾经构建过 Node.js 项目,可能经历过这样的场景。项目在你的机器上运行正常,但推送到服务器时却出现了问题。

可能是 Node 版本不同,可能是缺少环境变量,也可能是系统依赖项不匹配。你花了一个小时调试,结果发现这根本就不是代码问题。

Docker 从根源上解决了这个问题。使用 Docker 后,你不再只传输代码。Node 版本、依赖项和配置都会打包到容器中。无论是你的笔记本电脑、CI 服务器还是生产环境的虚拟机,它们的表现都完全一致。再也不用担心环境差异带来的意外问题。

在本教程中,我们将逐步讲解:多阶段构建的 Dockerfile、使用 Docker Compose 和 PostgreSQL 的本地开发配置,以及 GitHub Actions 工作流——每次合并到 main 分支时都会将新镜像推送到 Docker Hub。

本教程的完整代码可在 GitHub 上获取。

目录

  • 先决条件
  • 示例应用
  • 编写 Dockerfile
  • .dockerignore 文件
  • .gitignore 文件
  • 本地构建和测试镜像
  • 使用 Docker Compose 进行本地开发
  • 使用 GitHub Actions 自动化构建
  • 部署镜像
  • 总结

先决条件

  • Node.js 18+
  • Docker Desktop(可从 [docs.docker.com/get-docker](docs.docker.com/get-docker) 下载)。Windows 用户需要先安装 WSL 2,Docker 才能正常运行。以管理员身份打开 PowerShell 并运行 wsl --install。重启后,Docker Desktop 将无问题安装。
  • GitHub 账号
  • Docker Hub 账号([hub.docker.com](hub.docker.com) 提供免费注册)
  • 一些 Express.js 经验会有帮助,但不是必需的

示例应用

我们正在使用 Express 和 PostgreSQL 构建一个任务管理 API。请注意,这个应用只是一个教学工具,用于演示原理。这里设置的 Dockerfile 和流水线对任何 Node.js 项目都适用。

创建项目:

code
mkdir nodejs-docker-cicd && cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon

创建 src/index.js

code
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 });
  }
});
javascript
app.post('/tasks', async (req, res) => {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  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: 'Task not found' });
    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(`Server running on port ${PORT}`));
code
"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}

npm start 直接通过 Node 运行应用。npm run dev 使用 nodemon,当修改文件时服务器会自动重启。

不使用 Docker 运行时,创建 .env 文件:

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

注意所有数据库凭证都来自环境变量而非硬编码。只需替换变量,相同镜像即可连接本地数据库或生产环境数据库——无需修改代码。/health 端点是 Docker 用来检测应用是否正常处理请求的接口。

编写 Dockerfile

接触 Dockerfile 之前,需要了解两个术语。镜像是你应用的打包、不可变版本——Node 运行时、代码、依赖项,所有内容整合成一个 artifact。容器是该镜像的运行实例。一个镜像可以生成多个容器,运行在任意机器上。

我们将使用的 Dockerfile 如下:

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

WORKDIR /app

# 首先复制 package 文件——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

# 仅从 builder 阶段复制源代码(不复制 node_modules 或开发文件)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker 每 30 秒会 ping 一次 /health。如果失败 3 次,容器会被标记为不健康。
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 是有意为之。它会从 package-lock.json 读取精确版本,如果 lockfile 与 package.json 不匹配则会强制失败。每台机器上的每次构建都会安装完全相同的版本——避免因某个依赖项在夜间悄然更新而引发意外。

nodeuser 账户的存在是因为容器默认以 root 身份运行。这在出现问题之前是合理的。使用非 root 用户意味着攻击者即使入侵容器,也无法随意执行任意操作。

.dockerignore 文件

构建前请先创建 .dockerignore:

code
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:

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

本地构建和测试镜像

首先打开 Docker Desktop 并等待片刻。在 Windows 上,任务栏会出现鲸鱼图标,引擎启动时会进行动画显示。图标静止后即可运行 Docker 命令。如果在引擎启动前尝试运行 Docker,会出现以下错误:

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

如果出现此错误,请退出 Docker Desktop。以管理员身份打开 PowerShell,运行 wsl --update,然后重启。进入控制面板 → 程序 → 开启或关闭 Windows 功能,确保勾选 Hyper-V 和 Virtual Machine Platform。重启后 Docker Desktop 应该可以正常启动。

还需要了解另一个常见错误:

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

这意味着 Docker Desktop 没有运行或未安装。从开始菜单打开它并等待。

运行构建:

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

首次构建大约需要 30 秒,因为 Docker 需要从互联网拉取 node:18-alpine 镜像。一旦缓存建立,后续构建会快得多。两个阶段的构建过程会依次显示:

code
[+] Building 33.1s (17/17) FINISHED
 => [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 => [builder 4/5] RUN npm ci                                   3.5s
 => [production 5/7] RUN npm ci --only=production              3.2s
 => [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 => exporting to image                                         1.5s
 => => naming to docker.io/library/nodejs-docker-cicd:latest     0.0s

当看到 (17/17) FINISHED 时,镜像构建完成。检查镜像大小:

code
docker images nodejs-docker-cicd
code
镜像名称                  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 文件:

code
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 会阻塞应用启动,直到 PostgreSQL 真正通过健康检查。跳过这个设置的话,应用会启动后尝试连接尚未准备就绪的数据库,导致崩溃。健康检查每 5 秒向 pg_isready 发送一次请求。一旦收到成功响应,应用容器才会启动。

命名卷 postgres_data 可以在重启后保留数据。运行 docker compose down 后,下次启动数据依然存在。添加 --volumes 参数可清除数据。

启动服务:

code
docker compose up --build

你会看到 PostgreSQL 初始化过程,然后应用启动。当日志中出现 Server running on port 3000 时,服务组合就准备就绪了。

在第二个终端进行测试——保持第一个终端的 compose 日志运行。

Linux/macOS:

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

curl http://localhost:3000/tasks

curl http://localhost:3000/health

Windows PowerShell: 在 PowerShell 中输入 curl 会运行 Invoke-WebRequest,而非实际的 curl。应使用 curl.exe。对于 JSON 请求体,需要先写入文件:

code
'{"title": "Learn 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 会将 @ 解释为 splatting 操作符,而非传递给 curl 的文件名前缀。

你应该看到类似以下的响应:

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

# GET /tasks
[{"id":1,"title":"Learn 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,然后进入 Account Settings → Security → New Access Token。将权限设置为 Read & Write,只读权限会导致推送失败。令牌只会显示一次,因此在关闭页面前请先复制。

安全警告:不要将此令牌粘贴到聊天、邮件或提交中。如果意外暴露了令牌,请立即删除并重新生成一个新的。

步骤 2:向 GitHub 仓库添加密钥

进入仓库的 Settings → Secrets and variables → Actions,添加以下内容:

  • DOCKERHUB_USERNAME — 你的 Docker Hub 用户名
  • DOCKERHUB_TOKEN — 将令牌粘贴在此处,不要在其他地方使用

如果你遇到错误:Error: Username and password required,说明密钥尚未保存或名称拼写错误。两者都区分大小写。

日志中出现的 Node 20 即将弃用警告是正常的。这是来自 Docker Actions 内部的警告,与你的代码无关。

步骤 3:创建工作流文件

创建 .github/workflows/docker-publish.yml 文件:

code
name: Build and Push Docker Image

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: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

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

      - name: Extract metadata
        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: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

登录步骤中的 if: github.event_name != 'pull_request' 会跳过拉取请求的认证。来自 fork 的拉取请求无法访问你的密钥,因此尝试登录会直接失败。构建仍然会在拉取请求上运行以验证你的 Dockerfile,但镜像不会被推送。

元数据操作在每次合并到 main 分支时会生成两个标签:latest 和一个短提交 SHA,如 sha-a1b2c3d。SHA 标签使得回滚变得实用。如果 latest 在生产环境中出现问题,你可以拉取任何之前的 sha- 标签,几秒钟内就能恢复到已知的良好状态。

缓存机制:通过 cache-from/cache-to: type=gha 行,Docker 的镜像层缓存将存储在 GitHub Actions 的内置缓存中。首次运行时会从零开始构建所有内容。之后,未发生更改的镜像层将从缓存中拉取,而不是重新构建。对于典型的 Node.js 应用,这可将构建时间从 2-3 分钟缩短至 30 秒以内。

推送并实时查看运行

code
git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main

前往仓库的 Actions 标签页。你将看到工作流实时运行。每个步骤完成时会变为绿色:

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

所有步骤都显示绿色表示镜像已发布到 Docker Hub —— 两个标签:latest 和一个提交 SHA 值如 sha-a1b2c3d。从此之后,每次推送至 main 分支都会自动构建并发布。

部署镜像

将镜像上传至 Docker Hub 后,可以部署到任意基础设施:

任意 VPS 或服务器:

code
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 会处理其余操作。

Render — 将你的 Docker Hub 镜像 URL 粘贴到 Render 服务设置中。

每次推送至 main 分支都会触发工作流。新镜像上传至 Docker Hub,平台会自动获取 —— 这就是你的部署流程。

总结

最初只是一个本地 Node.js 应用,现在已封装在容器中运行。你可以在任何机器上获得一致的行为,开发环境使用真实的 PostgreSQL 数据库,并且构建和推送至 Docker Hub 的流程无需人工干预。

多阶段构建保持了镜像的精简 —— 开发工具被排除在外,使用非 root 用户,内置健康检查。Compose 可通过一条命令让任何克隆仓库的人都能启动完整堆栈。每个 GitHub Actions 构建的 SHA 标签意味着回滚只需拉取旧版本标签。

这些模式(多阶段构建、使用 Compose 进行本地开发、自动化镜像发布)在业界广泛用于生产环境的 Node.js 部署。掌握这些模式后,它们会跟随你参与每一个项目。

从这里你可以扩展流水线:在构建前添加测试步骤,或如果目标平台是 ARM 架构,可添加多平台支持。当 Docker Compose 在生产环境中开始显得受限时,通常就是 Kubernetes 进入舞台的时候。

Zia Ullah 是瑞典 ValueAdd Solution Scandinavia AB 公司的全栈开发人员 - https://www.valueadd.se/,拥有软件工程硕士学位和计算机科学学士学位。他拥有 13 年以上在 Azure 上构建和部署云应用的经验,设计过 GitHub Actions 和 Azure DevOps 的 CI/CD 流水线,并实施过安全的身份验证、授权和 API 安全方案。他是一位技术作家,其文章发表在领先的开发者平台和高权威技术出版物上,分享关于 Web 开发、云计算、DevOps、网络安全和软件工程的实用见解。在 LinkedIn 上联系他 - https://www.linkedin.com/in/zia-ullah/

如果你读到这里,请向作者表达感谢,让他们知道你在意他们。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成功获得开发工作。立即开始

ADVERTISEMENT