---
title: "GitHub Pages Deployment"
slug: "github-pages"
category: "ops"
tags: ["ops", "github-pages", "ci"]
status: "stable"
last_updated: 2026-08-14
summary: "When to ship on GitHub Pages, how to deploy via Actions, and how to wire a custom domain through Cloudflare."
related: ["[[ops/cloudflare]]", "[[ops/namecheap-dns]]", "[[tooling/github]]", "[[tooling/quartz]]"]
---

## 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 [[tooling/github-actions|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 [[ops/cloudflare]] in front to absorb the requests and protect the quota. For the end-to-end Quartz deploy walkthrough, see [[howto/deploy-quartz-site]].

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

## Deploy via Actions, not "deploy from branch"

Set the Pages source to [[tooling/github-actions|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:

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

`id-token: write` is required for `actions/deploy-pages@v5` 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.

```yaml
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@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22 }
      - run: npm ci
      - run: npx quartz build
      - uses: actions/upload-pages-artifact@v5
        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@v5
```

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 [[ops/namecheap-dns]] and route the apex through [[ops/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.

## Related

- [[ops/cloudflare]]
- [[ops/namecheap-dns]]
- [[tooling/github]]
- [[tooling/quartz]]
