GitHub Pages serves static files from a repository over HTTPS, on a GitHub domain or one you own.
Static is the operative word, and it is a boundary rather than a limitation to work around. Pages serves files. It does not run your code, hold a database connection, or process a form. Understanding that precisely is most of what you need to decide whether it is the right host.
Two kinds of site
Section titled “Two kinds of site”Project sites publish from any repository and are served at:
https://USERNAME.github.io/REPOSITORY/Note the path prefix — the site lives in a subdirectory, not at the domain root. This breaks
absolute paths like /style.css, which is the single most common Pages problem. Most static site
generators have a base-path setting for exactly this.
User and organisation sites publish from a repository named USERNAME.github.io and are served
at the root:
https://USERNAME.github.io/One per account. No path prefix, so absolute paths behave normally.
How the site gets built
Section titled “How the site gets built”There are two deployment sources, and the difference matters.
Deploy from a branch
Section titled “Deploy from a branch”You nominate a branch and a directory — typically the repository root or /docs. GitHub serves what
is there, optionally running Jekyll over it first.
Simple, with one significant consequence: built output must be committed. If your site is generated, your repository now contains both source and build artefacts, and every content change produces a diff full of generated files.
Deploy with GitHub Actions
Section titled “Deploy with GitHub Actions”A workflow builds the site and uploads the result as a deployment artefact. Nothing built is committed.
This is the better default for any generated site. It supports any toolchain, keeps generated output out of history, and makes the build reproducible and inspectable. It is also how you use a generator GitHub does not natively support — which is most of them.
Custom domains and HTTPS
Section titled “Custom domains and HTTPS”Pages supports domains you own, with certificates provisioned automatically.
The DNS
Section titled “The DNS”For an apex domain (example.com), create A records — or ALIAS/ANAME if your provider
supports them — pointing at GitHub’s Pages addresses, which are published in GitHub’s documentation
and occasionally change.
For a subdomain (www.example.com or docs.example.com), create a CNAME record pointing at
USERNAME.github.io. Subdomains are simpler and generally preferable.
Configuring the domain in the repository creates a CNAME file in the published output. That file
is part of how Pages knows which domain to serve — deleting it unsets the domain.
Verify from the terminal rather than trusting the settings page:
dig +short docs.example.comcurl -sI https://docs.example.com | head -3What it doesShows what the domain currently resolves to, and confirms the served certificate matches.
Why we run itDNS changes propagate on their own schedule, and Pages cannot issue a certificate until resolution is correct. Checking directly distinguishes 'not propagated yet' from 'configured wrongly'.
Expected resultThe CNAME target or A records, then a 200 response with a valid certificate.
Once DNS resolves correctly, GitHub provisions a certificate automatically. Enable Enforce HTTPS afterwards, which redirects HTTP traffic.
The ordering matters: the certificate cannot be issued before the domain resolves to GitHub, so enabling enforcement too early produces errors that look like a broken configuration and are simply impatience.
What Pages cannot do
Section titled “What Pages cannot do”This is the section that saves time.
No server-side code. No PHP, no Python, no Node at request time. A build step can run anything; the serving is files only.
No database. Data must be built into the site, or fetched by client-side JavaScript from an API hosted elsewhere.
No form handling. A form needs an endpoint. Third-party form services exist for this.
No server-side redirects or custom headers. No .htaccess, no rewrite rules, no control over
response headers — which means you cannot set a Content-Security-Policy or HSTS header. Client-side
redirects via meta refresh are the usual workaround, and they are worse.
No authentication. A Pages site built from a private repository is still publicly served. There is no way to require a login.
Usage limits. GitHub publishes soft limits on site size, bandwidth and build frequency. Pages is intended for project and documentation sites, and a site that outgrows those limits has outgrown Pages.
When Pages is right, and when it is not
Section titled “When Pages is right, and when it is not”Good fits: project documentation, personal sites and portfolios, blogs, landing pages, API reference generated at build time, static demos.
Poor fits: anything needing a login, anything with a database, sites requiring control over headers or redirects, high-traffic commercial sites, and anything where the content must not be public.
The honest comparison. Several static hosts offer things Pages does not: header and redirect configuration, preview deployments per pull request, serverless functions for the small amount of dynamic behaviour most sites need, and edge caching. If you need any of those, use one of them. If you need static files served from a repository with no additional accounts or cost, Pages is excellent and requires nothing else.
Choosing Pages and then fighting its constraints is the failure mode. The constraints are clear and stable; check them against your requirements first.
Publishing checklist
Section titled “Publishing checklist”Before pointing anyone at a Pages site:
- Confirm the deployment source — branch, or Actions.
- If the site is generated, set the base path correctly for a project site’s subdirectory.
- Add
.nojekyllunless you are deliberately using Jekyll. - Configure a custom domain if you have one, and verify it at account level.
- Wait for DNS to resolve, then enable Enforce HTTPS.
- Check the site on a phone; check a deep link, not just the home page.
- Confirm nothing in the published output should have stayed private.
Step 6 catches the base-path problem, which typically works on the home page and fails everywhere else.
A working Actions deployment
Section titled “A working Actions deployment”The branch-based source is simpler; the Actions source is what you want for any generated site. The shape is consistent regardless of generator:
name: Deploy Pages
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 cache: npm - run: npm ci - run: npm run build - uses: actions/upload-pages-artifact@v5 with: path: ./dist
deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment uses: actions/deploy-pages@v5Three parts are worth understanding rather than copying.
The permissions block is required. pages: write allows deployment and id-token: write
allows the deployment action to authenticate. Omitting either produces a permissions failure that
reads like an account problem.
concurrency prevents two deployments racing. cancel-in-progress: false lets an in-flight
deployment finish rather than leaving the site half-updated — the opposite of what you usually want
for CI, and correct here.
Build and deploy are separate jobs. The build produces an artefact; the deploy consumes it. That separation is what keeps generated output out of your repository.
Base paths, concretely
Section titled “Base paths, concretely”The single most common Pages problem deserves a worked example.
On a project site served at https://user.github.io/my-project/, an absolute path resolves
against the domain root:
<!-- Requests https://user.github.io/style.css — 404 --><link rel="stylesheet" href="/style.css">The fix is either a relative path, or configuring the generator’s base path so it emits the prefix:
// Astroexport default defineConfig({ site: 'https://user.github.io', base: '/my-project',});// Viteexport default defineConfig({ base: '/my-project/' });The symptom is distinctive: the home page looks unstyled, and deep links 404. If you have a custom domain, the problem disappears entirely — which is why it often surfaces only in a preview deployment.
Debugging a Pages site
Section titled “Debugging a Pages site”# Is it deployed at all?gh api repos/OWNER/REPO/pages --jq '{status, html_url, source: .source, cname}'
# Recent deploymentsgh run list --workflow pages.yml --limit 5
# What is actually served?curl -sI https://your-site.example.com | head -5curl -s https://your-site.example.com | grep -o 'href="[^"]*"' | headThe last command is the quickest way to diagnose base-path problems: if the emitted links lack the project prefix and you are on a project site, that is your answer.
For custom domains, DNS is usually the delay rather than the fault:
dig +short your-site.example.comdig +short your-site.example.com CNAMEUntil this resolves to GitHub, no certificate can be issued and enabling HTTPS enforcement will fail — which looks like a broken configuration and is impatience.
Pages and repository size
Section titled “Pages and repository size”Pages serves what is in the repository or the deployment artefact, and both have limits.
The practical consequence for documentation sites: do not commit large binary assets. Images, video and PDFs accumulate in Git history permanently, and a repository that gains a hundred megabytes of screenshots is slow to clone forever, even after they are deleted.
Options, in order of preference: optimise aggressively before committing; generate derivatives at build time from a small source; or host large media elsewhere and reference it. For anything already committed and regretted, Git LFS and history rewriting are the remaining options, and both are disruptive.
Common mistakes
Section titled “Common mistakes”Absolute paths on a project site. /style.css resolves to the domain root, not your
subdirectory.
Forgetting .nojekyll. Directories starting with _ vanish silently.
Enabling Enforce HTTPS before DNS resolves. The certificate cannot exist yet.
Assuming a private repository yields a private site. It does not.
Committing build output when using Actions deployment. Pick one; doing both produces confusing diffs.
Leaving a custom domain configured on a deleted repository. Domain takeover risk.
Expecting redirects or headers. Pages serves files; it has no configuration layer.
Exercise
Section titled “Exercise”- In your practice repository, add an
index.htmlwith a heading and a linked stylesheet. - Enable Pages, deploying from a branch, and wait for the URL to serve.
- Note whether your stylesheet loaded. If you used
/style.css, it did not — fix it with a relative path and observe the difference. - Add a second page and link between them, confirming deep links work.
- Add
.nojekylland confirm the site still builds. - Inspect the response headers with
curl -sIand note the absence of any header you might have wanted to set.
Step 3 is the lesson. The subdirectory prefix on project sites causes more Pages confusion than everything else combined.
Choosing a generator
Section titled “Choosing a generator”Pages serves files; what produces them is your choice, and the choice matters more than the hosting.
| Generator | Suits |
|---|---|
| Jekyll | Built-in support; simple blogs and documentation |
| Astro / Eleventy / Hugo | General static sites, with an Actions build |
| MkDocs / Docusaurus / Starlight | Documentation with navigation and search |
| Hand-written HTML | A single page, or a demo |
The consideration specific to Pages is the base path. Any generator you choose must let you set one, because a project site is served from a subdirectory. Every generator listed does; a hand-rolled build script frequently does not, which is how the base-path problem gets discovered late.
Performance and caching
Section titled “Performance and caching”Pages sets its own caching headers and you cannot change them, which shapes what you can do.
Content-hashed filenames are essential. A stylesheet at style.css may be served from cache after
you update it. One at style.a3f8c21.css cannot be, because the URL changes when the content does.
Every modern generator does this by default; anything hand-rolled needs it added.
You cannot preload, push, or set cache-control. No fine-grained control over how assets are delivered.
No image optimisation at serve time. Anything you want optimised must be optimised at build time — which is a good habit anyway, given the repository-size point earlier.
For a documentation or project site none of this matters much. For something where delivery performance is a product requirement, it is one of the clearer reasons to use a host that gives you headers and an edge network.
Migrating away from Pages
Section titled “Migrating away from Pages”If a site outgrows Pages, the migration is usually straightforward because the output is just files.
- Confirm the build produces a static directory — it already does, or Pages could not serve it.
- Point the new host at the repository, or upload the artefact from CI.
- Set the base path back to
/if you were on a project site and the new host serves from the root. - Move the DNS record.
- Keep the Pages deployment running until DNS has propagated, then disable it.
- Remove the custom domain from the Pages settings, or the domain remains claimed.
Step 6 is the one people forget, and it matters: a custom domain left configured on a repository you no longer use is a takeover risk if that repository is later deleted or transferred.
What you learned
Section titled “What you learned”- Pages serves static files; the build can run anything, the serving cannot.
- Project sites live under a path prefix, which breaks absolute paths; user sites do not.
- Actions-based deployment keeps build output out of the repository and supports any toolchain.
- Custom domains need DNS to resolve before a certificate can be issued, and should be verified at account level to prevent takeover.
- A Pages site is public even when its repository is private.
- No headers, no redirects, no authentication, no server code — check these against your needs before committing to Pages.
One more consideration
Section titled “One more consideration”Pages is free for public repositories and included in plans for private ones, with no bandwidth bill attached. For a documentation site that is genuinely difficult to beat, and it is the reason so many open-source projects use it despite the constraints.
The constraints only become the dominant factor when you need something Pages cannot do at all — headers, redirects, authentication, or dynamic behaviour. Below that threshold, “free, integrated and adequate” is a strong position.