How to Host a Private Jekyll Source on GitHub Pages Without Paying for Pro

GitHub Pages on the Free plan only builds from public repos. To keep your source private, GitHub charges $4/month for Pro. Here’s how to get the same result for $0 by splitting source and built output into two repos.

Why Pages and private repos don’t get along on Free

GitHub Pages on a free personal account only builds from public repositories. The “Pages from private repos” feature is gated behind GitHub Pro ($4/month). $48 a year is a fine price for the convenience, but I was curious whether there was a cleaner workaround that didn’t need it.

The trick is that Pages doesn’t care where your source code lives — it only cares about what’s in the repo it’s serving from. So if you build the site somewhere else and only push the built output to a public repo, the source can stay private.

The two-repo split

Here’s the setup I ended up with:

flowchart LR
    A["takasoft/takasoft.io-private-source<br/><b>(PRIVATE)</b><br/>Jekyll source"]
    B["takasoft/takasoft.github.io<br/><b>(PUBLIC)</b><br/>built _site/<br/>single orphan commit"]
    C(["GitHub Pages<br/>serves at<br/>takasoft.io"])
    A -- "GitHub Action:<br/>force-push orphan commit" --> B
    B --> C

A GitHub Action in the private source repo runs jekyll build, then force-pushes a single orphan commit of the built _site/ directory to the gh-pages branch of the public output repo. Pages picks up the change and serves it at takasoft.io as usual.

Three things make this work cleanly:

  1. force_orphan: true — every deploy is a single rootless commit. No history accumulates on the public side. Anyone browsing the public repo only ever sees the most recent build.
  2. Cross-repo PAT — the workflow needs a fine-grained personal access token scoped only to the output repo with Contents: read and write. Stored as a secret in the source repo.
  3. CNAME handled by the actionpeaceiris/actions-gh-pages writes the CNAME file to the published branch directly, so I don’t need to commit it to source.

The workflow

name: Build and deploy to Pages

on:
  push:
    branches: [master]
  workflow_dispatch:

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true
      - run: bundle exec jekyll build --safe
      - uses: peaceiris/actions-gh-pages@v4
        with:
          personal_token: $
          external_repository: takasoft/takasoft.github.io
          publish_branch: gh-pages
          publish_dir: ./_site
          force_orphan: true
          cname: takasoft.io

That’s the entire deploy pipeline. About 25 lines of YAML.

Migrating without downtime

If you’re doing this migration on an existing blog that’s already serving from master / (root), you want to avoid any window where takasoft.io is broken. The trick is to add the new infrastructure first and only remove the old after the new path is confirmed working. Until the final step, the public repo’s master branch still has your original source and Pages still serves from it — so flipping back is a one-click rollback.

  1. Create the new private source repo and push your code there. Nothing changes on the public side. The live site keeps serving from master as before.

  2. Create a fine-grained PAT and store it as a secret on the private repo. Scope it to only the public output repo with Contents: Read and write. Add it as a repository secret named PAGES_DEPLOY_TOKEN. A fine-grained PAT (as opposed to a classic one) limits the blast radius if the secret ever leaks.

  3. Add the workflow and run it once. The first deploy creates a new gh-pages branch on the public repo with your built site. The public repo’s master is untouched; the live site still serves the old build. This gives you a chance to inspect the contents of gh-pages before exposing it to visitors.

  4. Flip Pages to deploy from gh-pages. In Settings → Pages on the public repo, switch the source from master / (root) to gh-pages / (root). Then trigger a manual rebuild — Pages doesn’t auto-rebuild on a source-branch change:

    gh api -X POST repos/<owner>/<repo>/pages/builds
    
  5. Verify. curl -sI https://yoursite.com should return 200 with a fresh Last-Modified header. Spot-check a known post URL and an asset under /public/. If anything looks wrong, flip Pages source back to master / (root) — instant rollback to the pre-migration state.

  6. Only now, delete the public repo’s master. This is the destructive step that makes old source commits unreachable. The default branch must move first (GitHub refuses to delete the current default):

    gh api -X PATCH repos/<owner>/<repo> -f default_branch=gh-pages
    gh api -X DELETE repos/<owner>/<repo>/git/refs/heads/master
    

Until step 6, everything is reversible. After step 6, the old source history becomes unreachable on github.com — though as noted below, “unreachable” isn’t quite the same as “gone”.

Gotchas I hit

A couple of things bit me along the way that I want to remember.

Jekyll’s exclude: replaces the defaults — it doesn’t extend them.

I added a few entries to exclude: in _config.yml to keep some files out of the build:

exclude:
  - README.md
  - .ruby-version
  - .github

The build immediately broke with:

Invalid date '<%= Time.now.strftime(...) %>': Document
'vendor/bundle/ruby/3.3.0/gems/jekyll-3.10.0/lib/site_template/_posts/...'
does not have a valid date in the YAML front matter.

Jekyll was trying to render its own gem-internal template files as blog posts. It turns out that when you set exclude: yourself, you wipe out Jekyll’s built-in default exclusion of vendor/bundle/, node_modules/, Gemfile.lock, etc. — and bundler-cache: true puts gems in vendor/bundle/ inside the workspace, where Jekyll then sees them.

Fix: re-include the defaults manually. Annoying but easy once you know.

Pages doesn’t auto-rebuild when you change the source branch.

After flipping Pages settings from master / (root) to gh-pages / (root), I expected the site to rebuild immediately against the new branch. It didn’t — Last-Modified headers kept showing the old build’s timestamp. The fix is to trigger a build manually:

gh api -X POST repos/<owner>/<repo>/pages/builds

After that, future pushes get picked up automatically; Pages just doesn’t react to the source-branch setting change on its own.

What this hides and what it doesn’t

Hidden:

  • The git history of the source. Anyone visiting github.com/takasoft/takasoft.io-private-source gets a 404.
  • The Jekyll source files (_layouts/, _includes/, _config.yml, etc.). The public repo only has built HTML.
  • New commit SHAs on the public side, since the orphan force-push makes every previous commit unreachable.

Not hidden:

  • The site content itself — by definition it’s published.
  • Old commits from before the migration. They become unreachable, but GitHub doesn’t garbage-collect them immediately — direct-SHA URLs may still resolve for weeks. Forks (if any exist) keep them forever.

For a personal blog this trade-off is fine. The 8 years of commit messages I cared about hiding are now functionally unreachable for anyone who doesn’t already have an old SHA bookmarked.

Was it worth it

Sort of. The single-repo setup you get with GitHub Pro would have saved me maybe an hour of fiddling, plus a yearly calendar reminder to rotate the PAT. But the two-repo split is essentially fire-and-forget once it’s set up, and there’s something satisfying about getting the same outcome for free on a platform that wants to charge for it.

If you try this route, the only recurring task is rotating the deploy PAT yearly. GitHub emails you when it’s about to expire, so it’s hard to forget.