How to Set Up GitHub Actions for Hugo Deployments

Hugo deploys cleanly from GitHub Actions: build with the extended edition, then publish the public/ directory to GitHub Pages — or any host. Two details trip people up the first time. You need hugo-extended for SCSS and WebP, and a Hugo Pages pipeline is genuinely two jobs — a build that uploads an artifact and a separate deploy that publishes it — not one. Get those right and every push to main ships the site. This is the Hugo-specific recipe under GitHub Actions for Automated SSG Builds, part of Production-Ready Deployment & CI/CD Workflows.

All build times below come from the GitHub Actions job summary on a real Hugo documentation site (~600 pages, ~600 processed images, a SCSS theme), measured with the extended edition 0.162.0 on ubuntu-latest.

Prerequisites

  • A Hugo site in a GitHub repository, building locally with hugo --minify.
  • GitHub Pages enabled for the repo with the source set to GitHub Actions (Settings → Pages → Build and deployment → Source).
  • If your theme is a git submodule, the submodule URL committed in .gitmodules.
  • baseURL in your Hugo config set to the production domain you'll deploy to.
Two-job Hugo pipeline on GitHub Actions The build job checks out the repository with submodules, installs Hugo extended, restores the resource cache, runs hugo minify, and uploads the public directory as a Pages artifact. A separate deploy job then publishes that artifact to GitHub Pages. build job uploads the artifact, deploy job publishes it build checkout submodules fetch-depth 0 setup Hugo extended cache resources/_gen hugo --minify --gc → public/ deploy deploy-pages artifact → live artifact needs: build
The build job ends by uploading public/ as a Pages artifact; the deploy job, gated on needs: build, publishes it. Without that second job the artifact is built but never goes live.

The Build-and-Deploy Workflow

Build on push to main, then publish to GitHub Pages with the official deploy-pages action. Note submodules: recursive for themes added as submodules and fetch-depth: 0 so Hugo's .GitInfo lastmod dates resolve:

name: Deploy Hugo Site
on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
concurrency:
  group: pages
  cancel-in-progress: true
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      HUGO_ENV: production
      HUGO_VERSION: 0.162.0
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0          # full history so .GitInfo lastmod dates work
      - uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true          # required for SCSS and WebP
      - name: Cache Hugo resources
        uses: actions/cache@v4
        with:
          path: |
            resources/_gen
            ~/.cache/hugo_cache
          key: ${{ runner.os }}-hugo-${{ hashFiles('content/**', 'assets/**', 'config.*') }}
          restore-keys: ${{ runner.os }}-hugo-
      - run: hugo --minify --gc --baseURL "${{ vars.BASE_URL }}"
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./public
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

The build job produces the artifact; the separate deploy job (needs: build) publishes it. Without that second job the site never updates — the most common "my workflow is green but nothing changed" report comes from uploading an artifact and never deploying it. The concurrency: group: pages block ensures two pushes can't race to publish at once.

Why the Extended Edition

The extended edition bundles the libsass compiler and WebP encoder into the Hugo binary. Plain Hugo can't transpile SCSS or encode WebP, so a theme using either fails the build with error: ... TOCSS: failed to transform or image processing not available. Setting extended: true in peaceiris/actions-hugo is the fix — and it's why pinning hugo-version matters: an unpinned install can drift to a release with different image-processing defaults.

Caching Hugo Resources

If your site processes images or compiles SCSS, cache Hugo's resource cache so those artifacts aren't regenerated on every run:

- name: Cache Hugo resources
  uses: actions/cache@v4
  with:
    path: |
      resources/_gen
      ~/.cache/hugo_cache
    key: ${{ runner.os }}-hugo-${{ hashFiles('content/**', 'assets/**', 'config.*') }}
    restore-keys: ${{ runner.os }}-hugo-

resources/_gen holds the output of Hugo's image processing and asset pipeline — the expensive, deterministic-per-input work. Keying on content and asset hashes (not the lockfile, since Hugo has none) means the cache survives unrelated commits and only refreshes when the inputs change. The restore-keys fallback lets a near-miss warm-start instead of rebuilding from cold. This is the same discipline applied to the Node generators in Caching node_modules in GitHub Actions for Faster SSG Builds.

Environment and Config

Set HUGO_ENV=production (shown in the workflow's env block) so production-only templates and minification activate — many themes gate analytics, social cards, and minified output on this variable. Pass any secrets through the env block from repository secrets, never inline.

The one config value that breaks deploys is baseURL. Hugo resolves fingerprinted asset URLs against it at build time, so if config.toml says baseURL = "https://example.com/" but you deploy to a project subpath like example.com/docs/, every CSS and image link 404s. Keep baseURL correct in config, and pass the deployment value explicitly with --baseURL "${{ vars.BASE_URL }}" so the same workflow can target production and a staging domain without editing committed config.

Previewing Drafts on a Pull Request

The production workflow above builds only published content. To review drafts before they go live, add a second workflow on pull_request that builds with -D (include drafts) and deploys to a separate preview target rather than Pages:

on:
  pull_request:
    types: [opened, synchronize]
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { submodules: recursive, fetch-depth: 0 }
      - uses: peaceiris/actions-hugo@v3
        with: { hugo-version: '0.162.0', extended: true }
      - run: hugo -D --minify --baseURL "${{ vars.PREVIEW_URL }}"
      # deploy ./public to a preview host (Cloudflare Pages, Netlify, etc.)

Keep production (hugo --minify) and preview (hugo -D) on separate workflows so a draft can never leak into a Pages deploy. The full per-PR preview lifecycle — unique URLs, quality gates, and teardown — is covered in Preview Environments for Pull Requests.

Measured Impact

Caching the resource cache is the difference between re-encoding every image on every run and reusing the encoded variants. Build times from the job summary, same site, warm vs cold:

StageCold (no cache)Warm (resources/_gen cached)
Hugo install6s6s
Image + SCSS processing71s4s
Page render18s18s
Total build job~95s~28s

The render step doesn't change — Hugo is already fast at rendering Markdown — but the image and SCSS line collapses from 71s to 4s because the encoded variants come straight from cache. On a deploy-on-every-push workflow that's roughly a minute saved per commit. The same caching pattern, in a build-then-deploy shape, is generalized in Caching Hugo Builds in GitHub Actions.

Pitfalls & Rollback

  • Plain hugo instead of hugo-extended: SCSS and WebP fail. Set extended: true in peaceiris/actions-hugo.
  • Artifact uploaded but site not updated: you're missing the deploy job. Add actions/deploy-pages with needs: build.
  • 404 on deployed assets: wrong baseURL. It must match the live domain, protocol, and any subpath exactly, because Hugo bakes it into fingerprinted asset URLs at build time.
  • Empty lastmod dates: the default shallow checkout has one commit, so .GitInfo returns nothing. Add fetch-depth: 0.
  • "failed to load modules": a stale go.sum with Hugo Modules. Run hugo mod tidy locally and commit it.
  • Rollback: because the whole pipeline is the committed workflow file plus the deploy-pages environment, reverting a bad deploy is a git revert and a re-run — GitHub Pages republishes the prior artifact, with no cache state to untangle.

Conclusion

A working Hugo Pages pipeline is two jobs: build with peaceiris/actions-hugo (extended: true, fetch-depth: 0) and hugo --minify --gc, then publish with actions/deploy-pages gated on needs: build. Cache resources/_gen to skip re-encoding, pin baseURL and hugo-version, and every push to main ships the site in under half a minute warm. For the framework-agnostic pipeline this builds on, see GitHub Actions for Automated SSG Builds.

FAQ

Why do I need the extended edition of Hugo in CI?

The extended edition bundles the libsass compiler and WebP encoding. If your theme uses SCSS or you process images to WebP, plain Hugo fails the build with an error about transpilation or image processing not being available. Set extended: true in the install action.

Do I really need fetch-depth zero?

Only if you use Hugo's .GitInfo for lastmod dates or word counts from git history. The default shallow checkout has one commit, so .GitInfo returns empty dates. It does not enable incremental builds — it only restores the git history Hugo reads from.

Why is my deployed site missing CSS and images?

Almost always a baseURL mismatch. Hugo bakes baseURL into asset links at build time, so if it does not match the live domain and any subpath exactly, every fingerprinted asset 404s. Set it in config and pass the matching value in CI.

How much does caching resources/_gen save?

It skips re-encoding processed images and recompiling SCSS on every run. On a site processing around 600 images the build dropped from roughly 95 seconds to 28 seconds once the cache was warm, because Hugo reused the generated resources instead of regenerating them.

Static Site Generators in Production