Goal
A personal blog that is cheap to run and easy to write for:
- Hugo + PaperMod, restyled only through extension points (the theme itself is never edited).
- Source repo and output repo are separate. A GitHub Action builds the source and force-pushes a single commit to
<user>/<user>.github.io. - Custom domain with HTTPS.
- Posts are written in Obsidian and published with one button.
This is the process behind https://uestcyz.top. Names such as <user> and <domain> are placeholders.
Who does what
Do the technical steps yourself. Stop and ask the user for these, because they involve credentials or accounts the agent should not create on its own:
- Creating or approving a GitHub login (
gh auth login, a device code the user enters in the browser). - Writing the deploy key into repository secrets. Give the user the exact command and let them run it (in Claude Code:
! gh secret set ...). - Changing DNS records at the domain registrar.
Never commit tokens or private keys. Never print a private key into the chat.
Prerequisites
hugo(extended, 0.158 or newer),git,gh(logged in),ssh-keygen.- A GitHub account. A domain is optional.
- Obsidian is optional and only needed for the writing workflow in step 5.
Steps
1. Scaffold the site
hugo new site blog && cd blog && git init
git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
In hugo.yaml set at least:
baseURL: https://<domain>/
theme: PaperMod
hasCJKLanguage: true # only if you write Chinese, Japanese or Korean
timeZone: Asia/Shanghai # the time zone you write in, not the machine's
permalinks: { page: { posts: /posts/:slugorcontentbasename/ } }
Keep article URLs short and stable (slug in front matter). Changing a URL later breaks every link to it.
2. Customize only through extension points
Never edit files under themes/PaperMod/. A theme upgrade would overwrite them. Use these instead:
| Goal | File |
|---|---|
| Styles | assets/css/extended/*.css (one file, design tokens at the top) |
Extra <head> or scripts | layouts/_partials/extend_head.html, extend_footer.html |
| Extra content after a post | layouts/_partials/extend_post_content.html |
| Markdown behavior | layouts/_markup/render-*.html (images, callouts, math) |
| A page type | a copy of the theme template in your own layouts/, with a comment recording the theme commit it came from |
Write the project rules into AGENTS.md (structure, commands, “never edit the theme”, date format). The agent reads it at the start of every session.
Math: enable goldmark passthrough and render with transform.ToMath in a render hook; load the KaTeX CSS only on pages that contain math. Images: a render hook that resizes and converts to WebP keeps the repo and pages light.
3. Bring content in
- Put posts in
content/posts/. Required front matter:title,description,datewith an offset (2026-01-02T10:00:00+08:00),categories,tags. - When migrating from another generator, keep the old URLs working with
aliasesin front matter. Redirect old taxonomy URLs in CI, not locally (see pitfall 6). - Check locally:
hugo --gc --minifymust finish with no warnings.
4. Deploy: two repos and a deploy key
Why a separate output repo: the source repo can hold drafts and automatic backups, and the Pages repo stays tiny (one commit).
Create
<user>/<source>(the source) and<user>/<user>.github.io(the output). In the output repo set Pages to deploy from themainbranch.Create a deploy key pair on the machine, in a temporary directory:
ssh-keygen -t ed25519 -N "" -f /tmp/deploy_key -C "pages-deploy"Add the public key (
/tmp/deploy_key.pub) to the output repo as a deploy key with write access. Add the private key to the source repo as the secretACTIONS_DEPLOY_KEY. This is the credential step: ask the user to run the secret command. Delete the key files afterward.Add
.github/workflows/deploy.yml. It triggers only on thepublishbranch, so everyday backups onmainnever go live:on: { push: { branches: [publish] }, workflow_dispatch: {} } jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { submodules: recursive } - uses: peaceiris/actions-hugo@v3 with: { hugo-version: '0.169.0', extended: true } - run: hugo --gc --minify --baseURL "https://$(tr -d '[:space:]' < static/CNAME)/" - uses: peaceiris/actions-gh-pages@v4 with: deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} publish_dir: ./public external_repository: <user>/<user>.github.io publish_branch: main force_orphan: trueShip with
git push origin main && git push origin main:publish.
Use a deploy key rather than a personal access token. Tokens expire, and an expired one makes the deploy fail with a confusing error.
5. Custom domain and HTTPS
Put the domain in
static/CNAME(one line, no protocol). The workflow reads the base URL from this file.At the registrar add four
Arecords for the apex domain (@) pointing to185.199.108.153,185.199.109.153,185.199.110.153,185.199.111.153. This is a human step. ACNAMErecord on the apex does not work with some registrars, so useArecords.Wait until GitHub has issued the certificate, then enforce HTTPS:
gh api -X PUT repos/<user>/<user>.github.io/pages -f cname=<domain> -F https_enforced=trueIf this returns an error saying the certificate does not exist yet, wait and retry. It can take minutes to an hour.
6. Writing workflow with Obsidian
Use the open-source toolkit https://github.com/1-pluto1/obsidian-hugo-blog. Its installer turns content/posts into an Obsidian vault and adds a plugin with buttons for new post, preview and publish, image paste with WebP compression, and checks before publishing.
- Back up automatically to
main(Obsidian Git); publish with the plugin, which pushes topublish. - Exclude vault tooling from the site:
ignoreFiles: ['/posts/Templates/', '/posts/Scripts/', '/\.obsidian/']. Mount the images folder as assets withmodule.mountsso Markdown links likeattachments/a.pngresolve.
7. Work with the agent
Iterate locally with
hugo serverand look at real screenshots (light, dark, mobile). Do not push each small tweak.Batch the changes, then push once.
After every push, read the deploy result. Do not assume it worked:
gh run watch "$(gh run list -b publish -L1 --json databaseId -q '.[0].databaseId')" --exit-statusthen fetch the live page and check for the specific change you made.
Verify
All of these must hold:
hugo --gc --minifyprints no warnings.- The latest run on the
publishbranch ends withsuccess, and its head commit equals your localHEAD. curl -sI https://<domain>/returns200and the certificate is valid.http://<domain>/redirects to HTTPS.- A post with math, a post with an image, the search page and dark mode all render.
- Old URLs (if you migrated) answer with a redirect, not
404.
Pitfalls
- A web font works locally and fails on the live site. Some mirrors (for example npmmirror) reject requests whose
Refereris your domain. Test font URLs withcurl -H "Origin: https://<domain>" -e "https://<domain>/". jsDelivr worked. Compare local and live screenshots in the same browser. - Math shows unstyled. The page flag for “has math” is only known after the content renders, so a
<head>partial must touch.Contentfirst. - Words like
vector<int>or<PAD>vanish from a post. They are parsed as HTML. Wrap them in backticks. - Remote data fails locally.
resources.GetRemotecan be blocked by a local proxy. Make the template silently skip on error, so the build works everywhere and shows the data on CI. - Deploy fails before any job starts, with “workflow file issue”. Check https://www.githubstatus.com first. GitHub Actions had an outage during this build and nothing in the repository was wrong. Editing the workflow does not help.
- Case-insensitive file systems. If old URLs differ only by letter case (
/tags/LLMand/tags/llm), generate those redirects in CI on Linux, never on macOS. - Obsidian Git commits half-finished edits. Make larger refactors in a scratch copy or when the vault is idle, then apply them at once.
echo okafter a watch command hides failures. Use the exit code ofgh run watch --exit-status.
Prompts you can give an agent
- “Read
AGENTS.md, then restyle the home page. Change only CSS andlayouts/_partials/. Preview locally with screenshots in light, dark and mobile, and do not push until I say so.” - “Deploy failed. Read the run log and tell me whether it is our workflow or a GitHub incident before changing anything.”
- “Migrate these old posts. Keep the old URLs working with
aliases, convert external images to local files, and report any link that cannot be fixed.”