I had been running this site on a custom domain connected to the classic Google Sites service that was bundled with Google Apps (the predecessor of Google Workspace). Google retired that legacy service worldwide in 2021, so I left https://www.ixam.net unused for quite a while. Eventually I decided it was time to make use of the domain again. The goal was a serverless architecture that would not be tied to a platform that could disappear without warning, and I tackled the rebuild together with ChatGPT.

I assumed ChatGPT would make this an easy ride, but the reality was different. I kept forgetting requirements, our troubleshooting went in circles, and I ended up combing through repositories such as https://github.com/patrickgrey/website/ to get unstuck. It was hard work, yet we finished it as a collaborative effort.

The experience felt like guiding a highly capable yet frequently misguided teammate back onto the rails—which is another way of saying that AI has become genuinely useful. At the same time, the more complicated things became, the easier it was to get frustrated with the mistakes.

My takeaway: rather than forcing the AI to keep track of a huge backlog of context in one conversation, it is better to pause, summarize the current state yourself, and then start a clean thread with fresh instructions.

Even so, I could not have achieved the required amount of research, trial and error, and study on my own. Generative AI is an incredible productivity boost.


Goal

  • Serve the blog at https://www.example.com/
  • Log in to Decap CMS at https://www.example.com/admin/ and create posts
  • Have new posts committed to GitHub and automatically deployed to Cloudflare
  • Keep the additional running costs at zero (domain registration costs are outside the scope here)

0. Prerequisites and folder layout

0-1. Prerequisites

  • A domain (for example, example.com) is already registered

  • Windows or macOS works (command examples for both are included)

  • Services used (free tiers are fine)

0-2. Example repository structure

hugo.toml
content/
  blog/
  posts/
data/
functions/
  api/
    auth.js
    callback.js
layouts/
  _default/
    baseof.html
    index.html
    list.html
    single.html
static/
  _headers
  admin/
    config.yml
    index.html
  css/
    main.css

This structure serves the following roles:

  • hugo.toml — Site-wide configuration (replace baseURL with your production URL)
  • functions/api/*.js — Cloudflare Pages Functions (/api/auth and /api/callback for GitHub OAuth)
  • layouts/_default/*.html — Hugo templates
  • static/admin/Decap CMS UI and configuration
  • static/css/main.css — Styling
  • static/_headers — Optional HTTP headers for Cloudflare Pages

Throughout the guide I label areas that need environment-specific changes as “Replace this” checkpoints.


1. Preparation

1-1. Create a GitHub account

  • Sign up for GitHub in your browser

1-2. Create a Cloudflare account

  • Sign up for Cloudflare in your browser

1-3. Install Git and Hugo

  • Windows (PowerShell)

    PS C:\Users\alice> winget install Git.Git
    PS C:\Users\alice> winget install Hugo.Hugo.Extended
    
  • macOS (Terminal)

    mac:~ dev$ brew install git
    mac:~ dev$ brew install hugo
    

2. Main procedure (up to the first deployment)

2-1. Set up the repository locally

  1. Create an empty repository on GitHub

    • UI path: GitHub > New repository
    • Example name: my-hugo-blog
  2. Clone it locally

    • Windows

      PS C:\work> git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      PS C:\work> cd .\my-hugo-blog
      
    • macOS

      mac:~/work dev$ git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      mac:~/work dev$ cd my-hugo-blog
      
  3. Place the folder and file set shown above directly under the repository

Replace this checklist (mandatory changes)

  • hugo.toml

    baseURL = "https://www.example.com"   # ← replace with your production URL (e.g., https://www.example.com)
    languageCode = "ja-jp"
    title = "Example Blog"
    publishDir = "public"
    
    [permalinks]
      blog = "/blog/:year/:month/:slug/"
    
  • static/admin/config.yml

    backend:
      name: github
      repo: <YOUR_GH_USERNAME>/my-hugo-blog   # ← replace with your GitHub repository
      branch: master                          # ← default branch name (main or master)
      base_url: https://www.example.com       # ← replace with your production URL
      auth_endpoint: /api/auth                # ← function endpoint (fixed)
    media_folder: static/uploads
    public_folder: /uploads
    

Reference: functions/api/auth.js and functions/api/callback.js are environment-agnostic because they use url.origin, so no direct edits are needed.

  1. Make the first commit and push

    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Initial commit: Hugo + DecapCMS + CF Pages"
      PS C:\work\my-hugo-blog> git push -u origin master
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Initial commit: Hugo + DecapCMS + CF Pages"
      mac:~/work/my-hugo-blog dev$ git push -u origin master
      

Store the values listed in the next step as environment variables in Cloudflare Pages.


2-2. Create a GitHub OAuth App (for Decap CMS login)

  1. UI path: GitHub > Settings > Developer settings > OAuth Apps > New OAuth App

  2. Fill in:

    • Application name: DecapCMS for my-hugo-blog
    • Homepage URL: https://www.example.com
    • Authorization callback URL: https://www.example.com/api/callbackcritical
  3. After creating the app, note down:

    • Client ID
    • Client Secret (generate a new secret and save it)

These values will be used as Cloudflare Pages environment variables.


2-3. Create a Cloudflare Pages project

  1. UI path: Cloudflare dashboard > Pages > Create a project > Connect to Git

  2. Connect GitHub and select the my-hugo-blog repository

  3. Build settings:

    • Framework preset: None (or let Cloudflare auto-detect)

    • Build command: hugo

    • Build output directory: public

    • Environment variables:

      • HUGO_VERSION = 0.128.0 (example—match your local version for peace of mind)
      • GITHUB_CLIENT_ID = value from the previous section
      • GITHUB_CLIENT_SECRET = value from the previous section
  4. Click Save and Deploy and wait for the first deployment

    • Successful runs issue a *.pages.dev preview URL

2-4. Assign the custom domain www.example.com

  1. UI path: Cloudflare > Pages > (project) > Custom domains > Set up a custom domain

  2. Enter www.example.com

  3. If the domain is not fully managed by Cloudflare:

    • Copy the DNS record details shown in Cloudflare (for example, CNAME www -> <project>.pages.dev)
    • Configure those records at your registrar following their documentation
  4. After the DNS change propagates, confirm that https://www.example.com/ serves the site

Tip: if the domain is already on Cloudflare, one click adds the necessary DNS records automatically.


2-5. Log in to the Decap CMS admin UI

  1. Open https://www.example.com/admin/
  2. Click Login with GitHub and approve OAuth access
  3. On the first run, GitHub prompts for Authorize—allow it

If login fails, double-check the OAuth callback URL and the Cloudflare environment variables GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.


3. Operations (authoring, local sync, template changes)

3-1. Create posts from the CMS

  1. Navigate to https://www.example.com/admin/
  2. In the left navigation, select BlogNew blog
  3. Fill in Title, Publish Date, Description, and Body
  4. Click Publish to commit to GitHub → Cloudflare deploys automatically

The generated Markdown files appear under content/blog/.

3-2. Pull the latest content locally after posting from the CMS

  • Windows

    PS C:\work\my-hugo-blog> git pull origin master
    
  • macOS

    mac:~/work/my-hugo-blog dev$ git pull origin master
    

This keeps your local repository in sync so you can safely edit templates or CSS.

3-3. Tweak designs and templates locally

  1. Start the local development server

    • Windows

      PS C:\work\my-hugo-blog> hugo server -D
      
    • macOS

      mac:~/work/my-hugo-blog dev$ hugo server -D
      
    • Open http://localhost:1313/ in your browser to preview

  2. Common customization points

    • layouts/_default/baseof.html<head> content, header, footer
    • layouts/_default/index.html — latest-posts list on the top page
    • layouts/_default/single.html — article body template
    • static/css/main.css — colors, fonts, spacing
  3. Commit and push changes

    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Update theme/layouts"
      PS C:\work\my-hugo-blog> git push
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Update theme/layouts"
      mac:~/work/my-hugo-blog dev$ git push
      
    • Cloudflare deploys automatically within seconds to minutes

3-4. Branch management tips

  • Make sure the default branch (main or master) is consistent between static/admin/config.yml and the Cloudflare project settings
  • When you open a pull request on GitHub, Cloudflare Pages issues a preview environment automatically

4. Optional enhancements

4-1. Example static/_headers (disable caching for the admin UI)

/admin/*
  Cache-Control: no-store

4-2. Robots and sitemap

  • Provide static/robots.txt to control crawlers
  • Add outputs to hugo.toml if you want extra outputs such as RSS or sitemaps

5. Common pitfalls and remedies

  • CMS login loops endlessly
    → Verify that the GitHub OAuth callback URL is exactly https://www.example.com/api/callback and that the Cloudflare environment variables GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are correct
  • The home page works but article pages return 404
    → Confirm that baseURL in hugo.toml matches the real domain. Check the Cloudflare build logs to see that public/ was generated
  • /admin renders a blank page
    → Ensure the Decap CMS scripts referenced by static/admin/index.html are not blocked by extensions or CSP; disable extensions and reload

6. “Replace this” checklist (summary)

File / setting Key Example value to set
hugo.toml baseURL https://www.example.com
static/admin/config.yml repo <YOUR_GH_USERNAME>/my-hugo-blog
static/admin/config.yml branch Match your default branch (main/master)
static/admin/config.yml base_url https://www.example.com
Cloudflare Pages HUGO_VERSION e.g., 0.128.0
Cloudflare Pages GITHUB_CLIENT_ID GitHub OAuth App client ID
Cloudflare Pages GITHUB_CLIENT_SECRET GitHub OAuth App client secret
GitHub OAuth App Callback URL https://www.example.com/api/callback
Cloudflare Pages Custom domain Add www.example.com

7. Appendix: sample files (replace the marked sections)

7-1. functions/api/auth.js

// Cloudflare Pages Functions (/api/auth)
export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  try {
    const url = new URL(request.url);
    const redirectUrl = new URL('https://github.com/login/oauth/authorize');
    redirectUrl.searchParams.set('client_id', client_id);
    redirectUrl.searchParams.set('redirect_uri', `${url.origin}/api/callback`);
    redirectUrl.searchParams.set('scope', 'repo user');
    redirectUrl.searchParams.set('state', crypto.getRandomValues(new Uint8Array(12)).join(''));
    return Response.redirect(redirectUrl.href, 302);
  } catch (err) {
    return new Response(String(err?.message || err), { status: 500 });
  }
}

7-2. functions/api/callback.js

function renderBody(status, content) {
  const html = `
  <script>
    const receiveMessage = (message) => {
      window.opener.postMessage(
        'authorization:github:${status}:${JSON.stringify(content)}',
        message.origin
      );
      window.removeEventListener("message", receiveMessage, false);
    }
    window.addEventListener("message", receiveMessage, false);
    window.opener.postMessage("authorizing:github", "*");
  </script>`;
  return new Blob([html]);
}

export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  const client_secret = env.GITHUB_CLIENT_SECRET;
  try {
    const url = new URL(request.url);
    const code = url.searchParams.get('code');
    const response = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'user-agent': 'cf-pages-oauth', 'accept': 'application/json' },
      body: JSON.stringify({ client_id, client_secret, code }),
    });
    const result = await response.json();
    if (result.error) {
      return new Response(renderBody('error', result), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 401 });
    }
    const token = result.access_token;
    const provider = 'github';
    return new Response(renderBody('success', { token, provider }), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 200 });
  } catch (error) {
    return new Response(error.message, { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 500 });
  }
}

7-3. static/admin/config.yml

backend:
  name: github
  repo: <YOUR_GH_USERNAME>/my-hugo-blog
  branch: main
  base_url: https://www.example.com
  auth_endpoint: /api/auth

media_folder: static/uploads
public_folder: /uploads

collections:
  - name: 'blog'
    label: 'Blog'
    folder: 'content/blog'
    create: true
    slug: '{{slug}}'
    editor:
      preview: false
    fields:
      - { label: 'Title', name: 'title', widget: 'string' }
      - { label: 'Publish Date', name: 'date', widget: 'datetime' }
      - { label: 'Description', name: 'description', widget: 'string' }
      - { label: 'Body', name: 'body', widget: 'markdown' }

9. SEO and performance tips

Once the blog is up and running, these steps can improve search visibility and loading speed:

  1. Sitemaps and robots.txt

    • Submit Hugo’s auto-generated public/sitemap.xml to Google Search Console
    • Customize crawler behavior with static/robots.txt
  2. OGP (Open Graph Protocol) and Twitter cards

    • Add tags such as <meta property="og:title"> and <meta property="og:image"> to layouts/_default/baseof.html
    • Make social shares look better
  3. Convert images to WebP

    • Use resources/_gen/images/ and Hugo Pipes to automate WebP conversion
    • Speed up page loads
  4. Add categories and tags

    • Include categories and tags in the front matter under content/blog/ and surface them in templates, for example as a tag cloud
  5. Use structured data

    • Provide article metadata in JSON-LD to increase the odds of rich results

8. Wrap-up

  • Create a GitHub repository, connect it to Cloudflare Pages, configure GitHub OAuth for Decap CMS, and map the custom domain—that gets you to the finish line
  • Authors publish from /admin/ → GitHub commits → Cloudflare deploys automatically
  • Tweak templates and designs locally with hugo server, then push via Git

This is all you need to run and operate a serverless blog powered by Hugo, GitHub, Decap CMS, and Cloudflare.

I now have the ultra-lightweight foundation I wanted, although features such as site search, categories, tags, and tag clouds still lag behind the big blogging platforms. The plan is to expand the setup step by step with ChatGPT as my partner.