A self-hosted Over-The-Air (OTA) update server for Expo apps, built with Laravel 12 and Filament 5. Implements the Expo Updates Protocol v1 to serve JS bundle updates to your Expo clients without going through EAS Update.
- Multiple clients: manage several Expo apps from one server, each with its own slug-based URL
- Multiple environments: separate update channels for
production,staging, anddevelopment - Per-platform bundles: iOS and Android assets are stored and served independently
- Content-addressed asset deduplication: identical assets are stored once per client (SHA-256 based)
- Signed temporary URLs: assets are served via time-limited signed URLs; works with local storage and S3-compatible backends
- Git metadata tracking: branch and commit SHA attached to every published update
- Install counter: tracks how many times each update manifest has been fetched
- Token-based publish API: secure CI/CD publishing with per-client API tokens
- Client based Code-Signing: each client can have its own Code-Signing certificate
- Rollout control: gradually roll out new updates to a percentage of devices
- Rollbacks and Re-Publishing: republish an older update (to update client to that version) or use rollback to instruct the client to run the update embedded in the build.
- Filament admin panel: full management UI at
/admin
- PHP 8.3+
- Composer
- Node.js 18+ (for the Expo app side)
- SQLite, MySQL 8+, or PostgreSQL 15+
git clone <repo-url> expo-ota-server
cd expo-ota-server
composer install
cp .env.example .env
php artisan key:generate
# Configure your database in .env, then:
php artisan migrate
# Create the first admin user
php artisan make:filament-userThen visit /admin to log in.
APP_URL=https://your-ota-server.com # Used in generated asset URLs - must be publicly reachable
APP_ENV=production
APP_DEBUG=falseBy default, update bundles are stored on the local filesystem under storage/app/updates/. To use S3 or any S3-compatible service instead, set:
UPDATES_FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=your-bucket
AWS_ENDPOINT=https://... # Only needed for non-AWS S3-compatible services
AWS_USE_PATH_STYLE_ENDPOINT=false # Set to true for MinIO / path-style endpointsLocal storage note: The local updates disk has serve: true enabled, which means Laravel will serve assets directly and generate signed URLs for them. No symlink or separate web server config is required. Asset URLs expire after 1 hour.
Navigate to /admin after installation.
A client represents one Expo app. Create one per app you want to serve.
- Slug: used in all API URLs (e.g.
my-app). Lowercase letters, numbers, hyphens only. - Display name: human-readable label shown in the admin.
- Private Key: Optional. Private Key used to sign update manifests.
The client edit page shows an example curl command you can use to test the manifest endpoint.
Each client has its own API tokens, used to authenticate publish requests. Generate a token from the client's API Tokens tab. The plain-text token is only shown once. Copy it and store it as a secret in your CI environment.
The Updates resource shows all published updates across all clients. From the updates list or a client's Updates tab you can:
- View update details (platform, environment, runtime version, git info, install count)
- Activate, deactivate or republish individual updates
- Manually publish a new update by uploading a ZIP bundle
- Bulk activate/deactivate or delete updates
When an update's rollback flag is set, it will tell all clients that get that update to run the embedded update instead. This will also reset any updates that they might have installed already. Think of this as a "factory reset" to the last App-Store Version.
If you want to roll back to a specific previous version of an update, you can use the Re-Publishing feature.
This will duplicate the update record and set the published_at timestamp to the current time.
Or just upload the same ZIP bundle again.
GET /{client}/updates/manifest
Called by the Expo runtime on the device. Returns either a 204 (no update available) or a multipart/mixed manifest with signed asset URLs.
Required headers:
| Header | Description |
|---|---|
expo-protocol-version |
Must be 1 |
expo-platform |
ios or android |
expo-runtime-version |
Your app's runtime version string |
Optional:
| Source | Header / Query param | Default |
|---|---|---|
| Header | x-update-environment |
production |
| Query param | ?environment=staging |
production |
Responses:
| Status | Meaning |
|---|---|
204 |
No active update found for this client/platform/runtime/environment |
200 |
multipart/mixed body with manifest JSON and expo-protocol-version: 1 header |
POST /{client}/updates/publish?token={api_token}
Called by CI to push a new update. Accepts multipart/form-data.
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
bundle |
file (.zip) |
Yes | Output of npx expo export --platform all, zipped |
environment |
string | Yes | production, staging, or development |
runtime_version |
string | Yes | Must match the runtimeVersion in your app config (max 100 chars) |
git_branch |
string | No | Branch name (max 255 chars) |
git_commit |
string | No | Full 40-char commit SHA |
rollout_percentage |
integer | No | 0–100, defaults to 100. Controls what fraction of devices receive this update. |
Success response (201):
{
"message": "Update published successfully",
"updates": [
{ "id": "uuid", "platform": "ios", "environment": "production", "runtime_version": "1.0.0" },
{ "id": "uuid", "platform": "android", "environment": "production", "runtime_version": "1.0.0" }
]
}One record is created per platform found in the uploaded bundle (typically ios and android).
Error responses:
| Status | Cause |
|---|---|
401 |
Missing or invalid token query parameter |
422 |
Validation failed: JSON body with errors object |
The bundle ZIP must contain the direct output of npx expo export --platform all and optional the package.json as well as the json version of expo-config (app.json, app.config.js) named expoConfig.json.
The expected structure inside the ZIP:
expoConfig.json
packackge.json
metadata.json
bundles/
ios-<hash>.js
android-<hash>.js
assets/
<hash>
...
If you use app.config.js you can use this script to generate the expoConfig.json file:
const ExpoConfig = require('@expo/config');
const path = require('path');
const projectDir = path.join(__dirname, '..');
const { exp } = ExpoConfig.getConfig(projectDir, {
skipSDKVersionRequirement: true,
isPublicConfig: true,
});
console.log(JSON.stringify(exp));
The server reads metadata.json to discover platforms and asset manifests. It then stores each asset content-addressed under {client-slug}/{update-id}/{sha256-hash} on the configured storage disk.
The expoConfig.json file (if present) is used to populate the expoClient object in the manifest. It is only required if you configure your app to use runtime.
package.json is used purely for informational purposes and can be viewed in the admin panel.
Assets are deduplicated per client using their SHA-256 hash. When a new update is published, each asset is checked against all existing assets for that client. If the same file already exists (e.g. an unchanged image), the new update record points to the existing storage path instead of uploading a duplicate.
When an update is deleted, each of its asset files is only removed from storage if no other update for the same client still references that path.
Every update has a rollout_percentage (0–100, default 100) that controls what fraction of devices receive it. Set it below 100 to gradually roll out a new update and monitor for issues before going to everyone.
- Publish a new update with
rollout_percentage = 10~10% of devices receive it - Monitor crash reports and user feedback
- Bump the percentage in the Filament admin (edit the update record) e.g. 10 → 50 → 100
- At 100%, all devices receive the update
Raising the percentage is cumulative: devices that were already included stay included. You can also set it to 0 to effectively disable an update without deactivating it.
When a device requests the manifest, the server decides whether to serve the update:
With a stable installation-id (recommended): The server hashes SHA-256(installation-id + update-id) and checks if the result falls within the rollout percentage. The same device always gets the same answer for a given update. Raising the percentage adds new devices without disturbing the existing included set.
Without an installation-id (fallback): Behavior depends on OTA_ROLLOUT_FALLBACK:
# 'fingerprint' (default) stable per IP + User-Agent + platform.
# Restarting the app gives the same result. Acts as a hard gate.
# Re-buckets only when the device's IP changes (e.g. switches networks).
#
# 'random' per-request probability.
# Each update check independently rolls the dice.
# All devices will eventually receive the update over enough app starts.
# Useful as a delivery-rate control without implementing installation-id.
OTA_ROLLOUT_FALLBACK=fingerprintTo enable stable per-device bucketing, send a persistent installation ID with every update request. This must be registered before the update check runs.
Install the required packages in your Expo app:
npx expo install expo-secure-store expo-crypto expo-updatesThen in your root layout or app entry point:
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
import * as Updates from 'expo-updates';
const STORAGE_KEY = 'expo-ota-installation-id';
async function registerInstallationId(): Promise<void> {
let id = await SecureStore.getItemAsync(STORAGE_KEY);
if (!id) {
id = Crypto.randomUUID();
await SecureStore.setItemAsync(STORAGE_KEY, id);
}
// Attaches the ID to every subsequent update request via expo-extra-params header
await Updates.setExtraParamAsync('installation-id', id);
}
// Call BEFORE checkForUpdateAsync:
useEffect(() => {
async function initUpdates() {
await registerInstallationId(); // ← register first
if (!__DEV__) {
const result = await Updates.checkForUpdateAsync();
if (result.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
}
}
initUpdates();
}, []);Note:
Updates.setExtraParamAsyncrequiresexpo-updatesSDK 49+. The ID is sent as part of theexpo-extra-paramsheader on every manifest request. If the header is absent, the server falls back to the strategy configured inOTA_ROLLOUT_FALLBACK.
Add this workflow to your Expo app repository (not this server) to automatically publish OTA updates on every push to master.
Go to Settings → Secrets and variables → Actions in your Expo app repository:
| Name | Type | Value |
|---|---|---|
OTA_SERVER_URL |
Secret | https://your-ota-server.com (no trailing slash) |
OTA_API_TOKEN |
Secret | API token generated in the Filament admin |
OTA_CLIENT_NAME |
Variable | Client slug (e.g. my-app) |
name: Publish OTA Update
on:
push:
branches:
- master
paths:
- 'src/**'
- 'assets/**'
- 'app.json'
- 'app.config.js'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
- development
runtime_version:
description: 'Runtime version override (leave blank to read from app config)'
required: false
default: ''
jobs:
publish-ota:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ota]')"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Determine environment
id: ctx
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT
else
echo "environment=production" >> $GITHUB_OUTPUT
fi
echo "git_branch=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
echo "git_commit=${GITHUB_SHA}" >> $GITHUB_OUTPUT
- name: Read runtime version
id: runtime
run: |
if [ -n "${{ inputs.runtime_version }}" ]; then
echo "version=${{ inputs.runtime_version }}" >> $GITHUB_OUTPUT
else
VERSION=$(node -e "
const fs = require('fs');
const config = fs.existsSync('./app.config.js')
? require('./app.config.js')
: require('./app.json');
const expo = config.expo ?? config;
const rv = expo.runtimeVersion;
console.log(typeof rv === 'string' ? rv : expo.version);
")
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
- name: Export Expo bundle
run: npx expo export --platform all
- name: Export Expo Config
run: node ./scripts/export-config.js > dist/expoConfig.json
- name: Copy package.json
run: cp package.json dist/package.json
- name: Create bundle ZIP
run: cd dist && zip -r ../bundle.zip .
- name: Publish OTA update
run: |
curl --fail-with-body -s \
"${{ secrets.OTA_SERVER_URL }}/${{ vars.OTA_CLIENT_NAME }}/updates/publish?token=${{ secrets.OTA_API_TOKEN }}" \
-F "bundle=@bundle.zip;type=application/zip" \
-F "environment=${{ steps.ctx.outputs.environment }}" \
-F "runtime_version=${{ steps.runtime.outputs.version }}" \
-F "git_branch=${{ steps.ctx.outputs.git_branch }}" \
-F "git_commit=${{ steps.ctx.outputs.git_commit }}"Automatic deploys (push to master):
- Only runs when files under
src/,assets/,app.json,app.config.js,package.json, orpackage-lock.jsonchange CI/README-only pushes are ignored automatically. - Environment defaults to
production. - Runtime version is read from your
app.config.jsorapp.json(expo.runtimeVersion, falls back toexpo.version).
Manual deploys (workflow_dispatch):
- Select the target environment (
production,staging,development) from the GitHub Actions UI. - Optionally override the runtime version if needed.
Skipping a deploy:
Add [skip ota] anywhere in your commit message to prevent the workflow from running even when matching paths change:
git commit -m "Refactor components [skip ota]"
release/* tags do not trigger this workflow only branch pushes to master do. Tag-based native builds are handled by a separate workflow.
Subdirectory apps: If your Expo app lives in a subdirectory (e.g. app/), add working-directory: app to the Install dependencies, Read runtime version, Export Expo bundle, and Create bundle ZIP steps, and adjust the paths filter accordingly.
Configure the Expo Updates module in your app to point at this server. In app.config.js or app.json:
export default {
expo: {
runtimeVersion: "1.0.0",
updates: {
url: "https://your-ota-server.com/my-app/updates/manifest",
enabled: true,
checkAutomatically: "ON_LOAD",
requestHeaders: {
"x-update-environment": "production",
},
},
},
};Install the Expo Updates package if not already present:
npx expo install expo-updatesThe x-update-environment request header tells the server which update channel to serve. Omit it to default to production.
This project was inspired by this blog post from Jared Lynskey. Thanks for your time answering my questions!