Self-hosted Expo OTA update server. Ships as a Hono library you mount on your existing API.
There are two ways to use airlock — as a server library and as a CLI tool. They're the same package.
Add it as a dependency in your API project:
bun add @dawsson/airlockThen import and mount it (see Quick Start below).
Run without installing:
bunx @dawsson/airlock publish --platform ios --runtime 1.0.0Or install globally so airlock is always available:
bun add -g @dawsson/airlock
airlock publish --platform ios --runtime 1.0.0Or add as a dev dependency in your Expo project:
bun add -D @dawsson/airlock
bunx airlock publish --platform ios --runtime 1.0.0You can also install the Airlock skill into your coding agent with:
npx skills add dawsson/airlockimport { createAirlock } from "@dawsson/airlock";
import { CloudflareAdapter } from "@dawsson/airlock/adapters/cloudflare";
// Adapter and token factories are called per-request with the runtime env —
// the correct pattern for Cloudflare Workers where bindings aren't available at module init.
// CloudflareAdapter.forEnv() falls back to MemoryAdapter in local dev (no crash, just a log).
const airlock = createAirlock({
adapter: (env: Env) =>
CloudflareAdapter.forEnv({
kv: env.OTA_KV,
r2: env.OTA_R2,
r2PublicUrl: env.R2_PUBLIC_URL,
}),
adminToken: (env: Env) => env.AIRLOCK_ADMIN_TOKEN,
// Optional: only set if you want to require a bearer token on POST /events.
// clientEventToken: (env: Env) => env.AIRLOCK_CLIENT_EVENT_TOKEN,
metricsAuth: (req) => req.headers.get("x-internal-dashboard-key") === "allow",
});
// Returns a standard WinterCG fetch handler with basePath prefix stripping built in.
const handler = airlock.mount("/ota");
export default { fetch: handler };Or mount on an existing Hono app:
import { Hono } from "hono";
const app = new Hono<{ Bindings: Env }>();
app.all("/ota/*", (c) => handler(c.req.raw, c.env));Point your Expo app at https://your-api.com/ota/manifest and updates just work.
For local/dev workflows where state should survive restarts, use the file-backed adapter:
import { createAirlock } from "@dawsson/airlock";
import { FileAdapter } from "@dawsson/airlock/adapters/file";
const airlock = createAirlock({
adapter: new FileAdapter({ filePath: ".airlock/state.json" }),
adminToken: process.env.AIRLOCK_ADMIN_TOKEN,
});This persists updates, assets, health, and metrics snapshots to JSON on disk.
Add this to your app.json or app.config.ts:
{
"expo": {
"updates": {
"url": "https://your-api.com/ota/manifest",
"enabled": true,
"checkAutomatically": "ON_LOAD"
},
"runtimeVersion": {
"policy": "appVersion"
}
}
}# Initialize config
airlock init --server https://api.example.com/ota --token your-admin-token
# Publish from expo export output
npx expo export --platform ios
airlock publish --platform ios --runtime 1.0.0 --message "fix login crash"
airlock publish --platform ios --runtime 1.0.0 --kind emergency --stage production --cohort A --min-bandwidth 1500 --immediate-apply always
# Manage updates
airlock list --platform ios --runtime 1.0.0
airlock metrics overview --platform ios --runtime 1.0.0
airlock metrics segments --platform ios --runtime 1.0.0 --from 2026-02-01T00:00:00.000Z --to 2026-02-28T23:59:59.000Z
airlock promote --from staging --to production --platform ios --runtime 1.0.0
airlock rollout --platform ios --runtime 1.0.0 --update-id <id> --percentage 50
airlock rollback --platform ios --runtime 1.0.0
# Generate signing keys
airlock keygenThis repo includes a fixture Expo app at e2e/expo-ota-fixture and scripts to
run a full local Airlock OTA loop on iOS simulator.
- Xcode + iOS Simulator installed
- Bun installed
bun run e2e:ios-otaWhat it does:
- Starts a local Airlock server on
http://127.0.0.1:8788/ota - Exports and publishes fixture update
v1 - Builds and installs the fixture app in Release on iOS simulator
- Launches app and verifies marker
v1 - Exports and publishes fixture update
v2 - Relaunches app to fetch
v2, then relaunches again to verifyv2is active
The fixture app writes launch/update diagnostics to:
<simulator app data>/Documents/ota-status.json
The e2e script reads this file to assert OTA behavior.
It also writes a timing report to e2e/ios-ota-report.json.
Temporary Expo export artifacts are written to
e2e/expo-ota-fixture/.airlock-builds/ (gitignored).
Run a fast smoke suite for production controls:
bun run e2e:advancedThis verifies:
- A/B cohort targeting (
x-airlock-cohort) - Bandwidth-aware update gating (
x-airlock-bandwidth-kbps) - Crash-rate auto-blocking (bad latest update is skipped)
- Manual rollback behavior
- Health telemetry endpoint output
bun run e2e:serverDefault local credentials:
- Server:
http://127.0.0.1:8788/ota - Admin token:
local-dev-token
Override with env vars:
AIRLOCK_E2E_PORTAIRLOCK_E2E_TOKENAIRLOCK_E2E_SIMULATORAIRLOCK_E2E_BUNDLE_IDAIRLOCK_E2E_RUNTIMEAIRLOCK_E2E_STATE_FILE(optional JSON persistence file path)
- If updates never apply, verify fixture
runtimeVersionmatches publish--runtime. - If app cannot fetch assets, ensure manifest asset URLs are
assets/<hash>(or legacy_assets/*that Airlock normalizes). - If simulator launch fails, open Xcode once to accept toolchain/license prompts and rerun.
- Expo Updates protocol v1 compliant (multipart/mixed manifests)
- RSA-SHA256 code signing (
rsa-v1_5-sha256, Expo-compatible) - Deterministic hash-based rollout (same device always gets same result)
- Channel support (default, staging, production, etc.)
- Admin API with bearer token auth (publish, promote, rollback, rollout)
- Targeting controls: cohort, minimum bandwidth, and stage gating
- Update metadata: kind (
feature|optional|hotfix|emergency), stage, tags - Immediate-apply hint in manifest
extra.immediateApply(never|fast_connection|always) - Telemetry ingestion endpoint for launch/apply/download events
- Health stats endpoint (crash rate + timing aggregates)
- Authenticated metrics endpoints for dashboard queries (
/admin/metrics/*) - Automatic unhealthy-update blocking based on crash-rate thresholds
resolveUpdatehook for custom logic (A/B testing, feature flags, user targeting)onEventhook for analytics and logging- Critical update flag (passed to client via manifest
extra) - Update messages for human-readable history
- Asset proxy endpoint
- CLI for publishing and managing updates
Runs after the adapter fetches the latest update. Inspect headers, swap the update, or return null to skip.
createAirlock({
adapter,
resolveUpdate(update, context) {
// context has: channel, runtimeVersion, platform, headers, currentUpdateId
const userId = context.headers["x-user-id"];
if (!isBetaUser(userId)) return null;
return update;
},
});Fire-and-forget analytics. Never blocks the response.
createAirlock({
adapter,
onEvent(event) {
// event.type: manifest_request | asset_request | update_published
// | rollout_changed | update_promoted | update_rolled_back
console.log(event);
},
});Generate a key pair:
airlock keygen
# Creates: airlock-private.pem, airlock-public.pemConfigure the server:
import { createAirlock, importSigningKey } from "@dawsson/airlock";
createAirlock({
adapter,
signingKey: await importSigningKey(env.AIRLOCK_SIGNING_KEY),
signingKeyId: "main",
});Add the public key to your Expo app's app.json:
{
"expo": {
"updates": {
"codeSigningCertificate": "./airlock-public.pem",
"codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
}
}
}Use this to forward ingested telemetry to external analytics sinks (Cloudflare Analytics Engine, PostHog pipeline, etc.) without coupling Airlock core to any specific vendor.
createAirlock({
adapter,
onTelemetryBatch(events, context) {
// context.trusted -> true for /admin/client-events, false for public /events
// context.ip -> client IP when available
// Fire your own async write path here
void events;
void context;
},
});If this hook throws, Airlock swallows the error and emits
telemetry_export_failed via onEvent.
Configure automatic unhealthy update blocking:
createAirlock({
adapter,
stability: {
autoBlockUnhealthy: true,
minLaunchesForBlocking: 20,
crashRateThreshold: 0.2,
useUntrustedTelemetry: false,
},
});If telemetry reports that an update exceeds the crash threshold after the minimum sample size, Airlock skips that update and serves the next eligible one. By default, only trusted telemetry is used for auto-block decisions.
**@dawsson/airlock/adapters/cloudflare**— KV for metadata, R2 for assets**@dawsson/airlock/adapters/memory**— In-memory, for tests
Implement StorageAdapter for any backend (Postgres, S3, Upstash, etc.):
import type { StorageAdapter, StoredUpdate, Platform } from "@dawsson/airlock";
class PostgresAdapter implements StorageAdapter {
async getLatestUpdate(channel, runtimeVersion, platform) {
/* ... */
}
async publishUpdate(channel, runtimeVersion, platform, update) {
/* ... */
}
async setRollout(channel, runtimeVersion, platform, updateId, percentage) {
/* ... */
}
async promoteUpdate(fromChannel, toChannel, runtimeVersion, platform) {
/* ... */
}
async rollbackUpdate(channel, runtimeVersion, platform) {
/* ... */
}
async getUpdateHistory(channel, runtimeVersion, platform, limit?) {
/* ... */
}
async listUpdates() {
/* ... */
}
async getAssetUrl(hash) {
/* ... */
}
async storeAsset(hash, data, contentType) {
/* ... */
}
}Public client telemetry endpoint:
POST /events(no admin token required)- guarded by request size, batch size, timestamp skew, and per-IP rate limits
- events received here are marked untrusted unless
clientEventTokenis configured
Trusted telemetry endpoint:
POST /admin/client-events(admin token required)- events are always marked trusted for stability decisions
- events derive
bandwidthBucket(unknown|low|medium|high|very_high) server-side
Dashboard-oriented, authenticated metrics endpoints:
GET /admin/metrics/overviewGET /admin/metrics/timingsGET /admin/metrics/adoptionGET /admin/metrics/failuresGET /admin/metrics/segments
Shared query params:
runtimeVersion(required)platform(required:ios|android)channel(optional, defaultdefault)from/to(optional ISO timestamps; default rolling 24h)limit(optional, default 50, max 500)
Query guardrails:
- max window: 30 days
- invalid ranges or missing required params return
400
Auth behavior:
- if
metricsAuthis configured, it is used for/admin/metrics/* - otherwise metrics routes use normal admin bearer auth
- metrics routes are never public in this release
All admin endpoints require Authorization: Bearer <token> when adminToken is set.
| Method | Path | Description |
|---|---|---|
POST |
/admin/publish |
Publish an update with manifest + assets |
POST |
/admin/promote |
Copy update from one channel to another |
POST |
/admin/rollout |
Set rollout percentage for an update |
POST |
/admin/rollback |
Revert to previous update |
POST |
/admin/client-events |
Record trusted client launch/download/apply telemetry |
GET |
/admin/health |
Read per-update crash-rate and timing aggregates |
GET |
/admin/metrics/overview |
Read aggregate event/crash summary |
GET |
/admin/metrics/timings |
Read check/download/apply timing distributions |
GET |
/admin/metrics/adoption |
Read per-update launch/adoption counters |
GET |
/admin/metrics/failures |
Read per-update failure/error breakdown |
GET |
/admin/metrics/segments |
Read cohort/stage/network/bandwidth/trust slices |
GET |
/admin/updates |
List update history for a channel/rv/platform |
GET |
/admin/status |
Overview of all deployed updates across all channels |
CLI equivalents:
airlock metrics overview --platform ios --runtime 1.0.0airlock metrics timings --platform ios --runtime 1.0.0airlock metrics adoption --platform ios --runtime 1.0.0airlock metrics failures --platform ios --runtime 1.0.0airlock metrics segments --platform ios --runtime 1.0.0
- Store OTA bundles/assets in R2/object storage only.
- Store queryable telemetry aggregates in adapter storage (DB/KV) for fast dashboard reads.
- Keep raw, high-volume analytics in your own external sink if needed via
onTelemetryBatch.
From the latest local run (e2e/ios-ota-report.json, iPhone 17 Pro simulator):
| Step | Duration |
|---|---|
| wait_for_server | 270 ms |
| export_publish_v1 | 7.0 s |
| build_release_v1 | 25.8 s |
| first_launch | 8.4 s |
| export_publish_v2 | 7.3 s |
| second_launch | 8.4 s |
| third_launch | 8.4 s |
| total | 65.6 s |
Notes:
- Build dominates runtime; OTA publish/fetch/apply loop is significantly faster than full rebuild.
- Second launch was non-embedded (
isEmbeddedLaunch=false), confirming OTA activation.
MIT