Hosting a Hugo site on GitHub Pages needs four pieces: Hugo installed locally to build and preview, a theme added to the project, a GitHub Actions workflow that builds the site and deploys it with the Pages source set to Actions rather than a branch, and a correct baseURL so links and assets resolve once the site is live. The Actions-based workflow, not the older branch-deploy method, is the version GitHub now documents first, and it also avoids committing a generated public folder to the repository.
Hugo’s own quick start gets a site running locally in a few commands; the part that trips people up is what changes between a local build and a build running inside GitHub’s Actions runner.
This post walks through the setup end to end, then covers the three things that actually break Hugo-on-Pages builds: submodules, baseURL, and the parts of hosting that GitHub Pages does not do at all.
Table of contents
- Install Hugo and start a site
- Add a theme
- The Actions workflow, Pages source set to Actions
- The submodule trap on CI
- baseURL and the custom domain
- baseURL and relative URL bugs
- What GitHub Pages cannot do
- How this fits the rest of the stack
- FAQ
Install Hugo and start a site
Install Hugo with a package manager, brew install hugo on macOS or choco install hugo-extended on Windows, or a binary from Hugo’s releases page. The extended edition is the one with Sass support, which most themes assume.
hugo new site my-site
cd my-site
git init
hugo new site scaffolds the config file, content, layouts, and static directories. Nothing renders yet without a theme.
Confirm the install with hugo version before doing anything else. It should print a version string with (extended) in it if you installed the extended edition; without that word, Sass-based themes will fail to build later with an error about the scss transformer being unavailable, which is a confusing thing to debug the first time it happens. Running hugo server after scaffolding starts a local preview at localhost:1313 with live reload, which is worth doing before adding a theme so a blank site is confirmed to render correctly first.
Add a theme
Themes are usually added as a Git submodule, which keeps the theme’s own repository out of your commit history while still versioning which commit of it you are using.
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml
Submodules are the detail that breaks CI later, covered below; for local development they are invisible once added.
Not every theme needs a submodule. Some are plain packages meant to be copied into themes/ directly, and a few newer ones use Hugo Modules instead, declared in hugo.toml under module.imports rather than added with git submodule add. Submodules remain the most common path for older, widely used themes, and they are the ones that break on CI in the next section, so it is worth checking the theme’s own README for which mechanism it actually expects before committing to it.
Mixing the two on the same project rarely goes well: a theme installed as a submodule but declared under module.imports, or the reverse, tends to fail with an error about a missing theme directory that looks identical to the submodule-not-fetched error covered next, which makes it worth settling on one mechanism before debugging either.
The Actions workflow, Pages source set to Actions
In the repository’s Settings, Pages, set Source to GitHub Actions rather than a branch. Then add a workflow that builds with Hugo and deploys with GitHub’s own Pages actions.
name: Deploy Hugo site
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: 'latest'
extended: true
- run: hugo --minify
- 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
hugo —minify writes to public, upload-pages-artifact packages that folder, and deploy-pages publishes it, without ever committing the generated output to a branch.
Hugo builds are fast enough that caching rarely matters for a small site, but a large image-heavy one benefits from caching the resources directory Hugo uses for processed images between runs, using actions/cache keyed on a hash of the content directory so an unchanged set of images does not get reprocessed on every single push.
The submodule trap on CI
actions/checkout does not fetch submodules by default. Skip submodules: recursive and the build runs against an empty themes/ananke directory, fails on missing layout files, and the error rarely mentions submodules at all, just a missing template.
fetch-depth: 0 solves a quieter version of the same problem: a shallow checkout hides the commit history Hugo uses for .GitInfo and .Lastmod on pages, so dates and Git-derived fields come back empty or wrong on CI even though they work locally.
The actual error rarely says submodule. A missing theme produces something like template: theme/layouts/_default/baseof.html:1: parse failed, or simply a page not found response for every route, because Hugo falls back to no layout at all rather than refusing to build outright. Seeing that error on a build that works locally is the signal to check submodules: recursive first, before suspecting the theme itself or a Hugo version mismatch.
baseURL and the custom domain
baseURL in hugo.toml has to match where the site will actually live: https://username.github.io/repo-name/ for a project page, https://username.github.io/ for a user or org page, or https://example.com/ once a custom domain is attached. Wrong baseURL is the single most common reason a Hugo-on-Pages site loads with no CSS.
For a custom domain, add a file at static/CNAME containing just the domain, so Hugo copies it into the built output on every deploy; setting the domain only in the Pages UI without that file means the next deploy that happens to omit it silently drops the custom domain.
A quick way to confirm baseURL is correct without waiting for a full deploy: run hugo —minify locally and grep the generated public/index.html for href= and src= attributes. Absolute paths should carry the exact production domain, not localhost and not a mismatched subpath. Finding /css/style.css instead of https://example.com/css/style.css in that output means baseURL is still pointing at the wrong place.
baseURL and relative URL bugs
Templates and content that use absolute-looking paths, like href=/css/style.css, assume the site is served from the domain root. On a project page served from a subpath, that link points at the wrong place and 404s, even though the same template works fine locally where Hugo’s dev server does serve from root.
Hugo’s relativeURLs setting can paper over this by rewriting links to relative paths, but it tends to break RSS feeds and canonical URLs instead. Getting baseURL right for the actual deploy target, subpath included, is the fix that does not create a second problem.
A second, quieter version of the same bug shows up in RSS. An item’s link element, built from an absolute reference under the wrong baseURL, points feed readers at a broken URL that looks fine in the browser tab where relative links still resolve against the current page. Testing the built RSS feed’s URLs directly, not just clicking around the rendered site, catches this before a reader’s aggregator does.
What GitHub Pages cannot do
GitHub Pages serves static files well and stops there. It has no way to set a response header, so a content security policy or a custom cache-control value is not available at all. It has no server-side redirect mechanism beyond client-side meta-refresh tricks, and no way to run code at request time.
For a personal blog those gaps rarely matter. They start to matter once a site needs a security header, a clean redirect from an old URL structure, or a rewrite rule, which is a documented limit of Pages itself, not a Hugo problem, and the point at which a static host with header and redirect support becomes the better fit for the same generated output.
The closest Pages gets to a redirect is a static HTML file at the old path with a meta http-equiv=refresh tag pointing at the new one. It works for search engines and slow readers, but it adds a visible flash, does not carry query parameters, and does not preserve the original status code as a proper 301 would.
How this fits the rest of the stack
Hugo’s output is a folder of static files whichever host serves it, so moving off Pages means pointing the same build at a static host, not rewriting the site. RunxBuild builds a static site from the repository with support for response headers and redirects that Pages does not offer, and the RunxBuild hosting calculator shows what that costs once the free bandwidth allowance is exceeded.
Useful related references:
- Getting Started With GitHub Pages, and the Exact Point You Outgrow It
- GitHub Pages SEO: What You Can Fix and What You Cannot
- Turn a GitHub Repo Into a Website: Four Ways, and Which One Fits
- Response headers on RunxBuild
FAQ
Is Hugo free to host on GitHub Pages?
Yes. GitHub Pages is free for public repositories, and Hugo itself is open source with no license cost. The only recurring cost in this setup is a custom domain, if you attach one, which is bought from a registrar and is unrelated to either Hugo or GitHub Pages.
Why does my Hugo site on GitHub Pages have no styling?
Almost always a baseURL mismatch. If the site is served from a subpath, such as a project page at username.github.io/repo-name/, and baseURL in hugo.toml points at the domain root instead, every CSS and asset link resolves to the wrong path and 404s, which looks like missing styles.
Why did my theme disappear after pushing to GitHub Actions?
The theme is very likely a Git submodule, and actions/checkout does not fetch submodules unless the workflow explicitly sets submodules: recursive. Without it, the theme’s directory is empty on the runner, and the build fails or renders with no layouts.
Can I use a custom domain with Hugo on GitHub Pages?
Yes. Add a file at static/CNAME containing the domain name so Hugo copies it into every build’s output, and set the same domain under the repository’s Pages settings so GitHub issues a certificate for it. Point the domain’s DNS at GitHub Pages with the records GitHub’s documentation specifies.
What can GitHub Pages not do that a static host can?
GitHub Pages cannot set custom response headers, cannot run server-side redirects beyond a client-side meta-refresh, and cannot run any code at request time. A static host built for this adds header configuration, redirect and rewrite rules, and usually a bandwidth allowance, which matters once a Hugo site grows past a personal blog.