Showing your private GitHub work on your profile, without leaking a single repo name
Most of what I build as a freelancer lives in private client repositories. That's normal for client work, but it made my GitHub profile look half-empty: the public stats cards counted only the 20% of my work that happened to be public.
Then the public github-readme-stats instance went down (#3851), and my cards turned into broken-image icons. I took that as the push to build my own: a small Cloudflare Worker that counts private work safely and that I now run for free.
It's open source: kiri1101/github-profile-cards (MIT, with a Deploy to Cloudflare button).
The privacy problem: totals yes, names never
The goal was simple to state: show how much private work I do, without revealing what it is. No repository names, no client names, ever, not on the card and not in logs.
So the Worker only ever stores and renders totals. Each row on the card shows public, private and total, where private is simply total − public. The public part comes from counting only contributions in public repositories. The names never leave GitHub's API response; they're summed away immediately.
Why it needs two tokens
This surprised me. No single GitHub token gives you everything:
- A classic token with only read:user returns your total commit contributions for the year, private ones included. But the PR and issue counts it can see are public-only.
- A fine-grained token with read-only Metadata, Issues and Pull requests on all your repositories sees your private PRs and issues. It also sees your repositories' language breakdown.
Neither token can write anything. The Worker reads which account owns them (query { viewer { login } }), so there's no username to configure. If the two tokens belong to different accounts, it refuses to refresh rather than mixing two people's numbers on one card.
Cron + KV: never rate-limited, never "down"
Rendering cards on every request is what makes public instances expensive and fragile. This Worker does the opposite:
- A cron trigger every 6 hours fetches the numbers, validates them, renders light and dark SVGs, and stores them in Workers KV.
- Requests just read KV, which is fast and costs nothing on the free plan. If KV is ever empty or unreachable, you get a placeholder card, never a broken image.
- New data is validated before it replaces the old: a sudden drop (say, a counter falling by more than 10%) is held back until the same drop repeats four times, so a flaky API response can never wipe your card.
A GitHub README trap worth knowing
If you wrap a <picture> (the light/dark image switcher) in a link, GitHub's README renderer moves the <img> into its own image link outside your anchor. Clicks then open the raw image, and theme switching breaks. The fix for linked cards: one SVG that themes itself with @media (prefers-color-scheme: dark) inside, used as a plain <a><img></a>.
Tests that can actually fail
There's a fair complaint going around that AI-assisted code often comes with tests that always pass, because they were written to match the code rather than the feature. I wanted proof mine weren't like that, so I used mutation testing (StrykerJS). It plants small bugs in your code (flips a < to <=, deletes a call, changes a constant) and checks whether any test notices.
The first run scored 84%: one planted bug in six went unnoticed by a suite that looked fine by coverage. After adding sharp tests whose expected values come from the spec, not from the code's own output, the critical modules (grade formula, validation, privacy split, escaping, refresh safety) score 100%. CI fails if it ever drops.
I also deleted 30 tests that caught none of the planted bugs. Fewer tests, each of which matters.
Try it
1. Click Deploy to Cloudflare in the README.
2. Paste two tokens (the README links straight to GitHub's token pages with the right scopes).
3. Embed the two SVG URLs in your profile README.
It runs on the free plan, and an unedited copy shows neutral cards. You can add your own project cards and tech-stack blocks in one config file.
The grade uses the calculateRank formula from github-readme-stats, credit where it's due. Issues and ideas are welcome on the repo.
GitHub Readme Stats Not Working? How to Fix 503, Rate Limits, 403 and Broken Images
You open your GitHub profile and the stats card is gone. All that's left is a broken image icon, or a grey "Something went wrong" box. Nothing changed in your README, so what broke?
Almost always, the problem is the public service that draws the card, not your Markdown. Below is how I narrow it down, and three fixes that actually last.
Last verified: Oct 14, 2026.
TL;DR
| Symptom | Likely cause | Fastest fix |
|---|---|---|
| 503 / DEPLOYMENT_PAUSED | Public github-readme-stats instance paused | GitHub Actions or self-host |
| "Something went wrong" card | GitHub API rate limit or a fetch error | Your own token (Actions / self-host) |
| 403 Forbidden on streak card | Public streak endpoint blocking requests | Streak Stats GitHub Action |
| Broken image icon, no error | Slow response or a cached failure behind GitHub's image proxy | Static SVG in your repo |
| Card shows the wrong data | Typo, wrong username, cache | Check the URL and wait out the cache |
Step 1: Is it you or the service?
Copy the image URL out of your README and open it straight in your browser. Or check the headers:
curl -I "https://github-readme-stats.vercel.app/api?username=YOUR_USERNAME"
curl -I "https://streak-stats.demolab.com/?user=YOUR_USERNAME"
- 200 with image/svg+xml and a normal card: the service is fine. The problem is in your README (see Step 6) or a stale cached copy on GitHub.
- 200 but the SVG says "Something went wrong": the service is up but couldn't fetch your data. This is usually a rate limit.
- 503: the instance itself is down or paused.
- 403: the endpoint is refusing the request.
When I checked on Oct 14, both public endpoints returned 200. They work most days, but they go down every so often, so "it works right now" doesn't mean it'll still work tomorrow.
Step 2: Know why the public instances break
503 DEPLOYMENT_PAUSED. In #3851 ("Public deployment paused"), the maintainers explain that Vercel's sponsorship ended. They point people to GitHub Actions or self-hosting instead. #4737 and #4798 ("not working again ?") are both still open. The repo README now also says the project is no longer maintained and recommends its successor, GitHub Stats Extended.
Rate limits. The README says the public instance is "best-effort and can be unreliable due to rate limits and traffic spikes." Thousands of profiles share one pool of GitHub API requests. When that pool runs out, you get the error card.
403 on Streak Stats. In streak #872, users reported every public streak endpoint returning 403. That issue is closed now, but it can happen again.
Broken image, no error. GitHub doesn't load README images straight from the source. It serves them through its own image proxy (camo). If the card service is slow, the image may just not appear. Users have reported this in streak #754 and #792.
Step 3 (recommended): Generate the SVGs with GitHub Actions
This is the fix that holds up best. A workflow builds the cards on a schedule and commits them to your profile repo. Your README then points at a file in your own repo, with no outside service in the way.
Stats and top languages card. Save this as .github/workflows/grs.yml in your USERNAME/USERNAME repo. It's adapted from the action's README:
name: Update README cards
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: stats-organization/github-readme-stats-action@v2
with:
card: stats
options: username=${{ github.repository_owner }}&show_icons=true
path: profile/stats.svg
token: ${{ secrets.GITHUB_TOKEN }}
- uses: stats-organization/github-readme-stats-action@v2
with:
card: top-langs
options: username=${{ github.repository_owner }}&layout=compact
path: profile/top-langs.svg
token: ${{ secrets.GITHUB_TOKEN }}
- uses: stats-organization/github-readme-stats-action@v2
with:
card: stats
options: username=${{ github.repository_owner }}&show_icons=true&theme=dark
path: profile/stats-dark.svg
token: ${{ secrets.GITHUB_TOKEN }}
- name: Commit cards
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add profile/*.svg
git commit -m "Update README cards" || exit 0
git push
Then in your README:


To include private repo stats, create a PAT with the repo and read:user scopes. Store it as a repository secret and use it in place of GITHUB_TOKEN. Never paste the token into the YAML.
Streak card. Add .github/workflows/streak-stats.yml, following the Streak Stats README:
- name: Generate streak stats
uses: DenverCoder1/github-readme-streak-stats@v1
with:
options: user=${{ github.repository_owner }}&theme=default&disable_animations=true
path: profile/streak.svg
token: ${{ secrets.STREAK_STATS_TOKEN }}
(Use the same checkout and commit steps as above.) The token line is only needed for private contributions. The README says to use a PAT with the repo scope, stored as a secret.
The catch: the cards only update when the workflow runs, once a day here. For a profile README, that's usually fine.
Step 4: Self-host on Vercel
If you want cards that update live, deploy your own instance:
1. Fork the repo. The original README still has a Vercel guide, and GitHub Stats Extended has its own deploy docs.
2. Import the fork into Vercel.
3. Create a GitHub PAT and add it as an environment variable named PAT_1.
4. Deploy, then swap the domain in your README:

That last step trips people up. #4747 covers cases where a self-hosted fork still looked broken because the README still pointed at the public URL. Check the URL.
On caching: cache_seconds takes values from 21600 to 86400, and the default is 21600. On your own instance, you can set the CACHE_SECONDS environment variable instead. On Vercel's free plan, keep in mind that your own usage counts against your own limits.
Step 5: Top languages card looks wrong
Two things come up a lot. The card only looks at your first 100 repos, and by default it ranks languages by byte count. One big Jupyter notebook can push everything else off the card.

size_weight=0.5&count_weight=0.5 is the combination the README recommends. It weighs bytes and repo count together. To stop GitHub from counting notebooks at all, add a .gitattributes file to that repo:
*.ipynb linguist-vendored
Step 6: The boring causes
Before you change anything else, check these:
- A typo in the username, or a stray \& copied from a doc.
- user= vs username=. Streak Stats uses user, github-readme-stats uses username.
- A renamed account.
- A cached old version. Wait for the cache to expire before you assume it's broken.
For dark/light mode, use the light and dark SVGs the workflow writes above:
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./profile/stats-dark.svg" />
<img src="./profile/stats.svg" alt="GitHub stats" />
</picture>
Alternatives
If you'd rather not maintain a workflow at all, there are a few options. GitHub Stats Extended is the maintained successor and has its own public instance. The two GitHub Actions above are the most robust free option. Community-run public instances exist too, but they're third-party and nobody promises they'll stay up.
Full disclosure: I work on GitHubCard. GitHubCard is a hosted card editor: you design stats, top languages, streak and other widgets as one card, then embed it with a single line:

It's a hosted service too, so the same logic applies: if the card has to be up 100% of the time, a static SVG committed by an Action is still the safest choice.
Sources (accessed Oct 14, 2026)
- github-readme-stats README and issues #3851, #4737, #4747, #4798
- stats-organization/github-readme-stats-action
- DenverCoder1/github-readme-streak-stats, issues #754, #792, #872
Last verified: Oct 14, 2026.
Mentions
Showing your private GitHub work on your profile, without leaking a single repo name Most of what I build as a freelancer lives in private client repositories. That's normal for client work, but it made my GitHub profile look half-empty: the public stats cards counted only the 20% of my work that happened to be public. Then the public github-readme-stats instance went down (#3851), and my cards turned into broken-image icons. I took that as the push to build my own: a small Cloudflare Worker that counts private work safely and that I now run for free. It's open source: kiri1101/github-profile-cards (MIT, with a Deploy to Cloudflare button). The privacy problem: totals yes, names never The goal was simple to state: show how much private work I do, without revealing what it is. No repository names, no client names, ever, not on the card and not in logs. So the Worker only ever stores and renders totals. Each row on the card shows public, private and total, where private is simply total − public. The public part comes from counting only contributions in public repositories. The names never leave GitHub's API response; they're summed away immediately. Why it needs two tokens This surprised me. No single GitHub token gives you everything: - A classic token with only read:user returns your total commit contributions for the year, private ones included. But the PR and issue counts it can see are public-only. - A fine-grained token with read-only Metadata, Issues and Pull requests on all your repositories sees your private PRs and issues. It also sees your repositories' language breakdown. Neither token can write anything. The Worker reads which account owns them (query { viewer { login } }), so there's no username to configure. If the two tokens belong to different accounts, it refuses to refresh rather than mixing two people's numbers on one card. Cron + KV: never rate-limited, never "down" Rendering cards on every request is what makes public instances expensive and fragile. This Worker does the opposite: - A cron trigger every 6 hours fetches the numbers, validates them, renders light and dark SVGs, and stores them in Workers KV. - Requests just read KV, which is fast and costs nothing on the free plan. If KV is ever empty or unreachable, you get a placeholder card, never a broken image. - New data is validated before it replaces the old: a sudden drop (say, a counter falling by more than 10%) is held back until the same drop repeats four times, so a flaky API response can never wipe your card. A GitHub README trap worth knowing If you wrap a <picture> (the light/dark image switcher) in a link, GitHub's README renderer moves the <img> into its own image link outside your anchor. Clicks then open the raw image, and theme switching breaks. The fix for linked cards: one SVG that themes itself with @media (prefers-color-scheme: dark) inside, used as a plain <a><img></a>. Tests that can actually fail There's a fair complaint going around that AI-assisted code often comes with tests that always pass, because they were written to match the code rather than the feature. I wanted proof mine weren't like that, so I used mutation testing (StrykerJS). It plants small bugs in your code (flips a < to <=, deletes a call, changes a constant) and checks whether any test notices. The first run scored 84%: one planted bug in six went unnoticed by a suite that looked fine by coverage. After adding sharp tests whose expected values come from the spec, not from the code's own output, the critical modules (grade formula, validation, privacy split, escaping, refresh safety) score 100%. CI fails if it ever drops. I also deleted 30 tests that caught none of the planted bugs. Fewer tests, each of which matters. Try it 1. Click Deploy to Cloudflare in the README. 2. Paste two tokens (the README links straight to GitHub's token pages with the right scopes). 3. Embed the two SVG URLs in your profile README. It runs on the free plan, and an unedited copy shows neutral cards. You can add your own project cards and tech-stack blocks in one config file. The grade uses the calculateRank formula from github-readme-stats, credit where it's due. Issues and ideas are welcome on the repo.
GitHub Readme Stats Not Working? How to Fix 503, Rate Limits, 403 and Broken Images You open your GitHub profile and the stats card is gone. All that's left is a broken image icon, or a grey "Something went wrong" box. Nothing changed in your README, so what broke? Almost always, the problem is the public service that draws the card, not your Markdown. Below is how I narrow it down, and three fixes that actually last. Last verified: Oct 14, 2026. TL;DR | Symptom | Likely cause | Fastest fix | |---|---|---| | 503 / DEPLOYMENT_PAUSED | Public github-readme-stats instance paused | GitHub Actions or self-host | | "Something went wrong" card | GitHub API rate limit or a fetch error | Your own token (Actions / self-host) | | 403 Forbidden on streak card | Public streak endpoint blocking requests | Streak Stats GitHub Action | | Broken image icon, no error | Slow response or a cached failure behind GitHub's image proxy | Static SVG in your repo | | Card shows the wrong data | Typo, wrong username, cache | Check the URL and wait out the cache | Step 1: Is it you or the service? Copy the image URL out of your README and open it straight in your browser. Or check the headers: curl -I "https://github-readme-stats.vercel.app/api?username=YOUR_USERNAME" curl -I "https://streak-stats.demolab.com/?user=YOUR_USERNAME" - 200 with image/svg+xml and a normal card: the service is fine. The problem is in your README (see Step 6) or a stale cached copy on GitHub. - 200 but the SVG says "Something went wrong": the service is up but couldn't fetch your data. This is usually a rate limit. - 503: the instance itself is down or paused. - 403: the endpoint is refusing the request. When I checked on Oct 14, both public endpoints returned 200. They work most days, but they go down every so often, so "it works right now" doesn't mean it'll still work tomorrow. Step 2: Know why the public instances break 503 DEPLOYMENT_PAUSED. In #3851 ("Public deployment paused"), the maintainers explain that Vercel's sponsorship ended. They point people to GitHub Actions or self-hosting instead. #4737 and #4798 ("not working again ?") are both still open. The repo README now also says the project is no longer maintained and recommends its successor, GitHub Stats Extended. Rate limits. The README says the public instance is "best-effort and can be unreliable due to rate limits and traffic spikes." Thousands of profiles share one pool of GitHub API requests. When that pool runs out, you get the error card. 403 on Streak Stats. In streak #872, users reported every public streak endpoint returning 403. That issue is closed now, but it can happen again. Broken image, no error. GitHub doesn't load README images straight from the source. It serves them through its own image proxy (camo). If the card service is slow, the image may just not appear. Users have reported this in streak #754 and #792. Step 3 (recommended): Generate the SVGs with GitHub Actions This is the fix that holds up best. A workflow builds the cards on a schedule and commits them to your profile repo. Your README then points at a file in your own repo, with no outside service in the way. Stats and top languages card. Save this as .github/workflows/grs.yml in your USERNAME/USERNAME repo. It's adapted from the action's README: name: Update README cards on: schedule: - cron: "0 0 * * *" workflow_dispatch: jobs: build: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v6 - uses: stats-organization/github-readme-stats-action@v2 with: card: stats options: username=${{ github.repository_owner }}&show_icons=true path: profile/stats.svg token: ${{ secrets.GITHUB_TOKEN }} - uses: stats-organization/github-readme-stats-action@v2 with: card: top-langs options: username=${{ github.repository_owner }}&layout=compact path: profile/top-langs.svg token: ${{ secrets.GITHUB_TOKEN }} - uses: stats-organization/github-readme-stats-action@v2 with: card: stats options: username=${{ github.repository_owner }}&show_icons=true&theme=dark path: profile/stats-dark.svg token: ${{ secrets.GITHUB_TOKEN }} - name: Commit cards run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add profile/*.svg git commit -m "Update README cards" || exit 0 git push Then in your README:   To include private repo stats, create a PAT with the repo and read:user scopes. Store it as a repository secret and use it in place of GITHUB_TOKEN. Never paste the token into the YAML. Streak card. Add .github/workflows/streak-stats.yml, following the Streak Stats README: - name: Generate streak stats uses: DenverCoder1/github-readme-streak-stats@v1 with: options: user=${{ github.repository_owner }}&theme=default&disable_animations=true path: profile/streak.svg token: ${{ secrets.STREAK_STATS_TOKEN }} (Use the same checkout and commit steps as above.) The token line is only needed for private contributions. The README says to use a PAT with the repo scope, stored as a secret. The catch: the cards only update when the workflow runs, once a day here. For a profile README, that's usually fine. Step 4: Self-host on Vercel If you want cards that update live, deploy your own instance: 1. Fork the repo. The original README still has a Vercel guide, and GitHub Stats Extended has its own deploy docs. 2. Import the fork into Vercel. 3. Create a GitHub PAT and add it as an environment variable named PAT_1. 4. Deploy, then swap the domain in your README:  That last step trips people up. #4747 covers cases where a self-hosted fork still looked broken because the README still pointed at the public URL. Check the URL. On caching: cache_seconds takes values from 21600 to 86400, and the default is 21600. On your own instance, you can set the CACHE_SECONDS environment variable instead. On Vercel's free plan, keep in mind that your own usage counts against your own limits. Step 5: Top languages card looks wrong Two things come up a lot. The card only looks at your first 100 repos, and by default it ranks languages by byte count. One big Jupyter notebook can push everything else off the card.  size_weight=0.5&count_weight=0.5 is the combination the README recommends. It weighs bytes and repo count together. To stop GitHub from counting notebooks at all, add a .gitattributes file to that repo: *.ipynb linguist-vendored Step 6: The boring causes Before you change anything else, check these: - A typo in the username, or a stray \& copied from a doc. - user= vs username=. Streak Stats uses user, github-readme-stats uses username. - A renamed account. - A cached old version. Wait for the cache to expire before you assume it's broken. For dark/light mode, use the light and dark SVGs the workflow writes above: <picture> <source media="(prefers-color-scheme: dark)" srcset="./profile/stats-dark.svg" /> <img src="./profile/stats.svg" alt="GitHub stats" /> </picture> Alternatives If you'd rather not maintain a workflow at all, there are a few options. GitHub Stats Extended is the maintained successor and has its own public instance. The two GitHub Actions above are the most robust free option. Community-run public instances exist too, but they're third-party and nobody promises they'll stay up. Full disclosure: I work on GitHubCard. GitHubCard is a hosted card editor: you design stats, top languages, streak and other widgets as one card, then embed it with a single line:  It's a hosted service too, so the same logic applies: if the card has to be up 100% of the time, a static SVG committed by an Action is still the safest choice. Sources (accessed Oct 14, 2026) - github-readme-stats README and issues #3851, #4737, #4747, #4798 - stats-organization/github-readme-stats-action - DenverCoder1/github-readme-streak-stats, issues #754, #792, #872 Last verified: Oct 14, 2026.