GUIDES

How to Create a Free Blog on GitHub (CLI-Based)

Animated terminal introduction for the GitHub Pages CLI guide
Unix Magick terminal intro for this guide.

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 origin remote

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.