devto 2026-07-02 원문 보기 ↗
A few months ago I built an internal desktop tool for my company using Electron. It runs on machines across every branch office — different cities, different networks, different levels of IT literacy on the other end.
Last week I needed to ship a fix. Not a "whenever people get around to it" fix — a mass update, same day, everywhere.
Here's the part that made that possible: I never had to visit a single branch, email a setup file, or ask anyone to click "install." I opened my terminal, ran two commands, and walked away. By the next morning, every machine running the app had already updated itself.
That wasn't luck. It was because a few months earlier — before I needed it — I had wired up a proper auto-update system using electron-updater. This post breaks down exactly how that system works, end to end, and how it turned what should have been a logistical nightmare into a non-event. Hopefully it saves someone else the trial and error.
If you've ever shipped an internal Electron app to a non-technical audience spread across multiple physical locations, you already know the pain:
The moment your user base is decentralized, manual distribution becomes your biggest bottleneck — not writing the fix itself.
The fix for the bottleneck isn't a better installer. It's removing the installer from the loop entirely.
The system I built is based on electron-updater (from the electron-builder ecosystem) pointed at a generic HTTP provider — meaning updates are hosted on a plain web server I control via SFTP, with no dependency on GitHub Releases or a cloud storage vendor. That mattered a lot for internal company software that shouldn't be sitting in a public repo.
There are four distinct layers doing the work:
| Layer | Responsibility |
|---|---|
| Build & publish | Compile the app, upload the installer and metadata to the server |
| Main process | On launch, silently check for a newer version and download it |
| Preload bridge | Safely relay update events from the sandboxed main process to the UI |
| Renderer UI | Show the user what's happening, without letting them stop it |
Let's walk through each one — and where the "mass update to every branch" moment actually happens.
Everything starts with a single script:
npm run publish
# → npm run build && npm run upload
nextron build compiles both the Next.js renderer and the Electron main process into a dist/ folder. electron-builder handles packaging based on electron-builder.yml:
publish:
- provider: generic # plain HTTP server, no GitHub / S3
url: http://<server>/updates/
channel: latest
useMultipleRangeRequest: false
This one config block is the reason I own the entire update pipeline. There's no third-party service that could rate-limit me, gate access behind auth I don't control, or go down at the wrong moment. It's just a server I already trust.
Every build also auto-generates a latest.yml file — the single source of truth for the update system. It contains the new version number, the installer filename, a SHA-512 hash for integrity verification, and the release date. This file is what every branch's app will silently check against.
The upload script (scripts/upload-update.mjs) pushes everything to the server over SFTP, and it's smarter than a blind file copy:
HEAD request to check what's already on the server.Given that some of our branches sit on genuinely flaky connections, the SSH session is also tuned for resilience — a keepalive probe every 15 seconds, tolerating up to 10 missed probes (roughly 150 seconds) before giving up. That's the kind of detail you don't think about until a publish fails halfway through at 6 PM and you're debugging a timeout instead of going home.
This is the entire "developer side" of a mass update. I run npm run publish. That's it. Everything past this point happens on machines I will never log into.
This is the layer that turns a file sitting on a server into an update running silently on a machine in a branch office 400 kilometers away.
On every app launch, in production builds only, the following runs:
autoUpdater.logger = log
autoUpdater.logger.transports.file.level = 'info'
autoUpdater.autoDownload = true // no prompt — just download
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.checkForUpdates().catch((err) => {
log.error('Initial update check failed', err)
})
autoDownload: true is the key decision here. There's no "a new version is available, click to download" dialog for the user to ignore. electron-updater fetches latest.yml, compares versions, and if there's a newer one, it starts downloading immediately.
Six events are wired up, each forwarded to the renderer over IPC so the UI can react:
| Event | What it means |
|---|---|
checking-for-update |
Started polling the server |
update-available |
A newer version exists — download begins |
update-not-available |
Already current |
download-progress |
Live bytes/percent/speed |
update-downloaded |
Installer verified and ready |
error |
Something went wrong — logged, UI recovers gracefully |
Once the download finishes and the hash is verified:
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('update-downloaded', { version: info.version })
setTimeout(() => {
autoUpdater.quitAndInstall(true, true)
}, 3000)
})
Three seconds after the "installing" message renders, quitAndInstall(true, true) fires — silently, no confirmation dialog, force-closing all windows and running the installer immediately. The app comes back up seconds later on the new version.
No one at any branch had to know an update even shipped that day. It just... happened, in the background, between one launch and the next.
Electron's sandboxed renderer can't touch Node or Electron APIs directly, and it shouldn't — that's a security boundary you don't want to punch a hole through carelessly. The preload script exposes a narrow, controlled interface:
contextBridge.exposeInMainWorld('ipc', {
on(channel, callback) {
const subscription = (_event, ...args) => callback(...args)
ipcRenderer.on(channel, subscription)
return () => ipcRenderer.removeListener(channel, subscription)
},
// ...send, invoke
})
The returned cleanup function matters more than it looks — without it, every hot reload or component remount during development leaks a listener, and eventually you're debugging duplicate update dialogs firing three times for no reason.
Silent updates shouldn't mean invisible updates. If someone's mid-task and the app is about to restart itself, they deserve to see that coming.
Global listeners are registered once in _app.tsx, driving three pieces of state:
update-available → modal appears, "downloading" stage, progress starts at 0
download-progress → progress bar updates live
update-error → modal quietly disappears, no user-facing failure noise
update-downloaded → modal switches to "installing" stage
(3s later, the app restarts itself)
The modal itself is a fixed full-screen overlay with a backdrop blur — genuinely hard to miss, but not alarming. The downloading stage shows a live progress bar, transferred/total bytes, download speed, and an ETA calculated from remaining bytes divided by bytes-per-second. The installing stage swaps to a green indicator, a full progress bar, and a plain warning not to close the window because it's about to close itself anyway.
That's really the whole design philosophy: give people visibility, take away the decision. For internal software running business-critical workflows across branches, "click here to update" is a support ticket generator. Silent-but-visible is the sweet spot.
Here's the payoff. A bug surfaced that needed fixing across the board — not "roll it out gradually," but everywhere, same day. In the old world, that would have meant:
Instead, this is what actually happened:
Developer:
npm run build → new version compiled, latest.yml regenerated with new hash
npm run upload → SFTP pushes installer + metadata to the server
Every branch, independently, on next app launch:
checkForUpdates() → sees new version in latest.yml
update-available → download starts automatically, no click needed
download-progress → user sees a clean progress bar
update-downloaded → app quits and reinstalls itself
App relaunches → running the fixed version
I didn't touch a single machine. I didn't send a single file over email or Slack. Every branch pulled the fix on its own schedule, the moment someone opened the app, with zero coordination required from me or from them.
That's the actual return on investment of building the update pipeline before you need it under pressure. The infrastructure work happens once, calmly, months in advance. The payoff shows up exactly when you least have time to improvise.
If you're building internal or B2B desktop software with electron-updater, a few lessons from this stood out:
latest.yml with a fresh installer is a worse failure mode than no update at all — clients will silently think they're current when they're not.latest.yml is what makes autoDownload: true safe enough to trust across machines you'll never personally inspect.The best infrastructure work is invisible right up until the one day it saves you. Building the auto-update pipeline felt like a "nice to have" the day I shipped it. Months later, on the day I needed to patch every branch in the company at once, it was the only reason that day didn't turn into a week.
If your desktop app doesn't have this yet — and it's going to live on more than one machine you don't control — this is the piece to build before you need it, not after.
Stack used: electron-updater (^6.8.3), electron-builder (^26.8.1), electron-log (^5.4.3), Next.js via nextron, deployed to a generic HTTP server over SFTP.
Have you dealt with rolling out updates across machines you don't control? Curious whether others went the generic-provider route or leaned on something like S3/GitHub Releases instead — and how you handled staged rollouts if the update ever needed to be pulled back. Drop your approach below 👇