21 Sep 2026
Updated:

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.

Courtesy note
Chandra Vanipenta acknowledged the original idea with review and feedback. That nudge helped me finally sit down and draft this sequel.

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.

1
Content + templates + data -> static files -> CDN/static host

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 likeWhat it really means for the decision
Hugo is fast and low-maintenanceUse it when most pages can be generated ahead of time
Markdown content is portableUse it when authors are comfortable with files and Git
GitHub Pages is cheap and simpleUse it when static hosting is enough
Themes and layouts are flexibleUse it when you want control and can tolerate template work
No database means less maintenanceUse it when runtime state is not central to the product
Hugo does not include a CMSAvoid bare Hugo when non-technical editors need visual publishing
Static sites do not have runtime APIs by defaultAdd 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 whenWhy it fits
You are building a personal blogMarkdown, Git history, local preview, low hosting cost
You need a portfolio or professional siteStatic pages are fast, simple, and easy to serve behind a custom domain
You write technical articlesCode blocks, diagrams, page bundles, taxonomies, and internal links all fit naturally
You maintain project docsVersion-controlled content and repeatable builds beat hand-edited web pages
You want low maintenanceNo CMS database, no PHP runtime, no plugin patching for normal content
You like Git-based reviewArticles can move through branches, PRs, and CI checks
You publish from one main author or a small technical teamEveryone can work with Markdown, frontmatter, and local preview
You want cheap hostingGitHub Pages can host static output; usually the domain is the only unavoidable cost
Your data can be generated at build timeScripts 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 whenBetter direction
Non-technical editors need a visual publishing UIWordPress, Ghost, hosted CMS, or Git-backed CMS
Content changes per user at request timeFull-stack app or API-backed frontend
You need user accounts and permissionsDynamic backend with auth
You need dashboards or admin workflowsApplication framework, not only Hugo
You need ecommerce/cart/order managementShopify, WooCommerce, or custom app
You need real-time dataRuntime API, websocket/service layer, or app framework
You hate touching templatesWebsite builder or managed CMS
You need complex form workflowsBackend 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.

Static sites have a boundary
The danger is not one API call. The danger is every page depending on runtime state, browser-side secrets, and build tricks nobody remembers.

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:

  1. Hugo source lives in the repo.
  2. Articles live as Markdown page bundles.
  3. GitHub Actions runs the build.
  4. Hugo writes static output.
  5. Optional search/indexing runs after the build.
  6. GitHub Pages serves the generated files.

That is enough for a serious personal site.

The minimal repository can be as small as this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
my-site/
├── site/
│   ├── content/
│   │   └── posts/
│   ├── layouts/
│   ├── static/
│   ├── themes/
│   └── hugo.toml
├── .github/
│   └── workflows/
│       └── deploy.yml
└── README.md

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:

1
2
3
mkdir my-site
cd my-site
git init

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:

1
2
hugo new site site
cd site

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:

1
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git site/themes/ananke

Then set it in site/hugo.toml:

1
2
3
4
baseURL = "https://<username>.github.io/<repo>/"
languageCode = "en-us"
title = "My site"
theme = "ananke"
  • The theme gives Hugo templates and styling.
  • baseURL must match the deployed URL.
  • If you later use a custom domain, update baseURL to that domain.

Tip

If your repository is named <username>.github.io, your default URL is usually https://<username>.github.io/. If it is a project repository, the default URL is usually https://<username>.github.io/<repo>/. This matters for baseURL.

Step 4 - Create a first post

1
hugo new posts/hello-hugo/index.md

Edit the generated file:

1
2
3
4
5
---
title: "Hello Hugo"
date: 2026-09-21T09:00:00Z
draft: false
---

Then add a few paragraphs below the frontmatter.

  • index.md creates a page bundle.
  • Page bundles make it easy to keep images and article files together later.
  • draft: false means the post can appear in production builds.

Step 5 - Test locally

1
hugo server -D

Open the local URL Hugo prints, usually http://localhost:1313/.

  • hugo server starts a local preview server.
  • -D includes 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.lock
  • public/ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: Deploy Hugo site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: "0.165.0"
          extended: true

      - name: Build
        working-directory: site
        run: hugo --minify --gc --cleanDestinationDir

      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: site/public

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

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:

  1. Go to Settings.
  2. Open Pages.
  3. Under source, choose GitHub Actions.
  4. Push to main.
  5. 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:

  1. Add the custom domain in GitHub Pages settings.
  2. Configure DNS at your domain provider.
  3. Wait for DNS to settle.
  4. Enable HTTPS in GitHub Pages.
  5. Update baseURL in site/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.

Do not debug everything at once
First make Hugo work locally. Then make GitHub Actions build. Then make GitHub Pages serve the default URL. Then add the custom domain. If you mix all four at once, every error looks like every other error.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "private": true,
  "packageManager": "[email protected]",
  "engines": {
    "node": ">=20"
  },
  "workspaces": [
    "site",
    "workers",
    "packages/*"
  ],
  "scripts": {
    "dev": "pnpm --filter site run dev",
    "build": "pnpm --filter site run build",
    "test": "pnpm --filter site run test",
    "build:hugo": "pnpm --filter site run build:hugo",
    "build:search": "pnpm --filter site run build:search"
  }
}
  • 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:

1
2
3
4
5
6
7
8
9
{
  "scripts": {
    "dev": "hugo server -D",
    "build": "pnpm run og:generate && hugo --minify --gc --cleanDestinationDir && pnpm exec pagefind --site public",
    "build:hugo": "pnpm run og:generate && hugo --minify --gc --cleanDestinationDir",
    "build:search": "pnpm exec pagefind --site public",
    "test": "./tests/sanity/html-validation.test.sh"
  }
}
  • dev is still the normal Hugo local server.
  • build:hugo renders the static site.
  • build adds two earned extras: generated OG images and Pagefind search.
  • test runs a small sanity check after rendering.

The real hugo.toml has more settings now, but the important part is this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
baseURL = "https://example.com/"
title = "Example site"
theme = "ink-free"
enableRobotsTXT = true
enableGitInfo = true

[frontmatter]
  lastmod = ["lastmod", ":git", "date", "publishDate"]
  date = ["date", "publishDate", "lastmod"]

[permalinks]
  posts = "/:year/:month/:title"

[outputs]
  home = ["html", "rss"]
  taxonomy = ["html", "rss"]
  term = ["html", "rss"]

[params]
  mainSections = ["posts"]
  enablePagefind = true
  images = ["/images/default-og.png"]
  • baseURL is the production URL.
  • enableGitInfo lets Hugo use Git history for last-modified dates.
  • permalinks preserves stable post URLs.
  • outputs keeps HTML and RSS explicit.
  • params.images gives Open Graph/social cards a default image.

The production workflow is also the simple workflow with extra checks added:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
name: build, test, audit & deploy site

on:
  push:
    branches: [ master ]
    paths:
      - 'site/content/**'
      - 'site/data/**'
      - 'site/themes/**'
      - 'site/static/**'
      - 'site/assets/**'
      - 'site/layouts/**'
      - 'site/hugo.toml'
      - '.github/workflows/**'
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          submodules: recursive

      - uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: "0.165.0"
          extended: true

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build Hugo site
        working-directory: site
        run: hugo --gc --minify --cleanDestinationDir

      - name: Run sanity tests
        run: pnpm --filter site run test

      - name: Build search index
        working-directory: site
        run: npx -y pagefind --site public

      - name: Publish static output
        uses: peaceiris/actions-gh-pages@v4
        with:
          publish_dir: ./site/public
  • 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 firstDelay until it hurts
Local Hugo serverSearch
Clean content layoutCustom render hooks
One theme or small theme forkOG image automation
Basic GitHub Actions deployMultiple workflows
Custom domain and HTTPSBackend APIs
RSS, sitemap, and validationGenerated 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:

1
2
GitHub Actions builds.
GitHub Pages serves.

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:

NeedWhy static HTML is not enough
Contact formsYou need spam protection, validation, and email/server-side delivery
PaymentsSecrets, gateway calls, transaction state, and webhooks cannot live in the browser
WebhooksExternal services need a server endpoint to call
Bot protectionVerification must happen server-side
Private API tokensAnything secret must stay out of the built site
Stored stateStatic files cannot safely record transactions or submissions
Scheduled data refreshBuild-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.

A practical backend rule
If a feature needs secrets, stored state, webhook handling, or server-side checks, it belongs behind an API boundary. If it only needs content and links, keep it inside Hugo.

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:

1
2
3
4
Static content -> Hugo + GitHub Pages
Dynamic edge   -> Cloudflare Worker under /api/*
Stored state   -> D1/R2, only where needed
Secrets        -> Worker/GitHub/Cloudflare secret stores, never Hugo content

That split is also how payment-style flows stay sane:

1
2
3
4
Static page      -> shows content, buttons, and client-safe metadata
Worker endpoint  -> validates request, talks to provider, handles webhooks
D1/R2            -> stores only the state the site actually needs
Secrets          -> stay in Cloudflare, never in Hugo, Markdown, or browser JS

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.

The useful pattern
Every extra part paid rent: search helped discovery, validation caught content mistakes, OG images improved sharing, and Workers handled runtime API edges.

What I would do again

If I rebuilt this site today, I would keep these:

ChoiceWhy I would keep it
Hugo for content renderingFast, flexible, good enough for deep technical writing
Markdown page bundlesArticles and assets stay together
GitHub ActionsRepeatable builds beat local-only deploys
GitHub Pages for static outputSimple, cheap, boring hosting
PagefindStatic search without running a search backend
Content validationBroken metadata is easier to catch before publishing
OG image automationSocial previews should not be manual work forever
Separate /api/* backendRuntime behaviour stays out of static rendering
Docs near the codeFuture 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 thisUntil
Monorepo structureYou have multiple real packages or workflows
Backend APIStatic HTML cannot safely solve the feature
Multiple deploy workflowsOne workflow becomes too noisy
Heavy theme forkYou know what the theme cannot do
Custom shortcodes everywhereNative Markdown stops being readable
Generated data pipelinesManual update becomes repeated pain
Payment or webhook setupYou actually need server-side transactions
Complex analytics/ads/monetizationYou 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


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.
Vitthal Mirji profile photo

Vitthal Mirji

Engineer building platforms - Data, Software & OSS

Stuttgart, 🇩🇪Germany

Data, Software & OSS | Staff Software Engineer | Sharing insights on Data Engineering, Functional programming, Scala, Rust, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Rust
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "10 Reasons why gcc SHOULD be re-written in JavaScript - You won't believe #8!"