Building an Auto-Updating Electron App with AWS S3, CloudFront, and Terraform
Implementing Automatic Updates in Electron Apps with AWS S3 and CloudFront Author: Habibul Ali Shah Date: May 07, 2025 Introduction Building desktop applications with Electron gives web developers the power to create cross-platform apps using familiar web technologies. However, one critical aspect of desktop application development is implementing a robust update mechanism to ensure users always have access to the latest features and security patches. In this technical deep dive, we'll explore how to implement an automatic update system for Electron applications using AWS S3 for storage and CloudFront for content delivery. This approach offers excellent scalability, reliability, and performance for distributing application updates. Why AWS S3 and CloudFront? Before diving into implementation details, let's understand why this infrastructure choice makes sense: Cost-effective storage: S3 provides durable, highly-available object storage at a reasonable cost Global distribution: CloudFront's CDN ensures fast downloads for users worldwide Security: Access to update files can be tightly controlled Scalability: Handles any number of users without performance degradation Reliability: AWS's infrastructure provides excellent uptime guarantees Architecture Overview Our update system consists of several key components: Electron application: Implements electron-updater to check for and download updates AWS S3 bucket: Stores application update files (.exe, .dmg, .AppImage, etc.) CloudFront distribution: Delivers update files efficiently to users worldwide Multi-channel support: Separate update paths for stable, beta, and dev releases The application checks for updates, downloads them in the background, and then prompts users to install when ready. This happens via secure HTTPS connections to our CloudFront distribution. Setting Up the AWS Infrastructure with Terraform Instead of manually configuring AWS resources, we'll use Terraform to create our infrastructure as code. This approach ensures consistency and makes it easy to recreate or modify the infrastructure. Our main Terraform resources include: 1. S3 Bucket Configuration # Create an S3 bucket with private access by default module "s3_bucket" { source = "terraform-aws-modules/s3-bucket/aws" version = "4.3.0" bucket = "electron-update-auto" acl = "private" control_object_ownership = true object_ownership = "ObjectWriter" force_destroy = true block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true versioning = { enabled = true } server_side_encryption_configuration = { rule = { apply_server_side_encryption_by_default = { sse_algorithm = "AES256" } } } } 2. CloudFront Distribution Configuration module "cdn" { source = "terraform-aws-modules/cloudfront/aws" version = "4.1.0" comment = "CloudFront for electron-update- environment" enabled = true is_ipv6_enabled = true price_class = "PriceClass_All" retain_on_delete = false wait_for_deployment = false create_origin_access_control = true origin_access_control = { "electron-update-s3-oac" = { description = "OAC for electron-update- environment" origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } } origin = { s3_update = { domain_name = module.s3_bucket.s3_bucket_bucket_domain_name origin_access_control = "electron-update-s3-oac" origin_shield = { enabled = true origin_shield_region = "us-east-1" } } } default_cache_behavior = { target_origin_id = "s3_update" viewer_protocol_policy = "https-only" allowed_methods = ["GET", "HEAD", "OPTIONS"] cached_methods = ["GET", "HEAD"] compress = true query_string = true cache_policy_id = aws_cloudfront_cache_policy.s3_cache_policy.id } } 3. S3 Bucket Policy for CloudFront Access resource "aws_s3_bucket_policy" "allow_access_from_cloudfront" { bucket = module.s3_bucket.s3_bucket_id policy = jsonencode({ "Version" : "2008-10-17", "Id" : "PolicyForCloudFrontPrivateContent", "Statement" : [ { "Sid" : "AllowCloudFrontServicePrincipal", "Effect" : "Allow", "Principal" : { "Service" : "cloudfront.amazonaws.com" }, "Action" : "s3:GetObject", "Resource" : "${module.s3_bucket.s3_bucket_arn}/*", "Condition" : { "StringEquals" : { "AWS:SourceArn" : "arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${module.cdn.cloudfront_distribution_id}" } } } ]

Implementing Automatic Updates in Electron Apps with AWS S3 and CloudFront
Author: Habibul Ali Shah
Date: May 07, 2025
Introduction
Building desktop applications with Electron gives web developers the power to create cross-platform apps using familiar web technologies. However, one critical aspect of desktop application development is implementing a robust update mechanism to ensure users always have access to the latest features and security patches.
In this technical deep dive, we'll explore how to implement an automatic update system for Electron applications using AWS S3 for storage and CloudFront for content delivery. This approach offers excellent scalability, reliability, and performance for distributing application updates.
Why AWS S3 and CloudFront?
Before diving into implementation details, let's understand why this infrastructure choice makes sense:
- Cost-effective storage: S3 provides durable, highly-available object storage at a reasonable cost
- Global distribution: CloudFront's CDN ensures fast downloads for users worldwide
- Security: Access to update files can be tightly controlled
- Scalability: Handles any number of users without performance degradation
- Reliability: AWS's infrastructure provides excellent uptime guarantees
Architecture Overview
Our update system consists of several key components:
-
Electron application: Implements
electron-updater
to check for and download updates - AWS S3 bucket: Stores application update files (.exe, .dmg, .AppImage, etc.)
- CloudFront distribution: Delivers update files efficiently to users worldwide
- Multi-channel support: Separate update paths for stable, beta, and dev releases
The application checks for updates, downloads them in the background, and then prompts users to install when ready. This happens via secure HTTPS connections to our CloudFront distribution.
Setting Up the AWS Infrastructure with Terraform
Instead of manually configuring AWS resources, we'll use Terraform to create our infrastructure as code. This approach ensures consistency and makes it easy to recreate or modify the infrastructure.
Our main Terraform resources include:
1. S3 Bucket Configuration
# Create an S3 bucket with private access by default
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.3.0"
bucket = "electron-update-auto"
acl = "private"
control_object_ownership = true
object_ownership = "ObjectWriter"
force_destroy = true
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
versioning = {
enabled = true
}
server_side_encryption_configuration = {
rule = {
apply_server_side_encryption_by_default = {
sse_algorithm = "AES256"
}
}
}
}
2. CloudFront Distribution Configuration
module "cdn" {
source = "terraform-aws-modules/cloudfront/aws"
version = "4.1.0"
comment = "CloudFront for electron-update- environment"
enabled = true
is_ipv6_enabled = true
price_class = "PriceClass_All"
retain_on_delete = false
wait_for_deployment = false
create_origin_access_control = true
origin_access_control = {
"electron-update-s3-oac" = {
description = "OAC for electron-update- environment"
origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
}
origin = {
s3_update = {
domain_name = module.s3_bucket.s3_bucket_bucket_domain_name
origin_access_control = "electron-update-s3-oac"
origin_shield = {
enabled = true
origin_shield_region = "us-east-1"
}
}
}
default_cache_behavior = {
target_origin_id = "s3_update"
viewer_protocol_policy = "https-only"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
query_string = true
cache_policy_id = aws_cloudfront_cache_policy.s3_cache_policy.id
}
}
3. S3 Bucket Policy for CloudFront Access
resource "aws_s3_bucket_policy" "allow_access_from_cloudfront" {
bucket = module.s3_bucket.s3_bucket_id
policy = jsonencode({
"Version" : "2008-10-17",
"Id" : "PolicyForCloudFrontPrivateContent",
"Statement" : [
{
"Sid" : "AllowCloudFrontServicePrincipal",
"Effect" : "Allow",
"Principal" : {
"Service" : "cloudfront.amazonaws.com"
},
"Action" : "s3:GetObject",
"Resource" : "${module.s3_bucket.s3_bucket_arn}/*",
"Condition" : {
"StringEquals" : {
"AWS:SourceArn" : "arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${module.cdn.cloudfront_distribution_id}"
}
}
}
]
})
}
4. CloudFront Cache Policies
We create two cache policies:
- Standard caching policy: For application packages (.dmg, .exe, .AppImage)
- No-cache policy: For update metadata files (.yml, .json) to ensure users always get the latest update information
resource "aws_cloudfront_cache_policy" "s3_cache_policy" {
name = "electron-update-s3-cache-policy"
comment = "S3 cache policy for electron-update- environment"
default_ttl = 86400
max_ttl = 31536000
min_ttl = 1
# Configuration omitted for brevity
}
resource "aws_cloudfront_cache_policy" "s3_caching_disabled_policy" {
name = "electron-update-s3_caching_disabled_policy"
comment = "S3 no-cache policy for electron-update- environment"
default_ttl = 0
max_ttl = 0
min_ttl = 0
# Configuration omitted for brevity
}
Implementing Auto-Update in Electron
Now let's look at the application code. We'll use the electron-updater
package to handle the update process.
1. Package Configuration
First, we need to configure our package.json
file:
{
"name": "electron-updater-example",
"version": "0.1.1",
"main": "main.js",
"description": "electron-updater example project",
"author": "Ali",
"scripts": {
"postinstall": "electron-builder install-app-deps",
"start": "electron .",
"pack": "electron-builder --dir",
"publish": "env-cmd -e stable electron-builder --publish always"
},
"dependencies": {
"electron-log": "^5.3.4",
"electron-updater": "^6.6.2",
"env-cmd": "^10.1.0"
},
"build": {
"appId": "com.github.aliverses.electronupdaterexample",
"afterSign": "./build/notarize.js",
"mac": {
"category": "your.app.category.type",
"hardenedRuntime" : true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
},
"publish": [
{
"provider": "s3",
"bucket": "electron-update-auto",
"region": "us-east-1",
"path": "stable",
"channel": "stable",
"acl": "private"
}
]
}
}
The key section here is build.publish
, which configures the S3 bucket for publishing updates. The path
parameter corresponds to the update channel, allowing us to maintain separate paths for stable, beta, and development builds.
2. Auto-Update Implementation
Let's examine the main update logic in the Electron main process:
// Import Electron modules
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const log = require('electron-log');
// Configure logging
log.transports.file.level = 'info';
log.transports.console.level = 'debug';
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB
log.transports.file.maxFiles = 5;
// Global references
let mainWindow = null;
let autoUpdater = null;
let updateChannel = 'stable'; // Options: stable, beta, dev
// Configure the update channel
function setUpdateChannel(channel) {
if (!autoUpdater) return;
updateChannel = channel;
log.info(`Setting update channel to: ${channel}`);
try {
autoUpdater.setFeedURL({
provider: 'generic',
url: `https://dq860ai0sawu9.cloudfront.net/${channel}`,
channel: channel,
});
} catch (err) {
const errorMessage = err ? (err.message || err.toString()) : 'Unknown error setting feed URL';
log.error('Error setting feed URL:', errorMessage);
}
}
// Set up the auto-updater
function initAutoUpdater() {
try {
// Import electron-updater after app is ready
const { autoUpdater: updater } = require('electron-updater');
autoUpdater = updater;
// Configure logging
autoUpdater.logger = log;
// Set up event handlers
autoUpdater.on('checking-for-update', () => {
log.info('Checking for updates');
sendStatusToWindow('checking');
});
autoUpdater.on('update-available', (info) => {
log.info('Update available', info);
sendStatusToWindow('available', info);
});
autoUpdater.on('download-progress', (progress) => {
log.info(`Download progress: ${Math.round(progress.percent)}%`);
sendStatusToWindow('progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded', info);
sendStatusToWindow('downloaded', info);
});
// Set the feed URL
setUpdateChannel(updateChannel);
return true;
} catch (err) {
log.error('Failed to initialize auto-updater:', err);
return false;
}
}
3. IPC Handlers for Renderer Communication
We set up IPC handlers to allow the renderer process to interact with the update system:
function setupIpc() {
// Get current channel
ipcMain.handle('get-current-channel', () => {
return updateChannel;
});
// Set update channel
ipcMain.handle('set-update-channel', (event, channel) => {
if (!channel || !['stable', 'beta', 'dev'].includes(channel)) {
return { status: 'error', message: 'Invalid channel' };
}
setUpdateChannel(channel);
return { status: 'success', channel };
});
// Check for updates
ipcMain.handle('check-for-updates', async () => {
if (!autoUpdater) {
return { status: 'error', message: 'Auto-updater not initialized' };
}
try {
log.info('Manually checking for updates...');
const result = await autoUpdater.checkForUpdates();
log.info('Check for updates result:', result);
return { status: 'checking' };
} catch (err) {
const errorMessage = err ? (err.message || err.toString()) : 'Unknown error';
log.error('Error checking for updates:', errorMessage);
return { status: 'error', message: errorMessage };
}
});
// Trigger update installation
ipcMain.handle('trigger-update', () => {
if (!autoUpdater) {
return { status: 'error', message: 'Auto-updater not initialized' };
}
try {
log.info('Installing update and restarting...');
autoUpdater.quitAndInstall(true, true);
return { status: 'installing' };
} catch (err) {
const errorMessage = err ? (err.message || err.toString()) : 'Unknown error installing update';
log.error('Error installing update:', errorMessage);
return { status: 'error', message: errorMessage };
}
});
}
4. Preload Script for Secure Bridge
To maintain security with Electron's contextIsolation feature, we use a preload script:
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
electron: () => process.versions.electron
});
// Expose API for update functionality
contextBridge.exposeInMainWorld('updater', {
// Check for updates, optionally specify channel
checkForUpdates: (channel) => ipcRenderer.invoke('check-for-updates', channel),
// Get current update channel
getCurrentChannel: () => ipcRenderer.invoke('get-current-channel'),
// Set update channel
setUpdateChannel: (channel) => ipcRenderer.invoke('set-update-channel', channel),
// Trigger update download and installation
triggerUpdate: () => ipcRenderer.invoke('trigger-update'),
// Register for update status events
onUpdateStatus: (callback) => {
const subscription = (event, data) => callback(data);
ipcRenderer.on('update-status', subscription);
return () => {
ipcRenderer.removeListener('update-status', subscription);
};
}
});
5. Renderer Process UI Interaction
Finally, we need to implement the UI logic in the renderer process:
// Display version information
window.addEventListener('DOMContentLoaded', async () => {
// Set versions
nodeVersionEl.innerText = window.versions.node();
electronVersionEl.innerText = window.versions.electron();
// Set up update channel select
const currentChannel = await window.updater.getCurrentChannel();
channelSelectEl.value = currentChannel;
// Register event listeners
setupEventListeners();
// Register for update events
registerForUpdates();
});
function setupEventListeners() {
// Change update channel
channelSelectEl.addEventListener('change', async (event) => {
const selectedChannel = event.target.value;
const result = await window.updater.setUpdateChannel(selectedChannel);
if (result.status === 'success') {
showNotification(`Update channel changed to ${result.channel}`, 'info');
} else {
showNotification(`Error changing channel: ${result.error}`, 'error');
}
});
// Check for updates
checkUpdateBtn.addEventListener('click', async () => {
showNotification('Checking for updates...', 'info');
const result = await window.updater.checkForUpdates();
if (result.status === 'error') {
showNotification(`Error checking for updates: ${result.error}`, 'error');
}
});
// Trigger update
triggerUpdateBtn.addEventListener('click', async () => {
showNotification('Installing update and restarting...', 'info');
await window.updater.triggerUpdate();
});
}
function registerForUpdates() {
// Register for update status events
window.updater.onUpdateStatus((data) => {
console.log('Update status:', data);
switch (data.type) {
case 'checking':
showNotification('Checking for updates...', 'info');
hideUpdateButton();
hideProgress();
break;
case 'available':
showNotification(`Update available: ${data.data.version}`, 'info');
hideUpdateButton();
hideProgress();
break;
case 'progress':
const percent = Math.round(data.data.percent);
showProgress(percent);
break;
case 'downloaded':
showNotification(`Update downloaded. Version ${data.data.version} will be installed on restart.`, 'success');
showUpdateButton();
hideProgress();
break;
}
});
}
macOS Code Signing and Notarization
For macOS applications, code signing and notarization are essential steps. We've configured this with a notarize.js
script:
const { notarize } = require('electron-notarize');
const path = require('path');
exports.default = async function notarizing(context) {
if (context.electronPlatformName !== 'darwin' ||
process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') {
console.log("Skipping notarization");
return;
}
console.log("Notarizing...")
const appBundleId = context.packager.appInfo.info._configuration.appId;
const appName = context.packager.appInfo.productFilename;
const appPath = path.normalize(path.join(context.appOutDir, `${appName}.app`));
const appleId = process.env.APPLE_ID;
const appleIdPassword = process.env.APPLE_ID_PASSWORD;
if (!appleId || !appleIdPassword) {
console.warn("Not notarizing: Missing Apple ID credentials");
return;
}
return notarize({
appBundleId,
appPath,
appleId,
appleIdPassword
});
};
Multi-Channel Update Management
One of the powerful features of this implementation is the support for multiple update channels:
- Stable channel: Production-ready releases for general users
- Beta channel: Pre-release versions for testers
- Dev channel: Development builds for internal testing
The system uses environment variables in a .env-cmdrc.json
file to manage credentials and update channels:
{
"dev": {
"AWS_ACCESS_KEY_ID": "YOUR_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY": "YOUR_SECRET_ACCESS_KEY",
"UPDATE_CHANNEL": "dev"
},
"beta": {
"AWS_ACCESS_KEY_ID": "YOUR_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY": "YOUR_SECRET_ACCESS_KEY",
"UPDATE_CHANNEL": "beta"
},
"stable": {
"AWS_ACCESS_KEY_ID": "YOUR_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY": "YOUR_SECRET_ACCESS_KEY",
"UPDATE_CHANNEL": "stable"
}
}
Publishing Updates
To publish updates to different channels, we use npm scripts with env-cmd
to select the appropriate environment:
# To build and publish to the stable channel
npm run publish
# To build and publish to the beta channel
env-cmd -e beta electron-builder --publish always
# To build and publish to the dev channel
env-cmd -e dev electron-builder --publish always
Security Considerations
Security is paramount when implementing an update system. Our approach includes:
- Private S3 bucket: No public access allowed
- CloudFront with OAC: Origin Access Control ensures only CloudFront can access S3
- HTTPS only: All update traffic is encrypted
- Code signing: All application packages are signed to verify authenticity
- Notarization on macOS: Ensures malware checks are passed
Troubleshooting Common Issues
When implementing auto-updates, several issues may arise:
- Updates not downloading: Verify AWS credentials and S3 permissions
- CloudFront issues: Check origin configuration and CORS settings
- macOS signing/notarization: Ensure proper certificates are installed
-
CORS errors: Verify S3 CORS configuration allows
app://-
origin
Conclusion
Implementing automatic updates in Electron applications with AWS S3 and CloudFront provides a robust, scalable solution for keeping your applications up-to-date. This approach works across all major platforms (Windows, macOS, and Linux) and supports multiple update channels for different stages of your application lifecycle.
By leveraging Terraform for infrastructure setup, the entire process becomes reproducible and maintainable as code, making it easier to manage as your application grows.