I wanted a blog that was boring to operate: Markdown in a Git repository, a push to publish, and no web server for me to maintain.
GitHub Pages is enough for that.
This walkthrough starts with the smallest possible site and verifies that GitHub can actually serve it before adding Jekyll, layouts, CSS, or anything else. That makes troubleshooting a lot easier than building the whole site first and wondering which part broke.
The commands use GitHub CLI because I wanted to do the setup from the terminal. gh is not a requirement for GitHub Pages. If you would rather create the repository and configure Pages in the GitHub web interface, that works too.
All usernames, paths, and other identifying values below are examples. Replace them with your own.
What You Need
At minimum:
- a GitHub account
- Git
- a text editor
For the CLI-only version:
- GitHub CLI (
gh)
Check Git:
git --version
Check GitHub CLI:
gh --version
On Gentoo, GitHub CLI is available as:
doas emerge --ask github-cli
If you are not using gh, create the repository through GitHub’s web interface and skip the CLI-specific commands below.
Authenticate GitHub CLI
Start the login:
gh auth login
For GitHub.com, the browser-based login is the easiest option on a normal workstation.
Afterward:
gh auth status
You should see the account you intended to use and no authentication errors.
One security rule worth establishing immediately: do not put access tokens into blog screenshots, shell examples, or Markdown files. There is almost never a reason to run a command that prints your GitHub token while writing a tutorial.
Get the Username from GitHub
Rather than typing the account name into every command, get it from the authenticated session:
GHUSER="$(gh api user --jq '.login')"
printf 'GitHub username: %s\n' "$GHUSER"
For a personal GitHub Pages site, the repository name is:
YOUR-GITHUB-USERNAME.github.io
Using $GHUSER also avoids accidentally creating the repository with a typo.
Create the Repository
I keep blog repositories under ~/blog/git, but the local path does not matter to GitHub.
mkdir -p ~/blog/git
cd ~/blog/git
Create the repository:
GHUSER="$(gh api user --jq '.login')"
gh repo create "${GHUSER}.github.io" \
--public \
--add-readme \
--description "Personal blog" \
--clone
Then enter it:
cd "${GHUSER}.github.io"
The --add-readme is useful here for more than convenience. It gives the new repository actual content immediately, which is enough for the first Pages smoke test.
Verify the Repository Before Doing Anything Else
Check the local side:
git --no-pager status
git remote -v
git branch --show-current
A fresh repository should be on its default branch with a clean working tree.
Now check GitHub’s view of it:
gh repo view \
--json nameWithOwner,visibility,url,defaultBranchRef
Things I care about at this point:
- correct owner
- repository named
YOUR-GITHUB-USERNAME.github.io - public visibility when using GitHub Free
- expected default branch
- correct
originremote
If any of those are wrong, fix them now. There is no reason to start building a site on top of a bad repository setup.
Check Whether Pages Is Already Enabled
This surprised me during setup: by the time I checked the new user-site repository, Pages was already configured and built.
You can query it directly:
GHUSER="$(gh api user --jq '.login')"
gh api \
"repos/${GHUSER}/${GHUSER}.github.io/pages" \
--jq '{
status: .status,
url: .html_url,
source: .source,
https_enforced: .https_enforced
}'
A working configuration may look roughly like:
{
"https_enforced": true,
"source": {
"branch": "main",
"path": "/"
},
"status": "built",
"url": "https://YOUR-GITHUB-USERNAME.github.io/"
}
If you get valid Pages configuration, leave it alone.
If the API instead returns HTTP 404 because Pages has not been configured, it can be enabled from the CLI:
GHUSER="$(gh api user --jq '.login')"
gh api \
--method POST \
"repos/${GHUSER}/${GHUSER}.github.io/pages" \
--input - <<'JSON'
{
"source": {
"branch": "main",
"path": "/"
}
}
JSON
If your default branch is not main, use the actual branch name rather than copying that value blindly.
Then query /pages again and make sure the configuration is what you expected.
First Checkpoint: Does GitHub Serve Anything?
Do this before adding Jekyll.
curl -I "https://${GHUSER}.github.io/"
Or, if all you care about is the result:
curl -fsS -o /dev/null \
-w 'Homepage: %{http_code}\n' \
"https://${GHUSER}.github.io/"
The useful result is:
Homepage: 200
At this point the important part is already proven:
local Git repository
|
v
GitHub repository
|
v
GitHub Pages
|
v
HTTPS request returns 200
That is the publishing path.
Everything after this is site structure and design.
Turn the Bare Site Into a Blog
Now add the minimum Jekyll structure.
Create a posts directory:
mkdir -p _posts
mkdir -p _layouts
Create _config.yml:
title: YOUR-BLOG-NAME
description: Notes on Unix, infrastructure, security, networking, and whatever else breaks.
url: "https://YOUR-GITHUB-USERNAME.github.io"
baseurl: ""
markdown: kramdown
permalink: /:year/:month/:day/:title/
Do not put secrets in this file. It is part of a public site repository.
Add a Minimal Layout
Create _layouts/default.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
How to Create a Free Blog on GitHub (CLI-Based) | Unix Magick
</title>
</head>
<body>
<main>
</main>
</body>
</html>
There is intentionally no theme or CSS here.
The goal is still to prove the content pipeline before working on appearance.
Add the Homepage
Create index.md:
---
layout: default
title: YOUR-BLOG-NAME
---
# YOUR-BLOG-NAME
A place for technical notes, troubleshooting, and project write-ups.
## Posts
- [Build a Hardened Arr Stack with Docker Compose: Gluetun, Hardlinks, and Health Checks](/2026/09/01/build-a-hardened-arr-stack/) — 2026-09-01
- [Operationalizing an Arr Stack: Health Invariants and Stateful Alerting](/2026/08/28/operationalizing-an-arr-stack/) — 2026-08-28
- [Hardening an Arr Stack: Defense in Depth from Torrent to Library](/2026/08/27/hardening-an-arr-stack/) — 2026-08-27
- [Building an Arr Stack: What Each Service Should Own](/2026/08/26/building-an-arr-stack/) — 2026-08-26
- [How to Create a Free Blog on GitHub (CLI-Based)](/2026/08/25/create-free-blog-github-cli/) — 2026-08-25
That loop means new posts automatically appear on the homepage.
Add the First Post
Jekyll posts use a date-prefixed filename:
_posts/YYYY-MM-DD-post-name.md
For a simple test:
---
layout: default
title: "Site Online"
date: 2026-01-01
---
The site is online.
Use the real publication date when you create your own file.
At this stage the site is ugly, which is fine. Ugly and working is much easier to improve than attractive and broken.
Review the Repository Before the First Push
This is the part I would not skip on a technical blog.
Start with:
git --no-pager status --short
Then stage only the files you meant to add:
git add \
_config.yml \
_layouts/default.html \
index.md \
_posts/
Review the staged diff:
git --no-pager diff --cached
Actually read it.
For a public technical blog, I specifically look for:
- usernames that were supposed to be anonymized
- email addresses
- internal hostnames
- internal IP addresses
- tokens or API keys
- terminal prompts containing identifying information
- paths containing names that do not need to be public
- screenshots that reveal more than the text around them
.gitignore is useful, but it is not a substitute for reviewing what you are about to publish.
If a secret was already committed, adding it to .gitignore afterward does not remove it from Git history.
Commit and Push
Once the staged diff is clean:
git commit -m "Create initial blog"
git push
Now GitHub has the Jekyll source.
Verify the Pages Build
Check the latest build:
GHUSER="$(gh api user --jq '.login')"
gh api \
"repos/${GHUSER}/${GHUSER}.github.io/pages/builds/latest" \
--jq '{
status: .status,
error: .error.message,
commit: .commit,
created_at: .created_at,
updated_at: .updated_at
}'
You are looking for a successful build and no error message.
Then test the homepage again:
curl -fsS -o /dev/null \
-w 'Homepage: %{http_code}\n' \
"https://${GHUSER}.github.io/"
And test the first post using its actual permalink:
curl -fsS -o /dev/null \
-w 'Post: %{http_code}\n' \
"https://${GHUSER}.github.io/YYYY/MM/DD/POST-NAME/"
Both should return:
200
Now there are two verified paths:
Markdown -> Jekyll -> homepage -> 200
Markdown -> Jekyll -> post -> 200
That is enough infrastructure for a real blog.
What I Would Not Do Yet
At this point I would stop.
No custom domain.
No analytics.
No third-party JavaScript.
No theme dependency.
No giant pile of plugins.
No redesign directly on the production branch.
Get the basic publishing path working first. Once that is boring and repeatable, make a separate branch for layout and design work and test it locally before merging it into production.
That is exactly where the interesting part starts, but it should not be mixed into the initial bring-up.
A Few Commands Worth Keeping Around
Repository state:
git --no-pager status --short
Review staged changes:
git --no-pager diff --cached
GitHub authentication:
gh auth status
Pages state:
gh api \
"repos/${GHUSER}/${GHUSER}.github.io/pages"
Latest Pages build:
gh api \
"repos/${GHUSER}/${GHUSER}.github.io/pages/builds/latest"
Public smoke test:
curl -fsS -o /dev/null \
-w '%{http_code}\n' \
"https://${GHUSER}.github.io/"
That is most of the operational surface area for a small static blog.
The rest is writing.