Build static first: when Hugo and GitHub Pages are enough, and when Cloudflare helps
In 2020, I wrote about leaving WordPress because I was tired of fighting editors, plugins, PHP, databases, and 40-second page loads.
Hugo fixed the first pain: write Markdown, build static HTML, deploy it somewhere boring.
This is the sequel to Goodbye, WordPress: hello Hugo + nginx with fast builds and sane deploys today .
It covers the setup I would build today, when I would use it, and how this site grew into CI/CD, search, validation, generated data, and a small Cloudflare backend.
Note
This article has two parts. Part 1 is the simple version: Hugo, GitHub Actions, GitHub Pages, and a short backend summary for when static HTML is no longer enough. Part 2 is the grown-up version: how this site grew into validation, search, generated data, CI/CD, and a small Cloudflare backend. This is not a “copy my entire repo” article. That would be the wrong lesson.
Why I am writing part 2
The original article was about escape velocity. WordPress was solving a problem I no longer had.
I did not need a database query to render a blog post. I did not need a plugin ecosystem to paste a code block. I definitely did not need an admin dashboard that made writing feel like data entry with extra sadness.
I wanted this:
- write locally,
- preview locally,
- keep content in Git,
- push once,
- let automation publish the site.
Hugo gave me that. Six years later, the better lesson is:
A static site can stay simple for a long time if you keep runtime behaviour at the edges.
Hugo owns content and pages. GitHub Actions owns repeatable builds. GitHub Pages owns static hosting. Cloudflare Workers owns the few API paths that need secrets, state, or webhooks.
No one piece is magical. The setup works because each piece has a boring job.
Part 1 - The simple version: Hugo + GitHub Pages first
Let’s separate the two stories clearly.
Start here before copying any mature personal-site setup:
- Hugo for content and static rendering,
- GitHub Actions for repeatable CI/CD,
- GitHub Pages for static hosting,
- a tiny backend summary only for cases where static HTML stops being honest.
For a blog, portfolio, docs site, project site, or technical writing home, this may be the whole setup. No Worker. No D1. No scheduled data pipeline. No cleverness tax.
Why Hugo still makes sense
Hugo turns content, templates, data files, assets, and config into static output: HTML, CSS, JavaScript, RSS, sitemap, and custom formats.
The important bit is not that Hugo is written in Go. The important bit is the publishing model.
| |
That model removes noise:
- no production database for ordinary posts,
- no admin login to protect,
- no runtime rendering for the same article again and again,
- no plugin update treadmill just to keep a blog online.
For engineers, the workflow also feels natural. Markdown is diffable. Frontmatter is clear. Page bundles keep assets near the article. Pull requests can review prose, diagrams, and metadata like code.
That is the real win. Not “static sites are cool”. Static sites are boring. Boring is the point.
What a wider check changes about the when
I checked wider usage before writing this: personal sites, docs, portfolios, static blogs, Git-backed publishing, and small business sites. The useful pattern was not “Hugo is best”. It was this:
| What people like | What it really means for the decision |
|---|---|
| Hugo is fast and low-maintenance | Use it when most pages can be generated ahead of time |
| Markdown content is portable | Use it when authors are comfortable with files and Git |
| GitHub Pages is cheap and simple | Use it when static hosting is enough |
| Themes and layouts are flexible | Use it when you want control and can tolerate template work |
| No database means less maintenance | Use it when runtime state is not central to the product |
| Hugo does not include a CMS | Avoid bare Hugo when non-technical editors need visual publishing |
| Static sites do not have runtime APIs by default | Add Workers/serverless only at the dynamic boundary |
That is why the WHEN section matters more than the setup commands. Most bad design starts when we choose a tool because it can do something, not because the problem needs it.
When Hugo + GitHub CI/CD + GitHub Pages is the right choice
Use this stack when your site is mostly content and your publishing flow matters more than runtime features.
| Use it when | Why it fits |
|---|---|
| You are building a personal blog | Markdown, Git history, local preview, low hosting cost |
| You need a portfolio or professional site | Static pages are fast, simple, and easy to serve behind a custom domain |
| You write technical articles | Code blocks, diagrams, page bundles, taxonomies, and internal links all fit naturally |
| You maintain project docs | Version-controlled content and repeatable builds beat hand-edited web pages |
| You want low maintenance | No CMS database, no PHP runtime, no plugin patching for normal content |
| You like Git-based review | Articles can move through branches, PRs, and CI checks |
| You publish from one main author or a small technical team | Everyone can work with Markdown, frontmatter, and local preview |
| You want cheap hosting | GitHub Pages can host static output; usually the domain is the only unavoidable cost |
| Your data can be generated at build time | Scripts can write JSON/YAML data before Hugo renders pages |
The key condition: Markdown and Git must be acceptable. For a developer, Hugo feels freeing. For a non-technical editor who wants a visual CMS, bare Hugo may feel like homework.
When not to use this stack
This is where people over-sell static sites. Hugo is not an app framework. GitHub Pages is not an app platform. GitHub Actions is not a database.
You can stretch the stack, but at some point you are just hiding an app inside a build pipeline.
| Avoid it when | Better direction |
|---|---|
| Non-technical editors need a visual publishing UI | WordPress, Ghost, hosted CMS, or Git-backed CMS |
| Content changes per user at request time | Full-stack app or API-backed frontend |
| You need user accounts and permissions | Dynamic backend with auth |
| You need dashboards or admin workflows | Application framework, not only Hugo |
| You need ecommerce/cart/order management | Shopify, WooCommerce, or custom app |
| You need real-time data | Runtime API, websocket/service layer, or app framework |
| You hate touching templates | Website builder or managed CMS |
| You need complex form workflows | Backend service, serverless functions, or form provider |
The failure mode is not “this is impossible”. You can add JavaScript, APIs, CMSs, webhooks, scheduled jobs, and serverless functions. The problem starts when the mental model stops being static, but the setup still pretends it is.
If you are building an app, build an app. If you are building content, start static.
How the simple version works
This is the version I would recommend to someone starting today.
flowchart LR
A["Write Markdown<br/>in site/content"] --> B["Push to GitHub"]
B --> C["GitHub Actions"]
C --> D["Hugo build"]
D --> E["Optional Pagefind<br/>search index"]
E --> F["GitHub Pages"]
F --> G["Custom domain"]
style A fill:#e1f5ff,stroke:#0066cc,color:#000
style B fill:#fff4e1,stroke:#cc8800,color:#000
style C fill:#f0e1ff,stroke:#8800cc,color:#000
style D fill:#f0e1ff,stroke:#8800cc,color:#000
style E fill:#fff4e1,stroke:#cc8800,color:#000
style F fill:#e1ffe1,stroke:#2d7a2d,color:#000
style G fill:#e1ffe1,stroke:#2d7a2d,color:#000
The flow:
- Hugo source lives in the repo.
- Articles live as Markdown page bundles.
- GitHub Actions runs the build.
- Hugo writes static output.
- Optional search/indexing runs after the build.
- GitHub Pages serves the generated files.
That is enough for a serious personal site.
The minimal repository can be as small as this:
| |
You do not need a monorepo, five workflows, or a backend on day one. You need one working path from Markdown to the live site.
DIY path: from zero to GitHub Pages
If you are starting fresh or moving from another host, use this path. I am assuming you already have:
- a GitHub account,
- Git installed,
- Hugo installed,
- a domain name if you want a custom domain.
No domain yet? Use https://<username>.github.io/<repo>/ first. Get the site live before touching DNS.
Step 1 - Create the repository
Create an empty GitHub repository. Skip the GitHub UI README if you plan to push a local Hugo site into it; that avoids one early merge.
Locally:
| |
Result: my-site is your source repo. Git tracks Hugo files and workflows. CI will create public/, so do not commit it.
Step 2 - Create the Hugo site
You can keep Hugo at the repository root. I prefer site/ because it leaves room for scripts, workflows, or backend code later:
| |
Result: Hugo creates the site under site/. Future scripts or backend code can live beside it.
Step 3 - Add a theme
Use one theme. Keep it boring first.
Example with a theme submodule:
| |
Then set it in site/hugo.toml:
| |
- The theme gives Hugo templates and styling.
baseURLmust match the deployed URL.- If you later use a custom domain, update
baseURLto that domain.
Tip
If your repository is named
<username>.github.io, your default URL is usuallyhttps://<username>.github.io/. If it is a project repository, the default URL is usuallyhttps://<username>.github.io/<repo>/. This matters forbaseURL.
Step 4 - Create a first post
| |
Edit the generated file:
| |
Then add a few paragraphs below the frontmatter.
index.mdcreates a page bundle.- Page bundles make it easy to keep images and article files together later.
draft: falsemeans the post can appear in production builds.
Step 5 - Test locally
| |
Open the local URL Hugo prints, usually http://localhost:1313/.
hugo serverstarts a local preview server.-Dincludes drafts while previewing.- If the site does not work locally, do not debug GitHub Actions yet. Fix local first.
Step 6 - Ignore generated output
From the repository root, create .gitignore:
site/public/
site/resources/_gen/
.hugo_build.lockpublic/is generated output.- GitHub Actions will rebuild it.
- You commit source, not build artifacts.
Step 7 - Add the GitHub Actions workflow
Create .github/workflows/deploy.yml:
| |
Result: the workflow checks out the repo, builds site/public, uploads the Pages files, and deploys them.
Step 8 - Enable GitHub Pages
In the GitHub repository:
- Go to
Settings. - Open
Pages. - Under source, choose GitHub Actions.
- Push to
main. - Open the Actions tab and watch the workflow.
If the workflow passes, your site should be live at the Pages URL.
Step 9 - Add a custom domain
If you own a domain, configure it after the default GitHub Pages URL works.
At a high level:
- Add the custom domain in GitHub Pages settings.
- Configure DNS at your domain provider.
- Wait for DNS to settle.
- Enable HTTPS in GitHub Pages.
- Update
baseURLinsite/hugo.toml.
Result: GitHub Pages serves the same files through your domain. Hugo uses baseURL for canonical links, RSS, social preview URLs, and absolute links.
What this looks like in my repo
The DIY path above is the beginner version. My repo is the grown-up version, so do not copy it blindly. These snippets are trimmed from the real source, with account names, personal workflows, and secrets removed.
At the root, scripts delegate into the Hugo site workspace:
| |
- The root repo only coordinates commands.
- Hugo still lives under
site/. - A beginner can ignore the workspace and run Hugo directly inside
site/. - Once the repo grows, root scripts keep commands predictable.
Inside site/package.json, the actual build is still boring:
| |
devis still the normal Hugo local server.build:hugorenders the static site.buildadds two earned extras: generated OG images and Pagefind search.testruns a small sanity check after rendering.
The real hugo.toml has more settings now, but the important part is this:
| |
baseURLis the production URL.enableGitInfolets Hugo use Git history for last-modified dates.permalinkspreserves stable post URLs.outputskeeps HTML and RSS explicit.params.imagesgives Open Graph/social cards a default image.
The production workflow is also the simple workflow with extra checks added:
| |
- The trigger is scoped to site-related files.
- The workflow checks out the theme submodule.
- Hugo builds
site/public. - Sanity tests run before publishing.
- Pagefind indexes the rendered site.
- The publishing step uploads only static output.
The point is the shape, not my exact deploy target.
Build order, CI/CD, and hosting
Start with the parts that shorten the path from writing to publishing.
| Build first | Delay until it hurts |
|---|---|
| Local Hugo server | Search |
| Clean content layout | Custom render hooks |
| One theme or small theme fork | OG image automation |
| Basic GitHub Actions deploy | Multiple workflows |
| Custom domain and HTTPS | Backend APIs |
| RSS, sitemap, and validation | Generated data pipelines |
GitHub Actions should do one clean build: checkout, install Hugo, build site/public, publish generated output. Local machines lie; CI starts from scratch.
GitHub Pages should only serve built files:
| |
For a new site, use the official Pages Actions flow. Move to a gh-pages branch or separate hosting repo only when source and published output must live separately.
For a custom domain: configure DNS, add the domain in Pages settings, enable HTTPS, then update baseURL. If baseURL is wrong, RSS, canonical URLs, social preview images, and absolute links quietly break.
When a backend becomes necessary
Most blogs do not need a backend. Add one only when a feature crosses the static-site boundary:
| Need | Why static HTML is not enough |
|---|---|
| Contact forms | You need spam protection, validation, and email/server-side delivery |
| Payments | Secrets, gateway calls, transaction state, and webhooks cannot live in the browser |
| Webhooks | External services need a server endpoint to call |
| Bot protection | Verification must happen server-side |
| Private API tokens | Anything secret must stay out of the built site |
| Stored state | Static files cannot safely record transactions or submissions |
| Scheduled data refresh | Build-time scripts can help, but runtime or scheduled jobs may be cleaner |
Cloudflare Workers fit when you want a small API near a static site without managing a server. Workers give you routes, serverless functions, D1 for small SQL state, R2 for object storage, and secrets outside the static build.
The rule I like:
Keep the site static. Put runtime behaviour behind /api/*.
That gives you a clean boundary.
How to add a backend without ruining the static site
The backend should not take over the site. For this site, the production shape is:
flowchart TD
A["Reader requests page"] --> B["GitHub Pages<br/>static Hugo output"]
A --> C["/api/* request"]
C --> D["Cloudflare Worker"]
D --> E["D1<br/>small SQL state"]
D --> F["R2<br/>object/archive storage"]
D --> G["External services<br/>email, gateway, verification"]
style A fill:#e1f5ff,stroke:#0066cc,color:#000
style B fill:#e1ffe1,stroke:#2d7a2d,color:#000
style C fill:#fff4e1,stroke:#cc8800,color:#000
style D fill:#f0e1ff,stroke:#8800cc,color:#000
style E fill:#e1f5ff,stroke:#0066cc,color:#000
style F fill:#e1f5ff,stroke:#0066cc,color:#000
style G fill:#ffe1e1,stroke:#cc0000,color:#000
The static site and API deploy separately:
- Hugo can build without backend secrets.
- The backend can deploy without rebuilding every article.
/api/*has its own health checks and tests.- Cloudflare secrets stay in Cloudflare.
- GitHub Pages remains a static host, not a confused application runtime.
The current backend uses TypeScript, Hono, Effect, Cloudflare Workers, D1, and R2. The libraries matter less than the split:
| |
That split is also how payment-style flows stay sane:
| |
I would not start here. I would start static and add this only when the first real runtime need appears.
Part 2 - The grown-up version: what this site became
Now for the grown-up version. Do not copy this on day one. This is what appears after repeated pain becomes visible: search, validation, social previews, generated data, and a few runtime APIs.
What this site grew into
The current site is still static at the core, but the build and support work around it are more serious:
- Hugo site under
site/ - theme as a submodule,
- site-level layout overrides,
- custom render hooks for links, tables, blockquotes, and Mermaid,
- shortcodes for article formatting and site features,
- content validation for frontmatter quality,
- Open Graph image generation,
- Pagefind static search,
- RSS, sitemap,
llms.txt, and SEO partials, - GitHub Actions for deploy, validation, accessibility, Lighthouse, backups, and generated data,
- TypeScript tooling for generated data workflows,
- Cloudflare Worker backend for runtime API paths,
- payment/webhook-style flows kept behind the Worker boundary,
- D1 and R2 for small stored backend state,
- docs and playbooks to keep decisions out of my head.
The current shape looks like this:
flowchart TD
A["Markdown articles<br/>site/content"] --> B["GitHub repository"]
B --> C["GitHub Actions<br/>site deploy"]
C --> D["OG image sync"]
D --> E["Hugo build"]
E --> F["Pagefind index"]
F --> G["GitHub Pages"]
H["Scheduled/generated data"] --> I["YAML/JSON snapshots<br/>site/data"]
I --> E
J["Cloudflare Worker"] --> K["/api/*"]
K --> L["D1"]
K --> M["R2"]
K --> N["External services"]
O["Docs and playbooks"] --> B
style A fill:#e1f5ff,stroke:#0066cc,color:#000
style B fill:#fff4e1,stroke:#cc8800,color:#000
style C fill:#f0e1ff,stroke:#8800cc,color:#000
style D fill:#f0e1ff,stroke:#8800cc,color:#000
style E fill:#f0e1ff,stroke:#8800cc,color:#000
style F fill:#fff4e1,stroke:#cc8800,color:#000
style G fill:#e1ffe1,stroke:#2d7a2d,color:#000
style H fill:#e1f5ff,stroke:#0066cc,color:#000
style I fill:#e1f5ff,stroke:#0066cc,color:#000
style J fill:#f0e1ff,stroke:#8800cc,color:#000
style K fill:#fff4e1,stroke:#cc8800,color:#000
style L fill:#e1f5ff,stroke:#0066cc,color:#000
style M fill:#e1f5ff,stroke:#0066cc,color:#000
style N fill:#ffe1e1,stroke:#cc0000,color:#000
style O fill:#e1ffe1,stroke:#2d7a2d,color:#000
This came from repeated pain:
- I needed better social previews, so OG automation appeared.
- I needed search without a server, so Pagefind fit.
- I needed consistent article metadata, so validation became useful.
- I needed generated public data, so scheduled workflows wrote Hugo data files.
- I needed runtime API behaviour, so Cloudflare Workers took
/api/*. - I needed to remember how all this works, so docs became part of the system.
That is the healthy order: pain first, automation second.
What I would do again
If I rebuilt this site today, I would keep these:
| Choice | Why I would keep it |
|---|---|
| Hugo for content rendering | Fast, flexible, good enough for deep technical writing |
| Markdown page bundles | Articles and assets stay together |
| GitHub Actions | Repeatable builds beat local-only deploys |
| GitHub Pages for static output | Simple, cheap, boring hosting |
| Pagefind | Static search without running a search backend |
| Content validation | Broken metadata is easier to catch before publishing |
| OG image automation | Social previews should not be manual work forever |
Separate /api/* backend | Runtime behaviour stays out of static rendering |
| Docs near the code | Future debugging starts with memory, not archaeology |
The underrated part is validation. Hosting gets attention, but long-running sites quietly collect broken metadata: missing authors, long descriptions, uneven tags, bad image fields, broken slugs. A boring validator catches that early.
What I would delay
I would delay almost everything else.
| Delay this | Until |
|---|---|
| Monorepo structure | You have multiple real packages or workflows |
| Backend API | Static HTML cannot safely solve the feature |
| Multiple deploy workflows | One workflow becomes too noisy |
| Heavy theme fork | You know what the theme cannot do |
| Custom shortcodes everywhere | Native Markdown stops being readable |
| Generated data pipelines | Manual update becomes repeated pain |
| Payment or webhook setup | You actually need server-side transactions |
| Complex analytics/ads/monetization | You have traffic worth measuring |
Automation feels productive. Sometimes it is. Sometimes it is just a neat way to avoid writing the next article. The best stack keeps the writing path short.
The decision rule I use now
Here is the decision tree:
flowchart TD
A["Do you mostly publish content?"] -->|Yes| B["Use Hugo"]
A -->|No| C["Build an app"]
B --> D["Can editors work with Git/Markdown?"]
D -->|Yes| E["Use GitHub Actions + GitHub Pages"]
D -->|No| F["Add a Git-backed CMS or choose a CMS"]
E --> G["Need secrets, forms, payments, or webhooks?"]
G -->|No| H["Stay fully static"]
G -->|Yes| I["Add backend under /api/*"]
I --> J["Keep static and dynamic deploys separate"]
style A fill:#fff4e1,stroke:#cc8800,color:#000
style B fill:#e1ffe1,stroke:#2d7a2d,color:#000
style C fill:#ffe1e1,stroke:#cc0000,color:#000
style D fill:#fff4e1,stroke:#cc8800,color:#000
style E fill:#e1ffe1,stroke:#2d7a2d,color:#000
style F fill:#fff4e1,stroke:#cc8800,color:#000
style G fill:#fff4e1,stroke:#cc8800,color:#000
style H fill:#e1ffe1,stroke:#2d7a2d,color:#000
style I fill:#f0e1ff,stroke:#8800cc,color:#000
style J fill:#e1f5ff,stroke:#0066cc,color:#000
That is the practical answer:
Use the static model while the problem is static. Add runtime pieces only where static stops being honest.
References
- Hugo official site - Hugo positioning, features, community, speed, asset pipeline, and use cases.
- Hugo: Host on GitHub Pages - Official deployment guidance for Hugo and GitHub Pages.
- GitHub Pages publishing source docs - GitHub Pages publishing model and GitHub Actions support.
- Cloudflare Developer Docs - Workers, static assets, D1, R2, and serverless primitives.
- Building a personal website with Hugo and GitHub Pages - Practical example with similar needs: fast, low maintenance, Markdown, version control, automated deploy, low cost.
- Hugo Discourse - Community forum and recurring discussions around Hugo usage, hosting, themes, and maintenance.
- r/gohugo - Practical questions and trade-offs around Hugo’s fit for static blogs, docs, portfolios, CMS workflows, and runtime limits.
TL;DR
- Hugo is a strong fit when the site is mostly content.
- GitHub Actions makes builds repeatable.
- GitHub Pages is enough for static hosting.
- Add search, validation, OG images, and generated data only when the site needs them.
- Add a backend only for secrets, state, forms, payments, webhooks, or runtime APIs.
- Keep runtime behaviour behind
/api/*. - Start static. Evolve only after the pain is real.
