How to Fix electron-updater 404 on a Private GitHub Repo (2026)
electron-updater gets 404 on a private GitHub repo because your shipped app checks the public feed without a token. Three real fixes.
Your Electron app shipped. electron-builder --publish always uploaded the release assets fine with GH_TOKEN in CI, everything looks perfect on the Releases page — and then autoUpdater.checkForUpdates() on an installed copy of your own app dies with Cannot find asset "latest.yml" ... status 404: Not Found. You double-check the release, confirm latest.yml sits right next to the installer, and the 404 doesn't move. The last place anyone looks is the correct one: the problem is not the release, it's who is asking. Your installed app is talking to GitHub without any token, and GitHub answers unauthenticated requests to private repo resources with a 404 — not a 401 — because it deliberately doesn't reveal that a private repo exists.
TL;DR
- The 404 happens because electron-builder's
GH_TOKENis a build-time secret for uploading. It is not shipped with your app. At runtime, electron-updater picks its provider from the installed app'sapp-update.yml— and withoutprivate: trueplus a token, it uses the public GitHub feed, which returns 404 for private repos. - Best fix: keep your source private and publish releases from a separate public repo (releases-only repo). No tokens shipped, no rate-limit concerns, updater behaves exactly like every public repo setup.
- Acceptable fix for internal software: use the private GitHub provider —
private: trueplus a read-only fine-grained token, injected at runtime viasetFeedURL/addAuthHeader. Never bake a full write-scope token intoapp-update.yml; it sits in plaintext inside every installed machine. - Two separate 404s often stack on top: a draft release is invisible to update checks until you click "Publish release", and asset downloads need a token even when the release check succeeds.
- If you need pay-after-auth or per-customer update URLs: generic provider against S3/R2 with
requestHeaders/addAuthHeader. On Cloudflare R2, note the S3 API endpoint can't serve unauthenticated downloads — serve via r2.dev or a custom domain.
What your installed app actually does
electron-updater doesn't read GH_TOKEN from your CI brain. It reads the provider config from app-update.yml, a small YAML file that electron-builder baked into your installed app next to the executable. It contains essentially your publish section in YAML form: provider, owner, repo, channel (latest by default).
The provider selection is where the 404 is born. From electron-updater's providerFactory.ts (current master):
case "github": {
const githubOptions = data as GithubOptions
const token =
(githubOptions.private
? process.env["GH_TOKEN"] || process.env["GITHUB_TOKEN"]
: null) || githubOptions.token
if (token == null) {
return new GitHubProvider(githubOptions, updater, runtimeOptions)
} else {
return new PrivateGitHubProvider(githubOptions, updater, token, runtimeOptions)
}
}
Three consequences:
- Without
private: trueyou getGitHubProvider— the public provider. It talks togithub.comvia the public releases feed (deliberately not the API, to avoid rate limits). On a private repo, that feed is a 404 wall. - Even with
private: true, the token is looked up from the end-user's environment (GH_TOKENin the process running your app) or fromapp-update.ymlitself. AGH_TOKENyou set "for publishing" in CI or on your dev machine does nothing inside an installed app. PrivateGitHubProvidertalks toapi.github.comwith anAuthorizationheader — which is why it can see private repos at all, and why it's rate-limited.
This matches the official docs' warning almost verbatim: private GitHub update repos are "only for very special cases — not intended and not suitable for all users," and the API limit is 5000 requests per hour with up to 3 requests per update check.
Three different 404s with three different causes
Before touching config, figure out which 404 you actually have:
- Channel-file 404 at check time. Error like
Cannot find channel "latest.yml" update info(codeERR_UPDATER_CHANNEL_FILE_NOT_FOUND) orUnable to find latest version on GitHub. Your app is asking the public feed about a private repo. Fix = provider/token (below). - Asset 404 at download time.
Cannot download "https://api.github.com/repos/owner/repo/releases/assets/123", status 404: Not Found. The check succeeded but the latest.yml asset download or the installer itself wasn't authorized. This one appears when someone setsAuthorizationonly on the API request, or when the release was made public but the repo stayed private and tokens are absent. - Draft-release 404. electron-builder creates releases as draft by default (
releaseType: draft). Draft releases are not returned by update checks for normal users — they simply don't exist on the public feed, andPrivateGitHubProvidernow explicitly filters drafts too (fixed in electron-updater 6.8.5, issue #9694). If "it works on my machine with GH_TOKEN set but not for anyone else," your release is still a draft. Click Publish. For automated flows, stand up a token with write permission to flip drafts, or setreleaseType: release.
A useful diagnostic — no Electron needed, just ask GitHub what a tokenless client sees:
# Public provider path: if the repo is private, a NEW tokenless client sees 404 here
curl -I https://github.com/your-org/your-app/releases.atom
# Asset path: same for the channel file on a private repo
curl -I https://github.com/your-org/your-app/releases/download/v1.2.3/latest.yml
If both return 404, your installed app is unambiguously hitting the public provider. Same URLs with -H "Authorization: Bearer <read-only-token>" succeeding is your proof that a token is the missing ingredient — and exactly the test your users cannot perform, which is the argument for not going private-per-user.
Fix 1 (recommended): releases-only public repo
The cleanest architecture costs you nothing: repo your-app stays private; a small sibling repo your-app-releases is public and holds only Releases artifacts. electron-builder publishes the built artifacts and latest.yml there; your app's publish config points at it; the updater works on any machine with zero credentials.
# electron-builder.yml
publish:
provider: github
owner: your-org
repo: your-app-releases
releaseType: release # skip draft dance; or keep draft and publish manually
The app process needs nothing except the standard two lines:
import { autoUpdater } from "electron-updater"
export function initAutoUpdate(): void {
autoUpdater.logger = require("electron-log")
autoUpdater.checkForUpdatesAndNotify()
}
Downsides to be honest about: your installer binaries are publicly downloadable by anyone with the release URL, and anyone can watch your release cadence via the public feed. For a paid B2B tool distributing via license keys anyway, that's usually irrelevant. This is also exactly the pattern the docs hint at when they call private GitHub "not suitable for all users" — and what the long-standing Stack Overflow answers recommend.
Fix 2: private repo with a runtime token
If the binaries themselves must be private, use PrivateGitHubProvider — but inject the token at runtime, never at build time:
# electron-builder.yml
publish:
provider: github
owner: your-org
repo: your-app
private: true
import { app } from "electron"
import { autoUpdater } from "electron-updater"
app.whenReady().then(async () => {
// Fetch a short-lived, read-only fine-grained PAT from YOUR backend,
// scoped to Contents: Read + Metadata: Read on the releases repo only.
const readOnlyToken = await fetchUpdateTokenFromMyBackend()
// addAuthHeader sets the `authorization` request header for update requests
autoUpdater.addAuthHeader(`Bearer ${readOnlyToken}`)
await autoUpdater.checkForUpdatesAndNotify()
})
Both addAuthHeader (a documented shortcut on AppUpdater that merges into requestHeaders) and setFeedURL are real, shipped APIs on electron-updater 6.x — this is not folklore. The token is a credential inside your trusted app binary, fetched after install. Note the docs' strict stance on the alternative: the token field in GithubOptions says "Never specify it in the configuration files. Only for setFeedURL." Technically electron-builder will still accept a token in your publish config and write it verbatim into app-update.yml (some Stack Overflow answers do exactly that and get the updater working, because the token ends up in the YAML). Understand what that means before copying them: every installed machine ships with a plaintext GitHub token inside its app-update.yml, and if you reused your publish GH_TOKEN, that token also has push rights to the repo. Don't. If you have no backend to fetch tokens from and no per-machine shell access, Fix 1 is the answer, not a hardcoded PAT.
Fix 3: generic provider with S3/R2
Moving the feed off GitHub entirely sidesteps the whole token dance and gets you full control over auth:
# electron-builder.yml
publish:
provider: generic
url: https://releases.your-cdn.example/your-app
# Or provider: s3 / provider: r2 with bucket + region/path
// If your bucket keys are private, send credentials via headers — verified API
const { NsisUpdater } = require("electron-updater")
const updater = new NsisUpdater({
provider: "generic",
url: "https://releases.your-cdn.example/your-app",
})
updater.addAuthHeader(`Bearer ${preSignedOrBearerToken}`)
updater.checkForUpdatesAndNotify()
Two gotchas from the v27 (electron-updater 7) security migration, both verified in the changelog and docs: credentials from requestHeaders/addAuthHeader are now only sent to the feed's origin, and the v27 docs flag that Cloudflare R2's S3 API endpoint cannot serve unauthenticated downloads — you must serve artifacts via an r2.dev subdomain or custom domain so latest.yml and the installer are fetchable. Keep everything on one origin and this provider style is rock solid.
Day-one checklist to make the 404 disappear permanently
- Inspect the installed app's
app-update.yml(Windows:C:\Program Files\<App>\resources\, macOS:<App>.app/Contents/Resources/app-update.yml) — confirm the provider, owner, repo, channel match your intent. - Check the release is published (not draft). Draft = 404 for anyone else.
- Confirm
latest.yml(orlatest-mac.yml/latest-linux.yml) is attached as a release asset — the channel-file fetch is the first thing that 404s when it isn't. - Match tags: electron-builder writes
v-prefixed tags (vPrefixedTagName: trueby default). If you retag manually, tags must line up or the release lookup returns nothing. - Test against the same URLs with tokenless
curlbefore shipping a fix. - A broken staged rollout isn't recoverable by rewriting the same version — bump the version. One condensed tip of the docs you should never have to learn the hard way.
Wrap-up
- 404 from electron-updater on a private GitHub repo means the shipped app ran the public provider — check
app-update.ymland theproviderFactory.tslogic before blaming your release assets. - Ship credentials thoughtfully: publication tokens are build-time only; update tokens are runtime-fetched, fine-grained and read-only; never a plaintext write token inside every install.
- Private GitHub is a supported-but-discouraged path (rate limits, token-in-app problems, draft-filtering behavior). A public releases-only repo or a generic S3/R2 feed is the low-maintenance answer for most products.
Sources
Further reading from the primary sources: the electron-builder Auto Update docs (private GitHub section, rate limit, latest.yml generation, staged rollouts), the publish configuration reference (GithubOptions, the "Never specify token in configuration files" warning, recommended releases workflow), the providerFactory source and AppUpdater.addAuthHeader for the exact token-selection logic, the electron-updater 6.6–6.8 changelog for the draft-release filtering fix, the private-repo support issue #1266 for context on why tokens are handled the way they are, a canonical Stack Overflow thread on private-repo 404s showing the token-in-app-update.yml trap, and a hands-on electron-updater example repo for a minimal working public-repo setup.