这篇教程适合已经有一台 Linux 服务器,并且使用宝塔面板管理网站和 Docker 的情况。

最终要实现的效果很简单:

Text
本地修改代码
    ↓
git push
    ↓
GitHub Actions
    ↓
服务器上的 Self-hosted Runner 收到任务
    ↓
自动拉取最新代码
    ↓
自动构建 Docker 镜像
    ↓
自动更新容器
    ↓
健康检查
    ↓
部署完成

以后发布后端项目时,不需要再进入宝塔手动构建镜像、删除容器、重新创建容器。

整套流程只需要:

Shell
git push origin main

一、准备条件

开始前需要准备:

  • 一台 Linux 服务器
  • 已安装宝塔面板
  • 已安装 Docker
  • GitHub 仓库
  • Node.js 后端项目
  • 项目可以通过 Docker 运行
  • GitHub 仓库默认分支为 main

本文以一个 Node/Koa 项目为例。

假设项目名称:

Text
my-node-service

后端端口:

Text
3000

服务器项目目录:

Text
/www/git/my-node-service

生产环境变量:

Text
/www/env/my-node-service.env

二、项目需要准备的文件

项目根目录至少需要:

Text
my-node-service/
├── .github/
│   └── workflows/
│       └── deploy.yml
│
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── package.json
├── package-lock.json
├── app.js
└── 其他项目文件

三、编写 Dockerfile

如果你的 Node 项目不需要 TypeScript 编译,也没有 dist,可以直接运行源码。

例如:

Dockerfile
FROM node:20-bookworm-slim

WORKDIR /app

COPY package.json package-lock.json ./

ENV NODE_ENV=production

RUN npm ci --omit=dev && npm cache clean --force

COPY . .

EXPOSE 3000

CMD ["node", "app.js"]

如果你的启动入口不是:

Text
app.js

而是:

Text
src/index.js

那么改成:

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

也可以直接通过 package.json

JSON
{
  "scripts": {
    "start": "node app.js"
  }
}

然后 Dockerfile 使用:

Dockerfile
CMD ["npm", "start"]

四、创建 .dockerignore

项目根目录创建:

Text
.dockerignore

内容:

Text
node_modules
npm-debug.log

.git
.github

.env
.env.*

README.md

主要目的是避免:

  • 本地 node_modules 被打进镜像
  • .git 被复制到 Docker
  • GitHub Actions 文件进入镜像
  • .env 等敏感配置进入镜像

生产环境变量不要直接放进 GitHub 仓库。


五、增加健康检查接口

Docker 自动部署最好有一个专门的健康检查接口。

例如 Koa:

JavaScript
router.get('/health', async (ctx) => {
  ctx.status = 200
  ctx.body = {
    status: 'ok'
  }
})

这个接口必须满足几个条件:

Text
不需要登录
不需要 JWT
不查数据库
不请求第三方接口
直接返回 HTTP 200

例如:

Shell
curl http://127.0.0.1:3000/health

应该返回:

JSON
{
  "status": "ok"
}

不要给 /health 加鉴权。

否则 Docker 会认为:

Text
401 = unhealthy

如果接口不存在:

Text
404 = unhealthy

健康检查的作用只是确认:

Text
Node 进程已经正常启动
HTTP 服务能够正常响应

六、编写 docker-compose.yml

项目根目录创建:

Text
docker-compose.yml

例如:

YAML
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile

    image: my-node-service:latest

    container_name: my-node-service

    restart: unless-stopped

    ports:
      - "127.0.0.1:3000:3000"

    env_file:
      - /www/env/my-node-service.env

    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
        ]

      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

这里:

YAML
ports:
  - "127.0.0.1:3000:3000"

表示:

Text
服务器 127.0.0.1:3000
        ↓
Docker 容器 3000

这样 Node 的 3000 端口不会直接暴露到公网。

网站访问通过:

Text
公网
 ↓
宝塔 Nginx
 ↓
127.0.0.1:3000
 ↓
Docker
 ↓
Node

七、创建生产环境变量

不要把真实生产 .env 提交到 GitHub。

服务器创建:

Shell
mkdir -p /www/env

然后:

Shell
vi /www/env/my-node-service.env

例如:

ENV
NODE_ENV=production
PORT=3000

DB_HOST=数据库地址
DB_USER=数据库用户
DB_PASSWORD=数据库密码
DB_NAME=数据库名称

JWT_SECRET=你的JWT密钥

保存以后:

Text
/www/env/my-node-service.env

会由 Docker Compose 自动读取。

不要执行:

Shell
docker compose config

然后把完整结果发到聊天、日志或者公开页面。

因为这个命令会把 env_file 中的真实环境变量展开出来。

八、第一次把项目克隆到服务器

统一把 Git 项目放在:

Text
/www/git/

创建目录:

Shell
mkdir -p /www/git
cd /www/git

克隆:

Shell
git clone git@github.com:你的GitHub用户名/my-node-service.git

如果服务器访问 GitHub 很慢,可以使用浅克隆:

Shell
git clone \
  --depth=1 \
  --branch main \
  --single-branch \
  git@github.com:你的GitHub用户名/my-node-service.git

进入目录:

Shell
cd /www/git/my-node-service

检查:

Shell
ls -la

应该能看到:

Text
Dockerfile
docker-compose.yml
package.json
...

九、第一次手动测试 Docker

在自动部署之前,一定先手动把 Docker 跑通。

执行:

Shell
cd /www/git/my-node-service

docker compose up -d --build

查看:

Shell
docker compose ps

理想状态:

Text
my-node-service   Up ... (healthy)

再测试:

Shell
curl http://127.0.0.1:3000/health

应该返回:

JSON
{"status":"ok"}

如果这一步都不成功,先不要配置自动部署。

十、端口被占用怎么办

如果出现:

Text
Bind for 127.0.0.1:3000 failed:
port is already allocated

说明 3000 已经被其他服务占用。

查看:

Shell
ss -lntp | grep ':3000'

再看 Docker:

Shell
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Ports}}"

如果占用端口的是旧版项目,可以停止并删除旧容器。

如果是其他项目,就换端口。

例如:

YAML
ports:
  - "127.0.0.1:3005:3000"

表示:

Text
服务器:3005
容器:3000

此时宝塔反向代理也需要改成:

Text
http://127.0.0.1:3005

十一、创建专门的 deploy 用户

不要让 GitHub Runner 一直使用 root。

root 下执行:

Shell
useradd -m -s /bin/bash deploy

把 deploy 加入 Docker 用户组:

Shell
usermod -aG docker deploy

确认:

Shell
id deploy

应该看到:

Text
docker

测试:

Shell
su - deploy

然后:

Shell
docker ps

如果能看到 Docker 容器列表,说明权限正常。

十二、让 deploy 用户可以访问 GitHub

Runner 是使用 deploy 用户运行的,因此 deploy 自己也需要能够通过 SSH 拉 GitHub。

切换:

Shell
su - deploy

创建 SSH Key:

Shell
ssh-keygen \
  -t ed25519 \
  -C "deploy@server" \
  -f ~/.ssh/id_ed25519 \
  -N ""

查看公钥:

Shell
cat ~/.ssh/id_ed25519.pub

复制这一整行。

进入 GitHub:

Text
头像
→ Settings
→ SSH and GPG keys
→ New SSH key

填写:

Text
Title:
Server Deploy

Key type:
Authentication Key

把:

Shell
cat ~/.ssh/id_ed25519.pub

输出的公钥粘贴进去。

然后服务器测试:

Shell
ssh -T git@github.com

第一次会提示:

Text
Are you sure you want to continue connecting?

输入:

Text
yes

成功应该显示:

Text
Hi xxx! You've successfully authenticated,
but GitHub does not provide shell access.

十三、把项目目录交给 deploy 用户

如果 /www/git/my-node-service 是之前用 root 克隆的,deploy 用户可能会出现:

Text
fatal: detected dubious ownership in repository

不要只加 safe.directory

直接把项目目录交给 deploy:

root 下:

Shell
chown -R deploy:deploy /www/git/my-node-service

然后:

Shell
su - deploy
cd /www/git/my-node-service

测试:

Shell
git remote -v

再:

Shell
git fetch --depth=1 origin main

如果没有报错就可以。

十四、安装 GitHub Self-hosted Runner

每个 GitHub 仓库注册一个 Runner。

Runner 统一放:

Text
/home/deploy/runners/

例如:

Text
/home/deploy/runners/my-node-service

创建目录:

Shell
mkdir -p /home/deploy/runners/my-node-service

修改所有者:

Shell
chown -R deploy:deploy /home/deploy/runners

切换:

Shell
su - deploy

进入:

Shell
cd /home/deploy/runners/my-node-service

十五、获取 GitHub Runner

进入仓库:

Text
GitHub 仓库
→ Settings
→ Actions
→ Runners
→ New self-hosted runner

选择:

Text
Linux
x64

GitHub 页面会提供下载命令。

例如:

Shell
curl -o actions-runner-linux-x64-x.x.x.tar.gz \
  -L GitHub给出的下载地址

然后解压:

Shell
tar xzf actions-runner-linux-x64-x.x.x.tar.gz

解压完成应该看到:

Text
config.sh
run.sh
svc.sh
bin/
externals/

十六、Runner 安装包只需要下载一次

Runner 包一般有 200MB 左右。

国内服务器下载 GitHub 可能很慢,所以建议缓存。

创建:

Shell
mkdir -p /home/deploy/runner-cache

把安装包移动进去:

Shell
mv actions-runner-linux-x64-x.x.x.tar.gz \
  /home/deploy/runner-cache/

以后其他仓库直接:

Shell
mkdir -p /home/deploy/runners/其他项目
cd /home/deploy/runners/其他项目

tar xzf /home/deploy/runner-cache/actions-runner-linux-x64-x.x.x.tar.gz

不需要重新下载。

十七、注册 Runner

还是在 GitHub:

Text
Settings
→ Actions
→ Runners
→ New self-hosted runner

页面会给一条:

Shell
./config.sh \
  --url https://github.com/你的用户名/my-node-service \
  --token 临时Token

直接执行。

接下来会询问配置。

Runner group:

Text
直接回车

Runner 名称建议:

Text
my-node-service-runner

Labels:

Text
my-node-service,docker

Work folder:

Text
_work

成功后应该显示:

Text
Runner successfully added
Settings Saved

GitHub 页面:

Text
Settings
→ Actions
→ Runners

就能看到 Runner。

十八、不要长期使用 ./run.sh

测试阶段可以:

Shell
./run.sh

看到:

Text
Connected to GitHub
Listening for Jobs

说明注册成功。

但是不要长期这样运行。

因为关闭宝塔终端、SSH 断开或者终端异常,都可能导致 Runner 出问题。

正式环境应该使用 systemd。

十九、把 Runner 安装成系统服务

如果之前运行了:

Shell
./run.sh

先退出:

Text
Ctrl + C

如果出现重复 Session:

Text
A session for this runner already exists

检查:

Shell
pgrep -af "Runner.Listener"

如果有重复进程,可以停止:

Shell
pkill -f "/home/deploy/runners/my-node-service/bin/Runner.Listener"

然后 root 用户进入:

Shell
cd /home/deploy/runners/my-node-service

安装服务:

Shell
./svc.sh install deploy

启动:

Shell
./svc.sh start

查看状态:

Shell
./svc.sh status

正常应该看到:

Text
Active: active (running)

这样即使:

Text
关闭宝塔
关闭 SSH
服务器重启

Runner 也能继续工作。


二十、编写 GitHub Actions

项目创建:

Text
.github/workflows/deploy.yml

完整示例:

YAML
name: Deploy Node API

on:
  push:
    branches:
      - main

concurrency:
  group: my-node-service-production
  cancel-in-progress: false

jobs:
  deploy:
    name: Deploy Production

    runs-on:
      - self-hosted
      - Linux
      - X64
      - my-node-service

    timeout-minutes: 20

    steps:
      - name: Update source code
        run: |
          set -e

          cd /www/git/my-node-service

          echo "========== 更新代码 =========="

          git fetch --depth=1 origin main
          git reset --hard origin/main

          echo ""
          echo "========== 当前部署版本 =========="
          git log -1 --oneline

      - name: Deploy Docker
        run: |
          set -e

          cd /www/git/my-node-service

          echo "========== 构建并更新容器 =========="

          if ! docker compose up \
            -d \
            --build \
            --remove-orphans \
            --wait \
            --wait-timeout 60; then

            echo ""
            echo "========== 部署失败 =========="

            docker compose ps || true

            echo ""
            echo "========== 容器日志 =========="
            docker compose logs --tail=150 || true

            echo ""
            echo "========== 健康检查详情 =========="
            docker inspect my-node-service \
              --format='{{json .State.Health}}' || true

            exit 1
          fi

      - name: Check container
        run: |
          cd /www/git/my-node-service

          echo "========== 容器状态 =========="

          docker compose ps

          docker inspect my-node-service \
            --format='Status={{.State.Status}} Health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'

      - name: Verify API
        run: |
          set -e

          echo "========== API 健康检查 =========="

          curl \
            --fail \
            --silent \
            --show-error \
            http://127.0.0.1:3000/health

          echo ""
          echo "API 健康检查成功"

      - name: Cleanup Docker images
        run: |
          echo "========== 清理旧镜像 =========="

          docker image prune -f

      - name: Deployment completed
        run: |
          cd /www/git/my-node-service

          echo ""
          echo "========================================"
          echo "my-node-service 部署成功"
          echo "========================================"

          git log -1 --oneline

          docker compose ps

需要修改的地方主要有:

Text
my-node-service

改成自己的项目名称。

以及:

Text
3000

改成自己的宿主机端口。

二十一、为什么不用 actions/checkout

普通 GitHub Actions 经常这样:

YAML
- uses: actions/checkout@v4

但国内服务器访问 GitHub HTTPS 有时比较慢或者不稳定。

服务器已经有:

Text
/www/git/my-node-service

而且已经配置:

Text
deploy → GitHub SSH

所以直接:

Shell
cd /www/git/my-node-service

git fetch --depth=1 origin main
git reset --hard origin/main

更简单。

二十二、第一次测试自动部署

本地修改代码后:

Shell
git add .

提交:

Shell
git commit -m "test: auto deploy"

推送:

Shell
git push origin main

然后 GitHub 会自动执行:

Text
Deploy Node API

打开:

Text
GitHub 仓库
→ Actions

正常会依次看到:

Text
Update source code       ✅
Deploy Docker            ✅
Check container          ✅
Verify API               ✅
Cleanup Docker images    ✅
Deployment completed     ✅

全部绿色说明部署成功。

二十三、最终部署流程

以后开发完成只需要:

Shell
git add .
git commit -m "feat: xxx"
git push origin main

服务器自动执行:

Text
git fetch
 ↓
git reset
 ↓
docker build
 ↓
更新容器
 ↓
等待 healthy
 ↓
请求 /health
 ↓
清理旧镜像
 ↓
部署完成

整个过程不需要进入宝塔手动操作。

二十四、宝塔 Nginx 反向代理

假设 Docker:

Text
127.0.0.1:3000

宝塔网站反向代理设置:

Text
目标 URL:

http://127.0.0.1:3000

Nginx 大致类似:

Nginx
location / {
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;

    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;
}

整个访问链路:

Text
https://api.example.com
        ↓
宝塔 Nginx
        ↓
127.0.0.1:3000
        ↓
Docker
        ↓
Node/Koa

二十五、常用 Runner 管理命令

进入:

Shell
cd /home/deploy/runners/my-node-service

查看状态:

Shell
./svc.sh status

停止:

Shell
./svc.sh stop

启动:

Shell
./svc.sh start

也可以使用:

Shell
systemctl status actions.runner.xxx.service

二十六、常用 Docker 排查命令

查看容器:

Shell
docker ps

查看 Compose:

Shell
cd /www/git/my-node-service

docker compose ps

查看日志:

Shell
docker compose logs --tail=100

或者:

Shell
docker logs --tail=100 my-node-service

查看健康检查:

Shell
docker inspect my-node-service \
  --format='{{json .State.Health}}'

测试接口:

Shell
curl -i http://127.0.0.1:3000/health

重新构建:

Shell
docker compose up -d --build

二十七、常见问题

1. port is already allocated

报错:

Text
Bind for 127.0.0.1:3000 failed:
port is already allocated

检查:

Shell
ss -lntp | grep ':3000'

Docker:

Shell
docker ps

确认是不是旧容器占用。

2. Docker 显示 unhealthy

先测试:

Shell
docker exec my-node-service node -e \
"fetch('http://127.0.0.1:3000/health').then(async r => { console.log(r.status); console.log(await r.text()) })"

如果:

Text
401

说明 /health 被鉴权拦截。

如果:

Text
404

说明 /health 不存在或者地址写错。

正确应该是:

Text
200

3. dubious ownership

报错:

Text
fatal: detected dubious ownership in repository

说明项目目录不是 deploy 用户所有。

root 执行:

Shell
chown -R deploy:deploy /www/git/my-node-service

4. deploy 无法拉 GitHub

报错:

Text
Permission denied (publickey)

测试:

Shell
su - deploy

ssh -T git@github.com

如果失败,重新检查:

Text
/home/deploy/.ssh/id_ed25519

对应的公钥是否已经添加到 GitHub 账号。

5. Runner 提示 session already exists

检查:

Shell
pgrep -af "Runner.Listener"

同一个 Runner 应该只有一个 Listener。

如果有多个:

Shell
pkill -f "/home/deploy/runners/my-node-service/bin/Runner.Listener"

正式环境不要再运行:

Shell
./run.sh

统一使用:

Shell
./svc.sh start

6. Runner lost communication

先检查 Runner:

Shell
./svc.sh status

检查系统:

Shell
dmesg -T | grep -Ei "oom|out of memory|killed process"

检查内存:

Shell
free -h

检查 GitHub Actions 网络:

Shell
curl -I https://broker.actions.githubusercontent.com/

Runner 正式部署后尽量使用 systemd 服务,而不是宝塔终端里的 ./run.sh

二十八、多项目怎么管理

如果一台服务器部署多个 Docker 项目,可以这样:

Text
/www/git/
├── project-a
├── project-b
├── project-c
└── project-d

环境变量:

Text
/www/env/
├── project-a.env
├── project-b.env
├── project-c.env
└── project-d.env

Runner:

Text
/home/deploy/runners/
├── project-a
├── project-b
├── project-c
└── project-d

Runner 安装包统一缓存:

Text
/home/deploy/runner-cache/
└── actions-runner-linux-x64-x.x.x.tar.gz

每个仓库注册自己的 Runner,例如:

Text
project-a-runner
project-b-runner
project-c-runner

Labels:

Text
project-a,docker
project-b,docker
project-c,docker

Workflow:

YAML
runs-on:
  - self-hosted
  - Linux
  - X64
  - project-a

这样不同项目不会串任务。

二十九、最终推荐的服务器目录

完整整理后:

Text
/home/deploy/
│
├── runner-cache/
│   └── actions-runner-linux-x64-x.x.x.tar.gz
│
└── runners/
    ├── project-a/
    ├── project-b/
    └── project-c/


/www/git/
├── project-a/
├── project-b/
└── project-c/


/www/env/
├── project-a.env
├── project-b.env
└── project-c.env

职责非常清楚:

Text
/home/deploy/runners
→ GitHub Actions Runner

/www/git
→ 项目源码和 Docker 构建目录

/www/env
→ 生产环境配置

三十、部署完成后的日常使用

整套配置只需要做一次。

以后正常开发:

Shell
git add .
git commit -m "feat: 新功能"
git push origin main

然后等 GitHub Actions 变绿。

不需要:

Text
登录服务器
进入宝塔
打开 Docker
重新构建镜像
删除容器
创建容器
重新填写环境变量
手动启动

只要看到:

Text
Deploy Production ✅

就说明新版本已经部署完成。

这套方式适合:

Text
Koa
Express
NestJS
Fastify
普通 Node.js API
其他 Docker 化 Node 后端

如果项目需要 TypeScript 编译或者 NestJS 构建,只需要调整 Dockerfile 的构建阶段,GitHub Actions 和 Self-hosted Runner 部署方式基本不需要改变。

阅读进度 0%