Build a desktop app with Electron in a week
A 10-step playbook. Open in Dock and you'll get four surfaces seeded:
- **Steps** (table) — the 10 gates as rows, owner + due + status
- **Pointers** (table) — every official Electron + Apple + Microsoft doc linked from this playbook
- **Brief** (doc) — the canonical write-up you maintain alongside the work
- **Build log** (table) — one row per signed build (version, platform, signing status, notarization status, download URL)
Read `Steps` top-to-bottom on first open. Each row is one of the 10 steps. Click into a step to see the tasks, pointers, and the agent prompt for that step.
Outcome
A signed, notarized, auto-updating Electron app on macOS + Windows + Linux, with a release feed users install from and a CI pipeline that ships new versions on every git tag.
Estimated time: 1-2 weeks (most of it certs + signing + notarization)
Difficulty: advanced
For: Web developers shipping their first cross-platform desktop app.
What you'll need
Pre-register or install before you start.
- Electron (Free (MIT)) — The cross-platform desktop framework. Bundles Chromium + Node into a native window.
- electron-builder (Free (npm)) — Builds installers (.dmg, .exe, .AppImage), handles signing, publishes to release feeds.
- electron-updater (Free (npm)) — Reads a release feed (GitHub Releases or S3) and applies updates atomically.
- Apple Developer Program ($99/year) — Required for the Developer ID Application cert + notarization.
- Windows code signing certificate ($200-700/year (OV or EV via DigiCert / Sectigo / SSL.com)) — Signs your Windows installers so SmartScreen doesn't scare users.
The template · 10 steps
Step 1: Scaffold with Electron Forge or electron-vite
Estimated time: 1-2 hr
Two modern starters: Electron Forge (official, all-in-one) and electron-vite (community, faster dev with Vite HMR). Either is fine. Forge is the safer default if you're new to the ecosystem; electron-vite is the faster choice if you're already on Vite for your renderer.
Tasks
- npx create-electron-app my-app --template=vite-typescript (or electron-vite create my-app)
- Open the project, npm install, npm start, confirm the dev window opens
- Familiarise with the three processes: main, preload, renderer
- Read the contextIsolation + nodeIntegration default settings (both should be safe-by-default)
- Decide your renderer framework: React, Solid, Vue — works the same
Pointers
- [Official] Electron Forge
- [Code] electron-vite
- [Official] Process model
[!CAUTION] Gotchas
- The deprecated electron-builder boilerplate templates from 2018-2020 ship insecure defaults (nodeIntegration: true). Use Forge or electron-vite, not those.
- If you copy code from a renderer-process tutorial, expect runtime errors when it tries to require('fs'). Renderer code runs in Chromium without Node by default. Use the preload bridge.
- Hot module reload works in renderer; main / preload changes need a full electron restart. Set up nodemon or use vite-plugin-electron's HMR for main.
Step 2: Lock the security defaults: contextIsolation + sandbox + CSP
Estimated time: 2-3 hr
Electron's security review is uncompromising: nodeIntegration MUST be false, contextIsolation MUST be true, sandbox SHOULD be true, and a Content Security Policy SHOULD be set. Vulnerabilities here mean a remote-code-execution path through a single XSS in your renderer.
Tasks
- In every BrowserWindow: nodeIntegration: false, contextIsolation: true, sandbox: true
- Use a preload script with contextBridge.exposeInMainWorld to bridge specific APIs
- Set a Content Security Policy via or HTTP header
- Disable nodeIntegrationInWorker, nodeIntegrationInSubFrames, allowRunningInsecureContent
- Run the Electron security checklist: webPreferences review
- Add eslint-plugin-electron-security or run electronegativity scan
Pointers
- [Official] Security checklist
- [Official] Context Isolation
- [Tool] Electronegativity scanner — Static analysis for Electron-specific security misconfigurations.
[!CAUTION] Gotchas
- An XSS in your renderer + nodeIntegration: true = remote code execution on the user's machine. Don't enable it 'temporarily' for a feature; bridge specific APIs through preload.
- contextIsolation: true changes how the preload script works (window.X in preload != window.X in renderer). Use contextBridge to expose anything.
- CSP unsafe-inline allows React + Vue dev tools to inject scripts. In dev, looser CSP is OK; in prod, lock it down to 'self' + your own asset host.
Agent prompt for this step
Audit this Electron app's main + preload + renderer source for security risks.
For each BrowserWindow:
1. Confirm nodeIntegration: false, contextIsolation: true, sandbox: true.
2. List every API exposed via contextBridge.exposeInMainWorld + check whether each is necessary.
3. Confirm no use of remote module (deprecated, removed in Electron 14+).
4. Confirm CSP is set and disallows unsafe-inline / unsafe-eval.
5. Check IPC handlers for input validation (the message bus is reachable from any compromised renderer).
Output:
- A risk report with severity per finding (high / medium / low)
- Concrete code changes for each finding
- A 5-line summary I can paste into the Brief
Step 3: Wire the IPC bridge between renderer and main
Estimated time: 2-4 hr
Renderer (UI) and main (Node + native APIs) talk via IPC. ipcMain.handle in main + ipcRenderer.invoke in preload (exposed via contextBridge) is the modern pattern. Avoid ipcRenderer.send / on for new code — handle/invoke gives you Promise-based async out of the box.
Tasks
- In main.ts: ipcMain.handle('open-file', async (event, path) => fs.readFile(path))
- In preload.ts: contextBridge.exposeInMainWorld('api', { openFile: (p) => ipcRenderer.invoke('open-file', p) })
- In renderer: const text = await window.api.openFile(path)
- Validate every IPC argument in main (renderer is untrusted)
- Use a typed shape for the bridge: write a shared d.ts and reference it from preload + renderer
- Do NOT pass node objects (Buffer, Stream) through IPC — they get cloned via structured clone and may break
Pointers
- [Official] Inter-Process Communication (IPC)
- [Official] contextBridge API
[!CAUTION] Gotchas
- If you forget to await ipcMain.handle's promise, the renderer hangs. Always return a value or throw.
- Strings are fine. ArrayBuffers are fine. Functions, classes, and DOM nodes don't survive structured clone — IPC will silently drop them.
- Long-running IPC handlers (>30 sec) can block the main process. Off-load to a worker thread or stream events back via webContents.send.
Step 4: Get the macOS code-signing setup right
Estimated time: 3-5 hr
For macOS distribution outside the App Store, you need a Developer ID Application certificate (from your Apple Developer Program account) AND notarization through notarytool. Without notarization, Gatekeeper blocks the app on first launch with an unhelpful error. Without signing at all, the user has to right-click + Open every time.
Tasks
- Enrol in the Apple Developer Program ($99/year)
- In Xcode → Settings → Accounts: sign in + click Manage Certificates → Developer ID Application
- Verify the cert is in your Keychain: security find-identity -p codesigning
- Generate an app-specific password at appleid.apple.com for notarytool
- In electron-builder.yml: mac.identity (auto-detect) + afterSign hook for notarization
- Use @electron/notarize: { appleId, appleIdPassword: '@keychain:AC_PASSWORD', teamId }
- Build, watch the notarization run for 3-15 min, confirm 'Notarization complete'
- spctl -a -vvv path/to/MyApp.app should report 'accepted source=Notarized Developer ID'
Pointers
- [Official] Code signing for macOS
- [Official] Apple notarization documentation
- [Code] @electron/notarize
[!CAUTION] Gotchas
- Notarization rejects unsigned binaries inside your app bundle. Native modules (sqlite3, sharp, ffmpeg) often ship pre-built .node files that need re-signing. electron-builder usually handles this; if not, add hardenedRuntime + entitlements.
- Apple deprecated altool in Nov 2023; use notarytool. If a 5-year-old guide tells you to run xcrun altool, ignore it.
- Notarization takes 3-15 min usually but has been known to take 24+ hr during peak (early December). Don't ship at the last minute.
Step 5: Get the Windows code-signing setup right
Estimated time: 1-2 days (most of it cert delivery + identity verification)
Windows code signing changed dramatically in mid-2023. Standard OV certificates are now required to be on hardware (HSM, USB token, or cloud signing service). EV certs (Extended Validation) instantly clear SmartScreen reputation; OV certs build reputation over weeks of installs. Pick OV if budget is tight, EV if you can afford the $400-700/year.
Tasks
- Pick OV or EV. EV: instant SmartScreen pass, $400-700/year. OV: $200-400/year, builds reputation slowly
- Buy from DigiCert, Sectigo, SSL.com, or GlobalSign
- Verify your business identity (D-U-N-S number for OV; in-person notary for EV)
- Receive the cert: USB token (legacy), cloud signing API (modern), or PKCS#11 HSM
- Configure electron-builder.yml: win.signtoolOptions or use signing-utils for cloud signing
- Test signing: signtool verify /pa /v MyApp.exe should report 'Successfully verified'
- Run the signed installer on a Windows machine you've never installed it on; SmartScreen should pass (EV) or warn once (OV until reputation builds)
Pointers
- [Official] Windows code signing requirements (2023+)
- [Official] electron-builder Windows signing
- [Tool] SSL.com cloud signing API — Cloud signing skips the USB-token-on-CI problem.
[!CAUTION] Gotchas
- USB tokens (the old default) DO NOT WORK in CI. CI runners can't insert a physical token. Use a cloud signing service (SSL.com eSigner, DigiCert KeyLocker, AzureSignTool) for automated CI builds.
- Even with a valid OV cert, SmartScreen may flag the installer for the first 1k-3k downloads. EV certs skip this. Plan accordingly.
- Renewal is a separate identity verification each time. Calendar 60 days before expiry; cutting it close means a broken release week.
Step 6: Build installers with electron-builder
Estimated time: 2-4 hr
electron-builder produces the platform-specific installers: .dmg + .pkg for macOS, .exe (NSIS) + .msi for Windows, .AppImage + .deb + .rpm for Linux. Configure once in package.json's build key (or electron-builder.yml) and let it run per platform.
Tasks
- npm i -D electron-builder
- Add build script: 'dist': 'electron-builder -mwl' (mac, windows, linux)
- Configure build.appId (reverse-DNS, e.g. com.yourcompany.appname) — PERMANENT once shipped
- Configure build.productName + build.copyright
- For macOS: build.mac.category, build.mac.target ['dmg', 'zip']
- For Windows: build.win.target ['nsis'], build.nsis.oneClick = false (let user pick install path)
- For Linux: build.linux.target ['AppImage', 'deb']
- Build on each OS (or use GitHub Actions matrix) — cross-compiling is unreliable
Pointers
- [Official] electron-builder configuration
- [Official] GitHub Actions matrix for Electron
[!CAUTION] Gotchas
- Cross-compiling Windows builds from macOS works for unsigned builds but fails signing because signtool is Windows-only (alternatives like osslsigncode work but are flaky). Use a Windows runner.
- Linux AppImage requires libfuse on the user's machine. Modern distros include it; older Linux Mint / Debian sometimes don't. Ship a .deb or .rpm too.
- appId is PERMANENT. Changing it breaks auto-update for existing users (the new build looks like a different app).
Step 7: Wire up auto-update with electron-updater
Estimated time: 2-4 hr
electron-updater reads a public release feed (GitHub Releases is the easiest), downloads the new version in the background, and applies it on next launch. Five lines of code in main.ts + a publish key in electron-builder config = users always have the latest build.
Tasks
- npm i electron-updater
- In main.ts: import { autoUpdater } from 'electron-updater' + autoUpdater.checkForUpdatesAndNotify()
- Add publish config to electron-builder: { provider: 'github', owner: 'you', repo: 'your-app' }
- Build + publish: GH_TOKEN=... npm run dist + npm run publish
- Verify a draft GitHub Release was created with the installer + a latest.yml / latest-mac.yml manifest
- Publish the release; install old version + restart; verify the auto-update flow
- Listen for events: update-downloaded, update-error, etc., to give the user feedback
Pointers
- [Official] electron-updater overview
- [Official] Publish configuration
[!CAUTION] Gotchas
- The latest.yml / latest-mac.yml / latest-linux.yml files MUST be in the GitHub Release. If electron-builder fails to upload them, auto-update silently no-ops on every user's machine.
- macOS auto-update requires the new build to be signed by the SAME team as the installed build. Switching Apple Developer accounts breaks updates.
- Windows NSIS installers can fail auto-update if the user installed in a per-machine path without admin rights; force per-user install (oneClick: false, perMachine: false) for smoother updates.
Step 8: Set up a CI pipeline that signs + notarizes + publishes
Estimated time: 1 day
On every git tag (v1.0.1), GitHub Actions runs three jobs in parallel (mac-13, windows-2022, ubuntu-latest), each builds + signs + uploads the platform installer to a draft GitHub Release. When all three are green, you publish the Release manually and auto-update fires for every user.
Tasks
- Create .github/workflows/release.yml triggered on tag push
- Matrix job: macos-latest + windows-latest + ubuntu-latest
- Add secrets: APPLE_ID, APPLE_ID_PASSWORD, APPLE_TEAM_ID, CSC_LINK (mac p12), CSC_KEY_PASSWORD
- Add Windows secrets: WIN_CSC_LINK + WIN_CSC_KEY_PASSWORD OR cloud signing API creds
- Add GH_TOKEN with write permission on the repo's Releases
- Workflow steps: checkout, setup-node, npm ci, npm test, npm run dist + publish
- Push a v1.0.1 tag locally, watch all three OS jobs run, confirm a draft Release is created
- Publish the draft Release; users on v1.0.0 auto-update within ~24 hr
Pointers
- [Official] Multi-platform builds in CI
- [Code] GitHub Actions for Electron (community)
[!CAUTION] Gotchas
- macOS notarization in CI needs a CI runner with Xcode + a paid Apple Developer team. GitHub-hosted macos-latest works; private runners often don't have Xcode.
- Windows EV cert signing in CI requires a cloud-signing API or a self-hosted runner with the USB token plugged in. GitHub-hosted runners can't access USB tokens.
- Tag prefixes matter. semantic-release defaults to v1.2.3 but electron-builder publishes tagged 1.2.3 (no v). Pick one and stick with it; switching breaks the auto-update feed.
Step 9: Build the install + first-run experience
Estimated time: 1-2 days
Desktop app onboarding is harder than web: no analytics on the install page, no instant feedback if something fails. Build a polished first-run: a small welcome screen, a privacy disclosure if you collect any telemetry, a Quick Start, and a clear path to Help.
Tasks
- On first launch: render a Welcome window with a 30-second walkthrough
- Show what data the app reads + sends (telemetry on/off toggle)
- Set up a Help menu item linking to your docs + support email
- Add a 'Send Diagnostic Report' menu item (zips logs, opens mailto)
- Add About dialog with version, license, link to release notes
- Save first-run state in app.getPath('userData') + electron-store
Pointers
- [Official] App data paths
- [Official] Menu API
[!CAUTION] Gotchas
- userData paths differ per OS: ~/Library/Application Support/
on macOS, %APPDATA%<name> on Windows, ~/.config/ on Linux. Don't hardcode. - Auto-launch on system startup (auto-launch package or app.setLoginItemSettings) is a permission users HATE if you don't ask first. Default off.
- The macOS dock icon disappears if your app has no windows + activate event handler. Add one or your app vanishes when the user closes the window.
Step 10: Post-launch: crash reporting, telemetry, and update cadence
Estimated time: Ongoing, 5-10 hr/week for the first month
Once shipped, the operations layer is: crash reports (Sentry / Bugsnag wired into Electron's native crashpad), update adoption (electron-updater + electron-log shows you the version distribution), and a sane release cadence (don't ship a major every week).
Tasks
- Wire @sentry/electron or Bugsnag for crash reports + JS errors
- Wire electron-log for local logs + a 'Send Diagnostic Report' button that uploads them
- Track install / launch / version distribution via your own analytics or a third-party (PostHog, Plausible)
- Adopt a release cadence: patch every 1-2 weeks, minor every 1-2 months, major when needed
- Calendar Apple Developer renewal (yearly) + Windows cert renewal (yearly)
- Document the upgrade path for every breaking change
Pointers
- [Official] Crash reporter API
- [Code] @sentry/electron
- [Code] electron-log
[!CAUTION] Gotchas
- Native crashes (the Chromium renderer crashing in C++) need crashpad. Sentry's electron SDK wires it for you; raw Sentry browser SDK does not.
- Auto-update adoption is slower than web: a percentage of users always lag behind by 1-2 versions because they don't restart. Critical fixes need a clear in-app prompt.
- Telemetry collection without consent is a fast track to a privacy review. Always ship with telemetry off + an opt-in toggle in settings.
Hand the template to your agent
Paste the prompt below into your agent's permanent system prompt so the agent reads, writes, and maintains this workspace as you work through the steps.
You are an agent on the "Build a desktop app with Electron" playbook workspace at your-org/build-a-desktop-app-with-electron.
Your role: maintain the four surfaces (Steps, Pointers, Brief, Build log) as the user works through the 10-step playbook.
Cadence:
- When the user marks a step Done, append a line to the Brief.
- On every signed build (the build CI publishes a version), append a row to Build log: version, platform (mac, win, linux), signing status, notarization status, download URL.
- When a cert nears expiry (90, 30, 7 days out), append a Brief warning + tag flint to remind the user.
First MCP tool calls:
1. list_surfaces(workspace_slug="build-a-desktop-app-with-electron")
2. list_rows(workspace_slug="build-a-desktop-app-with-electron", surface_slug="steps")
3. get_doc(workspace_slug="build-a-desktop-app-with-electron", surface_slug="brief")
Do NOT modify canonical step titles. Append substeps as new rows.
FAQ
Do I really need code signing?
On macOS: yes, fully. Without a Developer ID Application cert + notarization, Gatekeeper blocks the app on first launch and most users won't know to right-click + Open. On Windows: practically yes. Without a code signing cert, SmartScreen warns users 'Windows protected your PC' and most click 'Don't Run'. On Linux: no, signing isn't a thing in the same way; AppImage signatures are optional and rarely checked.
EV vs OV Windows certificate?
EV ($400-700/year) skips SmartScreen reputation building entirely; users see no warning from day one. OV ($200-400/year) requires the installer to be downloaded a few thousand times before SmartScreen learns to trust it; the first thousand users see a warning. If you have any path to revenue, EV pays for itself in lower install drop-off.
How big is an Electron app?
150-200 MB installed for an 'empty' Electron app. Most of it is Chromium + Node. Stripping unused locales (build.electronLanguages) saves 30-50 MB. Removing dev dependencies from node_modules saves another 10-30 MB. Real-world apps end up at 200-400 MB. Compare to a native desktop app (5-30 MB), and decide if Electron's 'one codebase' tradeoff is worth it.
Should I use Tauri / Wails instead?
If you don't need any Node-native APIs and your app is mostly UI: Tauri (Rust + system webview) ships 10-20 MB binaries and is gaining traction. Wails (Go + system webview) is similar. Electron's win is ecosystem maturity (electron-builder, electron-updater, code-sign tooling, 8 years of Stack Overflow). For a first desktop app on a tight timeline, Electron is still the safer bet.
Can my AI agents help with the Electron app?
Yes. Agents are particularly useful for: auditing main + preload + renderer for security misconfigurations, drafting release notes from git log, drafting the privacy / telemetry disclosure for first run, configuring the GitHub Actions multi-platform build. The playbook ships agent prompts inline.