Over-the-air updates
Ship fixes to installed apps without a store release. Updates are signed on your machine, served by your own server, rolled out in stages and rolled back automatically if a release fails to start. The same commands work for Capacitor and Expo.
#What can update
Every store build carries a fingerprint of its native side, computed for you from your config and the device features you use. An update reaches only apps with the same fingerprint. Change anything native and you ship a store build; an app never receives code it might not be able to run.
| Change | Over the air | Needs a store build |
|---|---|---|
| Page JavaScript and CSS | ||
| HTML, HTMX documents and static assets | ||
| Device features, plugins and permissions | ||
| Deep-link hosts and scheme | ||
| Auth client and offline Sync schema | ||
| Update endpoint, channel and public keys |
#Store policy
Updates are for fixes and in-scope content, not for changing what the app does. Apple's App Review Guideline 2.5.2 does not allow downloaded code that introduces or changes features. Every update therefore carries a classification and your confirmation that it stays within the app's submitted purpose.
#Signing keys
Updates are signed with an ECDSA P-256 key. The private key stays on the machine that builds updates; only the public key goes in your config and your app.
# Private key: keep it on the build machine, outside the project
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
-out "$HOME/.config/absolutejs/mobile-update.pem"
# Public key: this value goes in absolute.config.ts
openssl pkey -in "$HOME/.config/absolutejs/mobile-update.pem" \
-pubout -outform DER | openssl base64 -Amobile: {
appId: 'com.example.shop',
appName: 'Shop',
server: { productionOrigin: 'https://shop.example.com' },
updates: {
publicKeys: {
'production-2026': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...'
}
}
}publicKeys maps a key ID to a key. To rotate, add the new key, ship a store build, and start signing with the new ID; drop the old key once no installed version needs it.
Expo also verifies the update response with an RSA certificate. Generate it once; the private key must live outside your project, and the certificate is committed and built into the app.
bunx absolute mobile update signing generate \
--private-key "$HOME/.config/absolutejs/shop-expo-update.pem"updates: {
publicKeys: { 'production-2026': '...' },
expoCodeSigning: {
certificatePath: 'mobile/code-signing/expo-update-certificate.pem',
keyId: 'main'
}
}#Update server
Your AbsoluteJS server hosts the updates itself, at /__absolute/mobile/updates/production/update.json on your production origin. Provisioning writes mobile.update.ts, the module that decides where releases are stored.
# Local storage for development and single-machine testing
bunx absolute mobile update provision --storage local --yes
# S3-compatible storage, required in production
bunx absolute mobile update provision --storage s3 --force --yesLocal storage is refused in production. With S3, the server writes, reads and deletes a test object at startup, so a wrong bucket or missing permission fails before any app asks for an update. Grant it GetObject, PutObject and DeleteObject, and set these on the server only:
| Variable | Purpose |
|---|---|
| ABSOLUTE_MOBILE_UPDATE_S3_BUCKET | Bucket for releases and receipts |
| ABSOLUTE_MOBILE_UPDATE_S3_REGION | Bucket region; auto for Cloudflare R2 |
| ABSOLUTE_MOBILE_UPDATE_S3_ENDPOINT | Endpoint for R2, MinIO, Backblaze B2 and other S3-compatible services |
| ABSOLUTE_MOBILE_UPDATE_S3_FORCE_PATH_STYLE | Set to 1 for services that need path-style URLs, such as MinIO |
| ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET | At least 32 random characters; signs health receipts |
| ABSOLUTE_EXPO_UPDATE_PRIVATE_KEY | Expo only: the RSA private key PEM |
#Ship an update
absolute mobile update buildabsolute mobile update publishabsolute mobile update promotebunx absolute mobile update build src/backend/server.ts \
--classification bug-fix \
--key-id production-2026 \
--signing-key "$HOME/.config/absolutejs/mobile-update.pem" \
--within-submitted-purpose
bunx absolute mobile update publish .absolutejs/mobile/updates/amu_RELEASE --rollout 0.05
bunx absolute mobile update promote --release amu_RELEASE --rollout 0.25
bunx absolute mobile update promote --release amu_RELEASE --rollout 1Apps check for an update after they start. A verified update is applied as soon as it finishes downloading, and Sync data and the signed-in session carry over.
#Staged rollout
Configure stages and each promotion moves through them: 5% for an hour with 20 reports, then 25% for six hours with 100 reports, then everyone, as long as no more than 5% of installs roll back. With automatic: true the server advances qualifying stages itself; otherwise run update advance.
updates: {
publicKeys: { 'production-2026': '...' },
server: {
health: { failureRate: 0.2, minimumReports: 20 },
rollout: {
automatic: true,
stages: [
{ rollout: 0.05, minimumReports: 20, observationMinutes: 60 },
{ rollout: 0.25, minimumReports: 100, observationMinutes: 360 },
{ rollout: 1 }
]
}
}
}Stages must increase and end at 1. A stage's failure ceiling must sit below the health failureRate, and staged rollout needs health reporting on.
#Automatic rollback
Health reports carry an anonymous installation ID and the outcome, nothing about the user, their session or their data.
#Commands
bunx absolute mobile update status
bunx absolute mobile update advance
bunx absolute mobile update pause
bunx absolute mobile update resume
bunx absolute mobile update cancel
bunx absolute mobile update rollback # back to the build in the store
bunx absolute mobile update rollback --release amu_RELEASE # back to an earlier update| absolute mobile … | What it does |
|---|---|
| update provision | Create mobile.update.ts with local or S3 storage |
| update signing generate | Expo: create the RSA key and certificate |
| update build | Build and sign a release from your server entry |
| update publish <dir> | Upload a release, optionally with --rollout |
| update promote | Point the channel at a release, at a rollout fraction |
| update advance | Move to the next rollout stage |
| update pause · resume · cancel | Control the current rollout |
| update reconcile | Bring the rollout state up to date |
| update rollback | Return to an earlier release or the store build |
| update status | Show the active release, rollout and health |
| update storage | Report storage use and what could be pruned |
| update gc | Delete unreferenced releases; dry run unless --apply |
#Events
Pages can follow updates through the absolute:mobile-update event. Its kind is download-progress, downloaded, activated, rolled-back, quarantined or failed. Transfer details are counts and sizes only, never URLs or paths.
window.addEventListener('absolute:mobile-update', (event) => {
if (!(event instanceof CustomEvent)) return;
const { detail } = event;
if (detail.kind === 'download-progress')
progressBar.value = detail.completedFiles / detail.totalFiles;
if (detail.kind === 'rolled-back')
console.warn('Update rolled back', detail.releaseId);
});Updates that need new native code go through a store release.