---
title: "GitHub Actions 自动部署"
description: "使用 GitHub Actions + GHCR + Watchtower 实现 Docker 容器自动部署"
canonical_url: "https://mhaibaraai.cn/docs/guides/deployment/github-actions"
---
# GitHub Actions 自动部署

> 使用 GitHub Actions + GHCR + Watchtower 实现 Docker 容器自动部署

## 架构概览

```text
GitHub 仓库
  └── push to main
        │
        ▼
GitHub Actions（构建 Docker 镜像）
  └── 推送至 GHCR（GitHub Container Registry）
        │
        ▼
Watchtower（每 60 秒轮询 GHCR）
  └── 检测到新镜像 → 自动拉取并重启容器
        │
        ▼
Docker 容器（加入 webnet 网络）
  └── nginx 通过容器名反向代理
        │
        ▼
用户访问（HTTPS）
```

## 项目端配置

### Dockerfile

三阶段构建：`deps` 安装依赖，`build` 编译产物，`runtime` 只包含 `.output/`，不含源码和 node_modules：

```dockerfile [Dockerfile]
FROM node:24-alpine AS base
RUN corepack enable

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    corepack install && pnpm install --frozen-lockfile --ignore-scripts

FROM deps AS build
WORKDIR /app
ENV NODE_OPTIONS=--max-old-space-size=8192
COPY . .
RUN --mount=type=secret,id=SECRET_1 \
    --mount=type=secret,id=SECRET_2 \
    for f in /run/secrets/*; do echo "$(basename $f)=$(cat $f)"; done > .env && \
    pnpm build && rm -f .env

FROM node:24-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/.output ./
USER app
ENV NODE_ENV=production \
    HOST=0.0.0.0 \
    PORT=3000
EXPOSE 3000
CMD ["node", "server/index.mjs"]
```

> [!NOTE]
> 
> - `--mount=type=cache` 缓存 pnpm store，源码变更时无需重新下载依赖。
> - `--mount=type=secret` 将 GitHub Secrets 以文件形式挂载到 `/run/secrets/`，只在该 `RUN` 指令执行期间可见，不会写入任何镜像层。
> - secrets 通过遍历写入 `.env`，Nuxt CLI 构建时自动加载，构建完成后立即删除。
> - `NODE_OPTIONS=--max-old-space-size=8192` 将 Node.js 堆内存上限设为 8GB，避免 Nitro 打包阶段 OOM。

### GitHub Actions 工作流

```yaml [.github/workflows/deploy.yml]
name: Deploy

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE: ghcr.io/${{ github.repository }}

permissions:
  contents: read
  packages: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v6
      - uses: docker/setup-buildx-action@v4

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE }}:latest
            ${{ env.IMAGE }}:${{ github.sha }}
          secrets: |
            SECRET_1=${{ secrets.SECRET_1 }}
            SECRET_2=${{ secrets.SECRET_2 }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
```

### GitHub Secrets 配置

前往仓库 **Settings → Secrets and variables → Actions**，根据项目需要新增 Repository secrets：

<table>
<thead>
  <tr>
    <th>
      Secret 名称
    </th>
    
    <th>
      用途
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        SECRET_1
      </code>
    </td>
    
    <td>
      构建时和运行时所需的第一个密钥
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        SECRET_2
      </code>
    </td>
    
    <td>
      构建时和运行时所需的第二个密钥
    </td>
  </tr>
</tbody>
</table>

> [!NOTE]
> 
> GITHUB_TOKEN
> 
>  由 GitHub 自动提供，无需配置，仅用于推送镜像到 GHCR。其余 secrets 需要手动创建。

> [!WARNING]
> 
> **GHCR 镜像命名规则**：`GITHUB_TOKEN` 只能写入**当前仓库关联的包**。镜像名必须是 `ghcr.io/<owner>/<repo>` 或其子路径（如 `ghcr.io/<owner>/<repo>/app`）。
> 
> 如果使用与仓库名不匹配的独立包名，会被 GHCR 视为**另一个独立包**，`GITHUB_TOKEN` 无权写入，推送时报 `permission_denied: write_package`。
> 
> 正确做法是使用 `ghcr.io/${{ github.repository }}` 或其子路径格式。

> [!TIP]
> 
> **为什么不用 build-args 传递 secrets？**
> 
> `ARG` + `ENV` 会将变量值固化进镜像层，任何有镜像访问权限的人都可以通过 `docker inspect` 读取。`--mount=type=secret` 是 BuildKit 提供的安全方案，secret 仅在构建期间临时挂载，不留痕迹。

## 服务器端配置

### Watchtower（自动更新）

```yaml [~/watchtower/docker-compose.yml]
services:
  watchtower:
    image: containrrr/watchtower:latest
    container_name: watchtower
    restart: unless-stopped
    environment:
      - WATCHTOWER_POLL_INTERVAL=60
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_INCLUDE_RESTARTING=true
      - DOCKER_CONFIG=/config
      - DOCKER_API_VERSION=1.41
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /root/.docker:/config:ro
    command: <container-name>
```

- `WATCHTOWER_POLL_INTERVAL=60`：每 60 秒检查一次新镜像
- `WATCHTOWER_CLEANUP=true`：更新后自动删除旧镜像
- `command: <container-name>`：只监控指定容器，多个容器用空格分隔
- `/root/.docker:/config:ro`：挂载 GHCR 登录凭证

> [!NOTE]
> 
> 首次使用前需在服务器上登录 GHCR（如果镜像设为私有）：
> 
> ```sh
> docker login ghcr.io -u <github_username> -p <github_pat>
> ```
> 
> GitHub PAT 只需 `read:packages` 权限。

### 应用容器

```yaml [~/webs/<container-name>/docker-compose.yml]
services:
  <container-name>:
    image: ghcr.io/<owner>/<repo>:latest
    container_name: <container-name>
    restart: unless-stopped
    env_file: .env
    networks:
      - webnet

networks:
  webnet:
    external: true
```

```sh [~/webs/<container-name>/.env]
SECRET_1=your_value_here
SECRET_2=your_value_here
```

> [!NOTE]
> 
> 运行时环境变量（
> 
> .env
> 
> ）与构建时 secrets 是两条独立的注入链路。Nuxt 生产服务器不读取 
> 
> .env
> 
>  文件，必须通过容器环境变量传入，
> 
> env_file
> 
>  是 Docker Compose 的标准做法。

### nginx 反向代理

```nginx [~/nginx/conf.d/<your-domain>.conf]
server {
    listen 80;
    server_name <your-domain>;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name <your-domain>;

    ssl_certificate /etc/nginx/ssl/<your-domain>.pem;
    ssl_certificate_key /etc/nginx/ssl/<your-domain>.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        resolver 127.0.0.11 valid=30s;
        set $upstream <container-name>:3000;
        proxy_pass http://$upstream;
        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;
    }

    access_log /var/log/nginx/<your-domain>.access.log;
    error_log /var/log/nginx/<your-domain>.error.log;
}
```

> [!NOTE]
> 
> resolver 127.0.0.11
> 
>  使用 Docker 内置 DNS，配合 
> 
> set $upstream
> 
>  变量实现动态解析。容器不存在时 nginx 仍可正常启动（返回 502），避免了 
> 
> upstream
> 
>  块在启动时强制解析导致的报错。

## 首次部署步骤

### 登录 GHCR（如果镜像私有）

```sh
sudo docker login ghcr.io -u <github_username> -p <github_pat>
```

### 启动 Watchtower

```sh
cd ~/watchtower && sudo docker compose up -d
```

### 手动首次启动应用容器

```sh
mkdir -p ~/webs/<container-name>
```

创建 `.env` 和 `docker-compose.yml`（参照上方配置），然后启动：

```sh
cd ~/webs/<container-name> && sudo docker compose up -d
```

Watchtower 只负责更新已有容器，不负责首次创建。

### 上传 nginx 配置并重载

```sh
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload
```

### 触发首次 CI 构建

```sh
git commit --allow-empty -m "chore: 触发首次部署"
git push
```

之后每次推送 `main`，全流程自动完成。

## 新增项目部署模板

每新增一个需要部署的 Nuxt/Node 项目，重复以下步骤：

**项目仓库**：新建 `Dockerfile` + `.github/workflows/deploy.yml`，修改镜像名和容器名。

**Watchtower**：在 `~/watchtower/docker-compose.yml` 的 `command` 里追加容器名：

```yaml [~/watchtower/docker-compose.yml]
command: existing-container new-container-name
```

```sh
sudo docker compose up -d
```

**nginx**：新建 `conf.d/<new-domain>.conf`，参照现有配置修改 `server_name` 和 `set $upstream`：

```sh
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload
```

**首次启动容器**：创建项目目录，参照模板创建 `.env` 和 `docker-compose.yml`，执行 `sudo docker compose up -d`。

**DNS**：域名解析控制台添加 `<new-domain> → 服务器 IP` 的 A 记录。

## 常用运维命令

```sh
# 查看所有容器状态
sudo docker ps

# 查看应用日志
sudo docker logs <container-name> -f --tail 100

# 查看 Watchtower 日志（确认自动更新是否生效）
sudo docker logs watchtower -f --tail 50

# 重载 nginx 配置（无停机）
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload

# 手动触发镜像更新（无需等待 Watchtower 轮询）
cd ~/webs/<container-name> && sudo docker compose pull && sudo docker compose up -d

# 进入容器内部调试
sudo docker exec -it <container-name> sh
```


## Sitemap

See the full [sitemap](https://mhaibaraai.cn/sitemap.md) for all pages.
