GitLab CI to GitHub Pages for a Hugo Blog
This blog’s source of truth and CI live on GitLab. The public site is served from GitHub Pages at devopstales.github.io. Hugo builds in a container, the job writes into a separate Pages repository, and readers never see GitLab’s job logs—only the static tree.

Shape of the pipeline
High level:
GitLab repo (content, layouts, themes)
|
v
hugomods/hugo image --> hugo --> public/
|
v
clone GitHub Pages repo, replace files, commit, push
|
v
https://devopstales.github.io/
The active job uses the hugomods/hugo image pinned to a Hugo version (for example hugomods/hugo:git-0.148.0) and sets:
variables:
GIT_SUBMODULE_STRATEGY: recursive
Themes such as Roadster and hugo-atom-feed are git submodules. Without recursive checkout, hugo fails or silently falls back to incomplete layouts.
Build steps that matter
A typical job script:
- Optional content normalization (
sedto forcetoc: true/ comment flags on section globs). mkdir publicand copy root files that must be at the site root (for exampleads.txt).- Run
hugo. - Add
.nojekyllso GitHub Pages does not run Jekyll on the uploaded tree. - Clone the Pages repo, replace its contents with
public/, commit, push.
.nojekyll is not cosmetic. GitHub Pages enables Jekyll by default. Without the empty marker file, some paths—especially Markdown-looking files—can 404 or be rewritten. If you also publish agent-facing text siblings, read the companion note on llms.txt and index.txt on GitHub Pages.
Locally, build.sh / build-osx.sh mirror the same Hugo invocation for a laptop build without the push step.
Deploy credentials
Do not hardcode a GitHub personal access token in .gitlab-ci.yml. Store it as a masked/protected CI/CD variable (for example GITHUB_TOKEN or GITHUB_PAGES_TOKEN) and clone with that variable:
git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/ORG/PAGES_REPO.git"
Use a fine-scoped token (or deploy key) with write access only to the Pages repository. Rotate anything that was ever committed in plain text.
Push commits with a bot identity that matches your project norms:
git config user.name "ci-bot"
git config user.email "ci-bot@users.noreply.github.com"
git add -A
git commit -m "$(date +%Y-%m-%d)" || true
git push
Copy with cp -a public/. pages-repo/ so dotfiles such as .nojekyll are included. A bare public/* glob skips them.
Keep section scripts in sync
If the job rewrites front matter per section with sed, every new content directory must be listed. When content/hugo/ was added, the CI globs still targeted older sections only (ai, kubernetes, …). New Hugo posts will not get those toggles until you extend the script:
sed -i 's|toc: false|toc: true|' content/hugo/*.md
Prefer fixing front matter in the source over CI mutation when you can. If you keep the sed approach, treat “new section” as a CI checklist item.
Verify
# After a green pipeline
curl -sI https://devopstales.github.io/ | head
curl -sI https://devopstales.github.io/.nojekyll | head
curl -s https://devopstales.github.io/hugo/ | head
Check the Pages repo history for the CI commit, and confirm submodule themes rendered (menu, Atom link, Roadster chrome).
Summary
Pin a Hugo image, fetch themes recursively, build into public/, stamp .nojekyll, and push to a dedicated GitHub Pages repo with a secret token. The split—GitLab for engineering, Pages for hosting—stays simple as long as credentials stay in CI variables and new content sections stay on the deploy checklist.