Contents

Hugo Blog CI/CD: GitHub Actions Auto-Build and Multi-Environment Deployment

From the early days of hugo && scp to Git Push-triggered auto-deployment — this post documents the evolution of my Hugo blog CI/CD setup, along with production-grade GitHub Actions best practices.

1. Deployment Evolution

1.1 Bronze Tier: Manual Deployment

1
2
3
4
hugo                     # Build
tar -czf public.tar.gz public/
scp public.tar.gz server:/tmp/
ssh server "cd /var/www && tar -xzf /tmp/public.tar.gz"

Pain points: Manual every time, easy to miss steps, no version history.

1.2 Silver Tier: Shell Script

1
2
3
#!/bin/bash
hugo && git add . && git commit -m "update" && git push
ssh server "cd ~/blog && git pull && cp -r public/* /var/www/"

Pain points: Depends on local environment, builds might differ across machines.

1.3 Gold Tier: GitHub Actions

1
2
3
4
# Push triggers cloud build and auto-deploy
on:
  push:
    branches: [main]

Benefits:

  • Environment consistency (containerized builds)
  • Version tracking
  • Multi-target deployment
  • Automated testing

2. Complete GitHub Actions Configuration

2.1 Directory Structure

1
2
3
4
.github/
└── workflows/
    ├── deploy.yml        # Main deploy pipeline
    └── preview.yml       # PR preview

2.2 Main Deploy Pipeline

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# .github/workflows/deploy.yml
name: Deploy Hugo Blog

on:
  push:
    branches:
      - main
  workflow_dispatch:  # Manual trigger support

# Permissions (required for GitHub Pages)
permissions:
  contents: read
  pages: write
  id-token: write

# Prevent concurrent deployment conflicts
concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  # ========== Build Stage ==========
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive  # Pull theme submodules
          fetch-depth: 0         # Full history (for lastmod)

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.139.0'
          extended: true  # Extended version (SCSS support)

      - name: Cache Hugo modules
        uses: actions/cache@v4
        with:
          path: /tmp/hugo_cache
          key: ${{ runner.os }}-hugo-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-hugo-

      - name: Build
        env:
          HUGO_ENVIRONMENT: production
          HUGO_ENV: production
        run: |
          hugo \
            --gc \
            --minify \
            --baseURL "${{ vars.SITE_URL }}"

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./public

  # ========== Deploy to GitHub Pages ==========
  deploy-pages:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

  # ========== Deploy to Self-Hosted Server ==========
  deploy-server:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'  # Only main branch
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: github-pages
          path: ./public

      - name: Extract artifact
        run: |
          cd public
          tar -xf artifact.tar
          rm artifact.tar

      - name: Deploy via rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avzr --delete
          path: public/
          remote_path: /var/www/blog/
          remote_host: ${{ secrets.DEPLOY_HOST }}
          remote_port: ${{ secrets.DEPLOY_PORT }}
          remote_user: ${{ secrets.DEPLOY_USER }}
          remote_key: ${{ secrets.DEPLOY_KEY }}

2.3 PR Preview Pipeline

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# .github/workflows/preview.yml
name: PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.139.0'
          extended: true

      - name: Build (Draft mode)
        run: |
          hugo --buildDrafts --baseURL "/"

      - name: Deploy Preview
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public
          destination_dir: pr-${{ github.event.number }}

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Preview URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.number }}/'
            })

3. Secrets Configuration

Add these in GitHub repository settings:

Secret NameDescriptionExample
DEPLOY_HOSTServer IP1.2.3.4
DEPLOY_PORTSSH port22
DEPLOY_USERSSH userdeploy
DEPLOY_KEYSSH private key-----BEGIN...

Generating Deploy Keys

1
2
3
4
5
6
7
8
# Generate locally
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key

# Add public key to server
cat deploy_key.pub >> ~/.ssh/authorized_keys

# Add private key to GitHub Secrets
cat deploy_key  # Copy content to DEPLOY_KEY

4. Advanced Configuration

4.1 Scheduled Rebuilds (Update lastmod)

1
2
3
4
5
on:
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM
  push:
    branches: [main]

4.2 Multi-Environment Deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
jobs:
  build:
    # ...build steps...

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    environment: staging
    # Deploy to staging

  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    environment: production
    # Deploy to production

4.3 Build Cache Optimization

1
2
3
4
5
6
7
- name: Cache Hugo modules
  uses: actions/cache@v4
  with:
    path: |
      /tmp/hugo_cache
      node_modules
    key: ${{ runner.os }}-hugo-${{ hashFiles('**/go.sum', '**/package-lock.json') }}

4.4 Dockerfile Build (More Control)

1
2
3
4
5
6
7
# Dockerfile
FROM hugomods/hugo:exts-0.139.0

WORKDIR /src
COPY . .

RUN hugo --gc --minify
1
2
3
4
5
# GitHub Actions
- name: Build with Docker
  run: |
    docker build -t blog-builder .
    docker run --rm -v $PWD/public:/src/public blog-builder

5. Monitoring & Alerts

5.1 Deployment Status Badge

1
![Deploy Status](https://github.com/username/blog/actions/workflows/deploy.yml/badge.svg)

5.2 Failure Notifications

1
2
3
4
5
6
7
8
- name: Notify on failure
  if: failure()
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    fields: repo,message,commit,author
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

6. Common Issues

6.1 Theme Submodule Not Pulled

1
2
3
- uses: actions/checkout@v4
  with:
    submodules: recursive  # Key config

6.2 Hugo Version Mismatch

1
2
3
4
# Pin the version to avoid surprises
- uses: peaceiris/actions-hugo@v3
  with:
    hugo-version: '0.139.0'  # Explicit version

6.3 Build Running Out of Memory

1
2
3
4
5
6
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      HUGO_CACHEDIR: /tmp/hugo_cache
      HUGO_NUMWORKERMULTIPLIER: 1  # Reduce concurrency

7. Complete Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
┌─────────────┐     ┌──────────────┐     ┌─────────────────┐
│  Git Push   │────▶│ GitHub       │────▶│ Build Hugo      │
│  (main)     │     │ Actions      │     │ (Container)     │
└─────────────┘     └──────────────┘     └────────┬────────┘
                         ┌────────────────────────┼────────────────────────┐
                         ▼                        ▼                        ▼
                  ┌─────────────┐         ┌─────────────┐         ┌─────────────┐
                  │ GitHub Pages│         │ Self-Hosted │         │ CDN Purge   │
                  │ (Backup)    │         │ (Primary)   │         │ (Optional)  │
                  └─────────────┘         └─────────────┘         └─────────────┘

8. Summary

Evolution StageDeploy TimeTraceabilityEnvironment Consistency
Manual5 minNonePoor
Shell script1 minNoneFair
GitHub Actions2 minFullConsistent

Key takeaways:

  1. Git = Deploy: Push triggers deployment, no extra steps
  2. Environment isolation: Containerized builds eliminate “works on my machine”
  3. Multi-target deployment: Ship to GitHub Pages and self-hosted server simultaneously
  4. Observability: Every deployment is logged, failures trigger notifications