Overview

GitHub Pages is the right host for static sites that fit inside the free tier. Bandwidth and storage are generous for personal and project sites, the build runs inside GitHub Actions, and a custom domain plus HTTPS comes free. Reach past it only when you need server rendering, edge functions, or per-PR preview URLs.

Use GitHub Pages when the site is static and small

Pick GitHub Pages for documentation, marketing pages, blogs, and Quartz vaults. The free tier caps each site at 1 GB on disk and 100 GB of bandwidth per month soft limit. Sites with sustained traffic should put cloudflare in front to absorb the requests and protect the quota.

Move to vercel when you need SSR, edge functions, ISR, or per-PR previews.

Deploy via Actions, not “deploy from branch”

Set the Pages source to GitHub Actions in the repo settings under Pages. The legacy “Deploy from a branch” mode runs an opaque Jekyll build and locks you out of build steps. Actions-mode publishes whatever artifact you upload.

The deploy job needs exactly these permissions:

permissions:
  contents: read
  pages: write
  id-token: write

id-token: write is required for actions/deploy-pages@v4 to mint the OIDC token Pages checks. Omitting it produces a cryptic 403.

Wire the workflow with two jobs

Split build and deploy. The build job produces the artifact; the deploy job consumes it. This keeps the deploy environment isolated and matches the official template.

name: Deploy
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
  pages: write
  id-token: write
 
concurrency:
  group: pages
  cancel-in-progress: false
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npx quartz build
      - uses: actions/upload-pages-artifact@v3
        with: { path: public }
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

Set cancel-in-progress: false so a fast follow-up push does not abort a deploy mid-upload and leave the site half-published.

Put the CNAME file at the build root

Add a CNAME file (no extension, single line) containing the apex domain or subdomain. The file must end up at the root of the uploaded artifact, not inside static/ or assets/. For Quartz, put it at quartz/static/CNAME so the build copies it to public/CNAME.

GitHub Pages reads CNAME after each deploy and rewrites the custom-domain setting. If the file disappears from a build, the custom domain unsets. Pin it in source control.

Let GitHub provision HTTPS through Let’s Encrypt

Once DNS resolves to the Pages IPs, GitHub provisions a Let’s Encrypt certificate automatically. The toggle is “Enforce HTTPS” in repo settings under Pages. Wait up to 24 hours after pointing DNS before flipping it on; the issuance check fails silently if it runs early.

For DNS, see namecheap-dns and route the apex through cloudflare with SSL/TLS mode set to Full strict.

Debug 404s in this order

Most Pages 404s have one of three causes.

  • Wrong base path. If the site is at user.github.io/repo, the static build needs --base /repo/ or equivalent. Apex domains do not need a base.
  • Missing index.html at the artifact root. Run ls public/ after the build; the file must exist.
  • CNAME mismatched against the configured custom domain. The file content and the repo setting must match exactly.

For long-running content sites, add a custom 404 page at 404.html in the build output; Pages serves it for any unmatched path.