Steven's Knowledge

Packaging

Turning an Electron app into signed, notarized installers — electron-builder vs Forge, asar, per-platform targets, code signing, notarization, and architectures

Packaging

Packaging is the step that turns a folder of JavaScript into a .dmg, .exe, or .AppImage a user can install — correctly signed so the OS trusts it. Get this wrong and users see scary warnings, downloads get quarantined, or auto-update silently refuses to apply.

Choosing a Tool

Three tools dominate. They differ in configuration style and how much of the signing/updating story they own.

ToolConfig styleSigningUpdaterPluginsNotes
electron-builderDeclarative YAML/JSON build blockmacOS + Windows + notarization built inFirst-class via electron-updaterLimited (hooks)The common default; batteries included
Electron ForgeJS config + makers/pluginsVia makers (@electron-forge/maker-*)Squirrel-basedRich plugin model (Vite/Webpack)The official toolchain; pick for the plugin ecosystem
@electron/packagerProgrammatic API onlyYou wire signing yourselfNoneNoneLow-level; produces an app bundle, not installers

@electron/packager only produces the unpacked .app/exe directory — it does not make installers or handle the update feed. Most teams want one of the higher-level tools.

Pick electron-builder unless you have a reason not to. It covers macOS signing + notarization, Windows Authenticode, all the common installer targets, and integrates directly with electron-updater (see the Auto-Update page). Choose Forge when you want the official, plugin-driven toolchain (its Vite/Webpack plugins and maker model) and are comfortable wiring the updater yourself.

The asar Archive

asar is a simple concatenated archive (like an uncompressed tar) of your app source. Electron reads files directly out of it, so most fs reads "just work" against paths inside the archive. It exists to avoid shipping thousands of small files (faster install, fewer path-length issues on Windows) and to lightly obscure source — it is not encryption.

Some files cannot live inside asar and must be unpacked to disk:

  • Native .node modules — the OS loader needs a real file path to dlopen.
  • Sidecar binaries / executables you spawn with child_process.
  • Anything addressed by an absolute real path (some libraries that fork a worker file).
# electron-builder.yml
asar: true
asarUnpack:
  - "**/*.node"
  - "node_modules/sharp/**"
  - "resources/ffmpeg*"

Unpacked files land in app.asar.unpacked/ next to app.asar. At runtime resolve them by replacing app.asar with app.asar.unpacked in the path (electron-builder’s tooling and many libraries do this for you).

asar is not a security boundary. Anyone can run npx @electron/asar extract app.asar out/ and read your code. Do not ship secrets (API keys, signing material) inside the bundle — they belong on a server or in OS-protected credential storage.

Per-Platform Targets

Each OS has several installer formats. Pick the ones your audience expects.

PlatformTargetUse it for
macOSdmgStandard drag-to-Applications installer
macOSzipRequired for Squirrel.Mac auto-update feed
macOSmasMac App Store (sandboxed, separate provisioning)
WindowsnsisDefault installer; supports per-user/per-machine, differential updates
WindowsmsiEnterprise/MDM deployment
WindowsportableSingle self-contained exe, no install
WindowsappxMicrosoft Store
LinuxAppImageDistro-agnostic single file; supports auto-update
Linuxdeb / rpmDebian/Ubuntu and Fedora/RHEL system packages
Linuxsnap / flatpakSandboxed store distribution with self-update

A typical config ships dmg+zip on macOS (zip feeds the updater), nsis on Windows, and AppImage+deb on Linux.

Code Signing

Signing proves the binary came from you and was not tampered with. Unsigned apps trigger Gatekeeper (macOS) and SmartScreen (Windows) warnings, and unsigned macOS builds cannot auto-update at all.

macOS — Developer ID

For distribution outside the App Store you sign with a Developer ID Application certificate, enable the hardened runtime, and grant any required entitlements.

<!-- build/entitlements.mac.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<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/>
  <!-- only if you use the camera / mic -->
  <key>com.apple.security.device.camera</key><true/>
</dict>
</plist>

electron-builder picks up the certificate from the login keychain (or CSC_LINK + CSC_KEY_PASSWORD in CI) and signs every nested binary, including unpacked native modules.

Windows — Authenticode

Windows binaries are signed with an Authenticode code-signing certificate.

CertificateSmartScreen behavior
OV (Organization Validation)Builds reputation over time; new versions may still warn until enough installs accrue
EV (Extended Validation)Instant SmartScreen trust; no warning from day one

EV (and increasingly OV) certificates now require the private key on a hardware token (FIPS HSM) or a cloud signing service (Azure Trusted Signing, DigiCert KeyLocker, SSL.com eSigner). You cannot export the key to a file, so CI signs by calling the cloud/HSM provider rather than reading a .pfx.

Why signing matters. On macOS, Gatekeeper refuses to launch an unsigned/un-notarized app downloaded from the internet ("…is damaged and can’t be opened"). On Windows, SmartScreen shows a red "unrecognized app" wall. Either one tanks your install conversion. Treat signing as a release requirement, not a nice-to-have.

macOS Notarization

Signing is necessary but not sufficient: macOS also requires notarization. You upload the signed app to Apple, their service scans it for malware, and returns a ticket. You then staple that ticket to the artifact so Gatekeeper can verify it offline.

The modern flow uses notarytool (the legacy altool is removed). electron-builder runs it automatically when notarization credentials are present:

# electron-builder.yml
mac:
  hardenedRuntime: true
  gatekeeperAssess: false
  entitlements: build/entitlements.mac.plist
  entitlementsInherit: build/entitlements.mac.plist
  notarize: true   # uses APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID
                   # or APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER
# what runs under the hood (managed for you by electron-builder):
xcrun notarytool submit App.dmg --apple-id "$APPLE_ID" \
  --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait
xcrun stapler staple App.dmg   # attach the ticket for offline verification

Notarization is required for any app distributed outside the Mac App Store on current macOS. Without it, Gatekeeper blocks the app on first launch.

Architectures

Apple Silicon and Intel are different CPU architectures, as are Windows/Linux x64 vs arm64.

ArchitectureWhere
x64Intel/AMD macOS, Windows, Linux
arm64Apple Silicon (M-series), Windows on ARM, ARM Linux
universalmacOS fat binary containing both x64 and arm64

A macOS universal build runs natively everywhere but roughly doubles download size. Alternatively ship separate x64 and arm64 artifacts and let the update feed serve the right one. On Windows/Linux, ship arm64 only if you have demand — most users are still x64.

electron-builder --mac --x64 --arm64        # two separate mac artifacts
electron-builder --mac --universal          # one fat binary

A Representative Config

A complete electron-builder.yml for a multi-platform, signed, auto-updating app:

# electron-builder.yml
appId: com.example.myapp
productName: My App
copyright: Copyright © 2026 Example Inc.

directories:
  output: dist
  buildResources: build

# Ship only what the runtime needs — never source or devDependencies.
files:
  - "out/**/*"           # compiled main/preload/renderer
  - "package.json"
  - "!**/*.{ts,map,md}"  # prune source maps, types, docs
  - "!**/{test,__tests__,.github}/**"

asar: true
asarUnpack:
  - "**/*.node"

mac:
  category: public.app-category.productivity
  target:
    - { target: dmg, arch: [x64, arm64] }
    - { target: zip, arch: [x64, arm64] }   # zip feeds the auto-updater
  hardenedRuntime: true
  entitlements: build/entitlements.mac.plist
  entitlementsInherit: build/entitlements.mac.plist
  notarize: true

win:
  target:
    - { target: nsis, arch: [x64] }
  # signing handled via cloud HSM / signtool config or CSC_* env vars

nsis:
  oneClick: false
  perMachine: false
  allowToChangeInstallationDirectory: true

linux:
  target: [AppImage, deb]
  category: Utility

publish:
  provider: github   # generates latest.yml / latest-mac.yml for electron-updater
  owner: example
  repo: myapp

The publish block is what makes auto-update possible: electron-builder writes the metadata files (latest.yml, latest-mac.yml, *.blockmap) the updater reads. See the Auto-Update page.

What Ships vs What Doesn’t

Your installer must contain the runtime app and its production dependencies — and nothing else. Two common approaches:

  • files pruning (above) — keep one package.json, but use files globs to exclude source, tests, and dev tooling. electron-builder already strips devDependencies from node_modules when it rebuilds the production tree.
  • Two-package.json structure — a root package.json for build tooling and an app/package.json with only runtime deps. The app/ folder is what gets packaged. This makes the boundary explicit and is the Forge-friendly layout.

Native deps must be real dependencies, not devDependencies. Anything required at runtime — better-sqlite3, sharp, your own native addon — has to be a production dependency or it will be pruned out of the package and the app will crash on launch with "module not found". Build-only tools (electron, electron-builder, bundlers) stay in devDependencies.

Reproducibility and Secrets

  • Pin versions. Pin electron, electron-builder, and native deps exactly, and commit a lockfile. An Electron bump changes the ABI and can break native modules; an unpinned builder can change output formats.
  • Pin the Electron version explicitly — auto-update artifacts must be built against a known runtime, and a surprise major bump can break signing or notarization.
  • Keep signing secrets in CI, never in the repo. Certificates, APPLE_API_KEY, Authenticode tokens, and provider credentials live in CI secret storage and are injected as environment variables at build time. A leaked Developer ID or code-signing key lets anyone ship malware as you.

Checklist

  • Choose electron-builder (or Forge with the updater wired) — not raw packager for distribution.
  • asar: true; asarUnpack native .node modules and sidecar binaries.
  • Build the targets your users expect; include macOS zip for the updater.
  • macOS: Developer ID + hardened runtime + entitlements + notarize.
  • Windows: Authenticode (EV to skip SmartScreen); sign via HSM/cloud in CI.
  • Ship arm64 where it matters; universal mac if you want one artifact.
  • Runtime/native deps in dependencies; prune source and devDeps from the package.
  • Pin versions, commit the lockfile, keep all signing secrets in CI.

On this page