Private repos on a public static site are a question about where the token lives

  • astro
  • github
  • cloudflare

I wanted a projects page on this site that pulls its descriptions from GitHub. The problem is that nearly all of my repositories are private and the page is not. A request to api.github.com from the browser returns nothing for them without a token, and a token that reaches the browser has stopped being a token: anyone opens DevTools and walks off with whatever access that key carries.

Three options, one of which works

From the client. Dead on arrival for the reason above. Routing it through a build variable doesn’t save it either — if the value ends up in the JS bundle, it ends up in the browser. Astro guards this explicitly, as it happens: only variables prefixed PUBLIC_ are exposed to client code, everything else is server-side only.

An edge function. Cloudflare Pages holds secrets, the function calls GitHub per request, the token stays on the server. The data is always fresh. The cost is a runtime where there wasn’t one: now there’s a cache to think about, and a question of what a visitor sees when GitHub returns a 500.

One call at build time. That’s what I went with.

Why the build

Astro renders pages to HTML ahead of time, and the code in a page’s frontmatter runs in Node on the build machine. So an ordinary fetch there isn’t a visitor’s request, it’s a build step. What lands in dist/ is finished text, and there’s physically nothing in it to leak — no key, no request, no endpoint.

You can see it in the build log. The projects page is the only one that takes any real time:

/posts/hello/index.html    (+3ms)
/projects/index.html     (+393ms)

Those 393 ms are the round trip to GitHub. Once per deploy, not once per visitor.

The cost is clear enough: the data freezes until the next build. The “last commit” date on the site is really the date of the last site build. A deploy hook on a cron fixes that if it ever starts to matter; so far it hasn’t.

The other half of the problem isn’t the token

You think about the token immediately. This one, not so much. “Show my projects” turns very easily into “ask the API for all my repositories and render them” — and then a public page is carrying the names of every private repo I have, including the ones nobody needs to know exist. A name is information on its own.

So the source of truth is a file, not the API. One markdown file per project with a repo field. File exists → the project is on the site; no file → GitHub is never even asked about it. The API only enriches what’s already been cleared for publication: language, push date, topics. Anything written by hand wins over whatever comes back.

An environment-variable wrinkle

.env is read by Vite and lands in import.meta.env. A process environment variable lives in process.env. Cloudflare passes build variables the second way, a local .env arrives the first way, so you have to read both:

const token = process.env.GITHUB_TOKEN ?? import.meta.env.GITHUB_TOKEN;

I checked that by experiment rather than by reasoning: put a deliberately wrong token in .env. If the file weren’t being read, the build would have said the token was missing. Instead it printed three 401 lines, one per repository. So the file is read and the path works.

That also settled how the thing should behave with no token at all. A build on someone else’s machine, or a fresh clone, won’t have one, and falling over at that moment helps nobody: the cards just get built from what’s in the files and a warning goes to the log. An API error degrades the same way instead of failing the build.

What’s left

Refreshing on a schedule. Right now, for the commit date on the site to stop lying, something has to deploy — and deploys happen when I write a post, not when I commit code.

← all posts