Skip to main content

How to Setup Auto Update for Electron App

· 10 min read

Shipping an Electron app is easy. Keeping every user on the latest version is the hard part. Unlike apps on the App Store or Google Play, a desktop app you distribute yourself has no built-in update channel — so you have to build one.

This guide shows how to add automatic updates to an Electron app using faynoSync, a self-hosted, open-source update server. We'll cover two approaches:

  • A custom update flow — you call faynoSync's checkVersion API yourself and control the UX end to end.
  • Native electron-updater integration — faynoSync speaks the electron-builder protocol, so the standard updater works out of the box.

We'll also cover the parts most tutorials skip: code signing, critical updates, changelogs, and what to do when updates silently don't work.


Two ways to auto-update an Electron app

Custom flowelectron-updater (electron-builder)
Control over UXFullLimited to autoUpdater events
Install handlingYou implement itAutomatic (download + relaunch)
Code signing requiredNo (for download links)Yes (macOS/Windows)
Best forCustom installers, portable appsStandard packaged apps (.dmg/.nsis/.AppImage)
faynoSync supportmanual updaterelectron-builder updater

If you just want the app to update itself with minimal code, go with electron-updater. If you need full control over the download and install step, use the custom flow. Both talk to the same faynoSync instance.


Prerequisites 📋

Before we begin, make sure you have:

  • A running faynoSync instance — see Getting Started
  • Published versions 0.0.1 and 0.0.2 of your app
  • Created an app in faynoSync named "myapp"
  • Created a channel in faynoSync named "nightly"
  • Created a platform in faynoSync named (darwin/linux/windows)
  • Created an architecture in faynoSync named (amd64/arm64)

Approach A: Custom update flow

This approach gives you complete control: you ask faynoSync whether an update exists and decide how to present it. Under the hood it uses the default manual updater, which returns plain JSON with direct download URLs.

1. Initialize Your Project

First, let's create a new project with the following package.json:

{
"name": "myapp",
"version": "0.0.1",
"description": "Hello world app for testing faynoSync",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"keywords": [],
"author": "Example Author",
"license": "ISC",
"dependencies": {
"node-fetch": "2.6.9"
},
"devDependencies": {
"electron": ">=23.3.13"
},
"engines": {
"npm": ">=8.19.3",
"node": ">=18.13.0"
}
}

2. Create Basic Files

index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title id="title"></title>
</head>
<body>
<h1 id="label">Hello, world!</h1>
</body>
</html>

config.js

const packageJson = require('./package.json');

module.exports = {
app_name: packageJson.name,
version: packageJson.version,
channel: "nightly",
owner: "example",
};

The Magic of Auto-Updates ✨

Let's create our main.js file step by step:

1. Import Required Modules

const { app, BrowserWindow, dialog, shell } = require('electron');
const fetch = require('node-fetch');
const os = require('os');
const { version, app_name, channel, owner } = require('./config.js');
const fs = require('fs');

2. Platform Detection

function getLinuxDistributionFamily() {
let distroFamily = 'Linux';
try {
const releaseInfo = fs.readFileSync('/etc/os-release', 'utf8');
const match = releaseInfo.match(/^ID(?:_LIKE)?=(.*)$/m);
if (match) {
const idLike = match[1].trim().toLowerCase();
if (idLike.includes('rhel') || idLike.includes('fedora') || idLike.includes('centos')) {
distroFamily = 'RHEL';
} else if (idLike.includes('debian') || idLike.includes('ubuntu') || idLike.includes('kali')) {
distroFamily = 'Debian';
}
}
} catch (err) {
console.error('Error getting Linux distribution family:', err);
}
return distroFamily;
}

3. Update Choice Window

function createChoiceWindow(updateOptions) {
const win = new BrowserWindow({
width: 600,
height: 400,
webPreferences: {
nodeIntegration: true,
},
});

win.loadURL(`data:text/html,
<html>
<body>
<h2>Choose an update package:</h2>
<ul>
${updateOptions
.map(
(option, index) =>
`<li><a id="option-${index}" href="${option.url}">${option.name}</a></li>`
)
.join('')}
</ul>
<script>
const { shell } = require('electron');
document.addEventListener('click', (event) => {
if (event.target.tagName === 'A') {
event.preventDefault();
shell.openExternal(event.target.href);
}
});
</script>
</body>
</html>`
);

return win;
}

4. Update Check Function

function checkUpdates() {
let url = `http://localhost:9000/checkVersion?app_name=${app_name}&version=${version}&platform=${os.platform()}&arch=${os.arch()}&owner=${owner}`;

if (channel !== undefined) {
url += `&channel=${channel}`;
}

fetch(url, { method: 'GET' })
.then((res) => res.json())
.then((data) => {
console.log(data);
if (data.update_available) {
const message = data.critical
? `A critical update is available. Please update now.`
: `You have an older version. Would you like to update your app?`;
dialog.showMessageBox({
type: 'question',
title: data.critical ? 'Critical update' : 'Update available',
message: message,
detail: data.changelog || undefined,
buttons: ['Yes', 'No'],
defaultId: 0,
}).then(({ response }) => {
if (response === 0) {
const updateOptions = [];
for (const key in data) {
if (key.startsWith('update_url_')) {
updateOptions.push({ name: key.substring(11).toUpperCase(), url: data[key] });
}
}
const choiceWindow = createChoiceWindow(updateOptions);
}
});
}
})
.catch(() => {});
}

Notice we now read two extra fields from the response: critical and changelog. faynoSync returns these automatically — critical lets you force the prompt, and changelog shows users what changed. We'll cover them in detail below.

5. Main Window Creation

function createWindow() {
let osName = os.platform();
let pcArch = os.arch();
if (osName === 'linux') {
osName = getLinuxDistributionFamily();
}
const title = `${app_name} - v${version} (${osName}-${pcArch})`;

let win = new BrowserWindow({
width: 400,
height: 300,
webPreferences: {
nodeIntegration: true,
},
});

win.setTitle(title);
win.loadFile('index.html');
win.on('closed', () => {
win = null;
});

checkUpdates();
}

app.whenReady().then(createWindow);

Running Your App 🏃‍♂️

To start your app, simply run:

npm start

If a newer version exists, you'll see something like this in your logs:

{
"critical": false,
"update_available": true,
"update_url_dmg": "http://localhost:9010/cb-faynosync-s3-public/myapp-example/nightly/darwin/arm64/myapp-0.0.2.0.dmg"
}

And in your app's UI, you'll see a notification about the available update. After agreeing, the user downloads and installs the new version.


Approach B: Native electron-updater integration

Most production Electron apps are packaged with electron-builder and use its electron-updater module. The good news: faynoSync ships an electron-builder updater that returns the exact YAML feed (latest.yml, latest-mac.yml, latest-linux.yml) the updater expects — so you don't need GitHub Releases or an S3 generic provider.

1. Upload builds for the electron-builder updater

When uploading, pass the updater parameter so faynoSync isolates the artifacts and generates the matching *.yml:

curl -X POST --location 'http://localhost:9000/upload' \
--header 'Authorization: Bearer <jwt_token>' \
--form 'file=@"/path/to/myapp-0.0.2.dmg"' \
--form 'file=@"/path/to/latest-mac.yml"' \
--form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.2\",\"channel\":\"nightly\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"electron-builder\"}"'

2. Point electron-updater at faynoSync

In your app, configure the feed to use the generic provider and faynoSync's electron-builder endpoint:

const { autoUpdater } = require('electron-updater');

autoUpdater.setFeedURL({
provider: 'generic',
url: 'http://localhost:9000/checkVersion?app_name=myapp&channel=nightly&platform=darwin&arch=arm64&owner=example&updater=electron-builder',
});

faynoSync responds with electron-builder-compatible YAML:

version: 0.0.2
files:
- url: myapp-0.0.2.dmg
sha512: <sha512_hash>
size: <file_size>
path: myapp-0.0.2.dmg
sha512: <sha512_hash>
releaseDate: '2026-01-15T10:00:00.000Z'

See the Updaters Support doc for the full list of supported updaters (squirrel_windows, squirrel_darwin, tauri, and more).

3. Wire up the autoUpdater events

const { app, dialog } = require('electron');
const { autoUpdater } = require('electron-updater');

app.whenReady().then(() => {
autoUpdater.checkForUpdates();
});

autoUpdater.on('update-available', (info) => {
console.log('Update available:', info.version);
});

autoUpdater.on('download-progress', (progress) => {
console.log(`Downloaded ${Math.round(progress.percent)}%`);
});

autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
type: 'info',
title: 'Update ready',
message: 'A new version has been downloaded. Restart to apply it?',
buttons: ['Restart', 'Later'],
}).then(({ response }) => {
if (response === 0) autoUpdater.quitAndInstall();
});
});

autoUpdater.on('error', (err) => {
console.error('Update error:', err);
});

That's the whole flow: electron-updater downloads the package in the background and quitAndInstall() swaps the binary and relaunches.


Code signing and notarization

electron-updater will refuse to install unsigned updates on macOS and Windows. This is the single most common reason auto-updates "work in dev but not in production." Before shipping:

  • macOS — sign with a Developer ID certificate and notarize the app, otherwise Gatekeeper blocks it and the updater silently fails. With electron-builder, set mac.notarize and provide APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID in your build environment.
  • Windows — sign the installer with an Authenticode certificate (EV or OV). Unsigned installers trigger SmartScreen warnings and break differential updates. If you don't want to buy a traditional certificate, Microsoft's Azure Trusted Signing offers a fully managed signing service for about $9.99/month — a cheap, modern alternative for individual developers and small teams.
  • Linux — AppImage updates don't require signing, which makes Linux the easiest target to test the flow first.

The custom flow (Approach A) sidesteps the updater's signature checks because you open download links yourself — but you still owe your users signed binaries.


Critical updates, changelogs, and required intermediate builds

faynoSync's checkVersion response carries three fields that make updates smarter:

  • critical — mark a release as critical at upload time, and the API flags it here. Use it to make the update prompt non-dismissible.
  • changelog — markdown release notes returned alongside the update, so you can show "What's new" without a second request.
  • is_intermediate_required — an informational flag. When a mandatory intermediate build exists between the user's version and latest, faynoSync returns the download link for that intermediate version (not latest) and sets this field to true. The intermediate build must be installed before the latest one — it's used for migrations that can't be skipped, such as database schema changes.
{
"update_available": true,
"critical": true,
"is_intermediate_required": true,
"changelog": "### Changelog\n\n- Added feature X\n- Fixed bug Y",
"update_url_dmg": "https://<bucket>.s3.amazonaws.com/myapp/stable/darwin/amd64/myapp-0.0.5.dmg"
}

If is_intermediate_required is true, prompt the user to install the returned version, then check again — repeat until they reach latest. Read more in Required Intermediate Build.


Troubleshooting

SymptomLikely cause
update_available is always falseversion in the request matches the latest, or wrong channel/platform/arch
electron-updater says "No published versions"The *.yml feed wasn't uploaded, or updater=electron-builder was omitted on upload
Update downloads but won't install (macOS/Windows)App or installer isn't code signed / notarized
Works locally, fails in packaged appsetFeedURL points to localhost — use your public faynoSync URL in production
401 on uploadMissing or expired JWT — re-authenticate against /login

Tip: run faynoSync's /info/latest endpoint manually with curl to confirm the server returns what you expect before debugging the client.


How to try faynoSync?

  1. Follow the Getting Started guide: 👉 https://faynosync.com/docs/getting-started

  2. Create your app using the REST API or web dashboard: 📦 API Docs: https://faynosync.com/docs/api 🖥️ Dashboard UI: https://github.com/ku9nov/faynoSync-dashboard

  3. Upload at least two versions of your application.

  4. Check for updates with this simple request: 📡 /info/latest



Need Help? 🤝

If you have any questions or need assistance:

  1. Check out our documentation
  2. Create an issue on GitHub

If you find this project helpful, please consider subscribing, leaving a comment, or giving it a star, create an Issue or feature request on GitHub. Your support keeps the project alive and growing 💚