Building a serverless blog with a custom domain and CMS on the Hugo + GitHub + Decap CMS + Cloudflare Pages stack
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)
- GitHub account
- Cloudflare account (for Pages)
- Git (local)
- Hugo (for local previews and builds on Cloudflare)
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 (replacebaseURL
with your production URL)functions/api/*.js
— Cloudflare Pages Functions (/api/auth
and/api/callback
for GitHub OAuth)layouts/_default/*.html
— Hugo templatesstatic/admin/
— Decap CMS UI and configurationstatic/css/main.css
— Stylingstatic/_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
-
Create an empty repository on GitHub
- UI path: GitHub > New repository
- Example name:
my-hugo-blog
-
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
-
-
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
andfunctions/api/callback.js
are environment-agnostic because they useurl.origin
, so no direct edits are needed.
-
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)
-
UI path: GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
-
Fill in:
- Application name:
DecapCMS for my-hugo-blog
- Homepage URL:
https://www.example.com
- Authorization callback URL:
https://www.example.com/api/callback
← critical
- Application name:
-
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
-
UI path: Cloudflare dashboard > Pages > Create a project > Connect to Git
-
Connect GitHub and select the
my-hugo-blog
repository -
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 sectionGITHUB_CLIENT_SECRET
= value from the previous section
-
-
Click Save and Deploy and wait for the first deployment
- Successful runs issue a
*.pages.dev
preview URL
- Successful runs issue a
2-4. Assign the custom domain www.example.com
-
UI path: Cloudflare > Pages > (project) > Custom domains > Set up a custom domain
-
Enter
www.example.com
-
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
- Copy the DNS record details shown in Cloudflare (for example,
-
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
- Open
https://www.example.com/admin/
- Click Login with GitHub and approve OAuth access
- 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
andGITHUB_CLIENT_SECRET
.
3. Operations (authoring, local sync, template changes)
3-1. Create posts from the CMS
- Navigate to
https://www.example.com/admin/
- In the left navigation, select Blog → New blog
- Fill in
Title
,Publish Date
,Description
, andBody
- 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
-
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
-
-
Common customization points
layouts/_default/baseof.html
—<head>
content, header, footerlayouts/_default/index.html
— latest-posts list on the top pagelayouts/_default/single.html
— article body templatestatic/css/main.css
— colors, fonts, spacing
-
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
ormaster
) is consistent betweenstatic/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
tohugo.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 exactlyhttps://www.example.com/api/callback
and that the Cloudflare environment variablesGITHUB_CLIENT_ID
andGITHUB_CLIENT_SECRET
are correct - The home page works but article pages return 404
→ Confirm thatbaseURL
inhugo.toml
matches the real domain. Check the Cloudflare build logs to see thatpublic/
was generated - /admin renders a blank page
→ Ensure the Decap CMS scripts referenced bystatic/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:
-
Sitemaps and robots.txt
- Submit Hugo’s auto-generated
public/sitemap.xml
to Google Search Console - Customize crawler behavior with
static/robots.txt
- Submit Hugo’s auto-generated
-
OGP (Open Graph Protocol) and Twitter cards
- Add tags such as
<meta property="og:title">
and<meta property="og:image">
tolayouts/_default/baseof.html
- Make social shares look better
- Add tags such as
-
Convert images to WebP
- Use
resources/_gen/images/
and Hugo Pipes to automate WebP conversion - Speed up page loads
- Use
-
Add categories and tags
- Include
categories
andtags
in the front matter undercontent/blog/
and surface them in templates, for example as a tag cloud
- Include
-
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.