DEV Community

Cover image for Electron Cross-Platform Shipping: A Field Guide to Packaging, Code Signing & Auto-Update
mp3sss
mp3sss

Posted on AI-assisted

Electron Cross-Platform Shipping: A Field Guide to Packaging, Code Signing & Auto-Update

Writing an Electron app is easy. Shipping it — to real users, on Windows and macOS, without scary SmartScreen warnings or broken updates — is where most teams burn weeks.

I've spent the last two years shipping a cross-platform Electron client (Windows + macOS) to production users. This is the field guide I wish I'd had on day one: the packaging stack, the signing traps, and an auto-update flow that won't corrupt your users' installations.


1. Pick your packaging stack once

Three main options exist:

Tool Style Verdict
electron-packager Bare-bones Good for dev builds; you'll end up scripting everything
electron-forge All-in-one Powerful, but heavier to configure for advanced signing
electron-builder Declarative config The pragmatic default — one config file drives installers, signing, and publishing

I recommend electron-builder for most teams. It handles NSIS installers on Windows, DMG/ZIP on macOS, code signing, and publishing to update servers through a single electron-builder.yml. You don't need to be an installer expert to ship with it.

Key decision: electron-builder can also sign your binaries during packaging — a huge win, because signing after modifying the binary is a classic source of broken builds (more on that later).


2. Windows: SmartScreen, certificates, and the "Windows protected your PC" wall

If you ship an unsigned Windows build, your users will see this:

Windows protected your PC — Microsoft Defender SmartScreen prevented an unrecognized app from starting.

For a non-technical user, that screen is a conversion killer. Unsigned builds are for internal testing only.

OV vs EV certificates

  • OV (Organization Validation) — cheaper, issued in days. After you've signed enough installers, SmartScreen reputation builds up and warnings fade.
  • EV (Extended Validation) — expensive, hardware token required, instant reputation. If you're a startup with a small install base, EV is the shortcut past the "new publisher" trust problem.

Practical note: the timestamp server matters more than people think. If you don't timestamp, your signature expires when the certificate does. Always timestamp:

# electron-builder.yml
win:
  target:
    - target: nsis
      arch: [x64, arm64]
  certificateFile: ./certs/win-cert.pfx
  certificatePassword: ${env.WIN_CERT_PASSWORD}
  signtoolOptions:
    signWithParams: /tr http://timestamp.digicert.com /td sha256 /fd sha256
Enter fullscreen mode Exit fullscreen mode

SmartScreen reality check

Even with a valid OV signature, the first few thousand installs may still show a warning until Microsoft's reputation engine recognizes your publisher. Don't panic — this is normal. Ship, collect reputation, and re-evaluate EV if installs stall.

NSIS installers: one-way upgrades

Use NSIS with oneClick: false if you want a per-user/per-machine choice. Keep the installerIcon and uninstallerIcon custom — tiny details that make your installer look professional rather than default.


3. macOS: signing, notarization, and the Gatekeeper gauntlet

macOS is the stricter platform by far. Since Catalina, every app must be notarized or users face an "cannot be opened because the developer cannot be verified" block.

The three-step dance

  1. Sign your app bundle with a Developer ID Application certificate.
  2. Notarize it — upload to Apple's notary service, wait for approval.
  3. Staple the notarization ticket to the app so it validates offline.

Hardened runtime & entitlements

If you enable the hardened runtime (required for notarization), you must declare entitlements — otherwise your app may crash at launch on the user's machine while working fine on yours. The classic missing entitlement:

<!-- entitlements.mac.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
</dict>
</plist>
Enter fullscreen mode Exit fullscreen mode

Warning: If your app spawns helper binaries or uses native modules, disable-library-validation is frequently required — but it weakens security. Add it deliberately, document why, and remove it if you can.

electron-builder config for macOS

mac:
  target:
    - target: dmg
      arch: [x64, arm64]
  category: public.app-category.developer-tools
  hardenedRuntime: true
  entitlements: ./entitlements.mac.plist
  entitlementsInherit: ./entitlements.mac.plist
  notarize:
    teamId: ${env.APPLE_TEAM_ID}
    appleId: ${env.APPLE_ID}
    appleIdPassword: ${env.APPLE_APP_SPECIFIC_PASSWORD}
Enter fullscreen mode Exit fullscreen mode

The traps that cost days

  • "Certificate not found in keychain" — the certificate's private key must be in the login keychain of the machine doing the build, not the System keychain, and its type must be "Developer ID Application" (not "Developer ID Installer" — they look similar).
  • CI machines lose keychains constantly. Export the certificate as a .p12 and import it in your build step, or use a signing service.
  • Apple ID with 2FA — you must use an app-specific password, never your account password.
  • Notarization latency — allow 5–15 minutes per submission. Batch it into your release pipeline, not your pre-release "ship now" hour.

4. Auto-update that doesn't break users

The fastest way to lose trust is an update that bricks your app. Here's a flow that has survived production for two years.

Use electron-updater

npm i electron-updater
Enter fullscreen mode Exit fullscreen mode
const { autoUpdater } = require("electron-updater");

autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;

// Only check when the app is foregrounded or on a timer — never at every boot.
autoUpdater.checkForUpdatesAndNotify();
Enter fullscreen mode Exit fullscreen mode

Config it with a plain file server (nginx, S3, any static host):

publish:
  provider: generic
  url: https://updates.example.com/releases
Enter fullscreen mode Exit fullscreen mode

Differential updates

electron-builder generates .blockmap files that let electron-updater download only the changed bytes — typically 5–20% of the full package for a normal release. This is the single biggest win for update speed.

Rollback safety

Three rules keep updates from becoming outages:

  1. Never delete the previous version's files on the server. Keep at least the last two releases so a bad rollout can be rolled back by re-publishing the old latest.yml.
  2. Validate the downloaded package before install — electron-updater verifies the signature/hash automatically. Don't disable it.
  3. Ship updates to a percentage of users for risky releases, then ramp up. Electron apps have no built-in percentage rollout — implement a simple "update channel" by environment variable or server-side allowlist if you need it.

Version numbering

electron-updater compares semver strictly. 1.0.10 is newer than 1.0.9; a 1.0.9.5 four-part version will confuse comparisons. Enforce MAJOR.MINOR.PATCH everywhere — package.json, the updater, and your release tooling.


5. Versioning & CI: make releases boring

The release process should be one command, run by a pipeline, not a person.

GitHub Actions matrix example

name: Release
on:
  push:
    tags: ["v*"]
jobs:
  build:
    strategy:
      matrix:
        os: [windows-latest, macos-latest]
        arch: [x64, arm64]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx electron-builder --publish always
        env:
          WIN_CERT_PASSWORD: ${{ secrets.WIN_CERT_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
Enter fullscreen mode Exit fullscreen mode

Cache & artifact hygiene

  • Cache node_modules per-OS (native modules are platform-specific).
  • Only sign once, on the final packaged artifact — never sign a zip, then unzip, modify, and re-sign. Re-signing after modification invalidates the seal and Gatekeeper will reject it.
  • Keep a release checklist in your repo: "tag → pipeline → smoke test on a clean Windows VM and a clean macOS VM → announce."

6. The real-world pitfall list

Bugs I actually hit, in roughly the order they'll hit you:

  1. SmartScreen warning on first release even with OV cert — fixed by patience + EV evaluation.
  2. macOS app crashes only on users' machines — root cause: missing entitlements for JIT/native modules under hardened runtime.
  3. Update loop after bad release — users stuck on broken version; fixed by keeping old latest.yml and re-publishing previous release.
  4. Windows Defender false positives — Electron apps occasionally trip heuristics, especially with unsigned helper executables. Submit to Microsoft for review; keep signing everything including helpers.
  5. CI "certificate not found" — keychain not imported in fresh runners; fixed with a .p12 import step.
  6. DMG name with spaces breaking update paths — normalize filenames to AppName-1.2.3.dmg strictly. Spaces in update URLs are a silent source of 404s.
  7. ARM64 vs x64 update mixing — electron-updater picks the right arch automatically from package.json arch field, but only if you publish both variants. Check your latest-mac.yml / latest.yml list both.

7. TL;DR — if you remember five things

  1. electron-builder + electron-updater is the stack that works.
  2. Sign everything, every time, with timestamps — on Windows and macOS.
  3. macOS = sign → notarize → staple, and never modify the bundle after signing.
  4. Keep the last two releases on the server — rollback is your safety net.
  5. Strict semver or the updater will fight you.

Shipping a cross-platform Electron app is a solved problem — but only if you treat packaging and distribution as a first-class engineering concern, not an afterthought. Automate it, sign it, and your users will never think about it. That's the whole point.

Have your own shipping war stories? Drop them in the comments — the worst ones are the most instructive.

Top comments (0)