Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Expo OTA Server

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.

Features

  • Multiple clients: manage several Expo apps from one server, each with its own slug-based URL
  • Multiple environments: separate update channels for production, staging, and development
  • 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

Requirements

  • PHP 8.3+
  • Composer
  • Node.js 18+ (for the Expo app side)
  • SQLite, MySQL 8+, or PostgreSQL 15+

Installation

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-user

Then visit /admin to log in.


Configuration

Core

APP_URL=https://your-ota-server.com   # Used in generated asset URLs - must be publicly reachable
APP_ENV=production
APP_DEBUG=false

Storage

By 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 endpoints

Local 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.


Admin Panel

Navigate to /admin after installation.

Clients

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.

API Tokens

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.

Updates

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

Rollbacks & Re-Publishing

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.


API Reference

Fetch Manifest

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

Publish Update

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

Bundle Format

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.


Asset Deduplication

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.


Rollout Control

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.

Flow

  1. Publish a new update with rollout_percentage = 10 ~10% of devices receive it
  2. Monitor crash reports and user feedback
  3. Bump the percentage in the Filament admin (edit the update record) e.g. 10 → 50 → 100
  4. 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.

Bucketing

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=fingerprint

Device-Side Setup (Stable Rollout)

To 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-updates

Then 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.setExtraParamAsync requires expo-updates SDK 49+. The ID is sent as part of the expo-extra-params header on every manifest request. If the header is absent, the server falls back to the strategy configured in OTA_ROLLOUT_FALLBACK.


GitHub Actions Integration

Add this workflow to your Expo app repository (not this server) to automatically publish OTA updates on every push to master.

Required GitHub Secrets and Variables

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)

Workflow

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 }}"

How It Works

Automatic deploys (push to master):

  • Only runs when files under src/, assets/, app.json, app.config.js, package.json, or package-lock.json change CI/README-only pushes are ignored automatically.
  • Environment defaults to production.
  • Runtime version is read from your app.config.js or app.json (expo.runtimeVersion, falls back to expo.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.


Expo App Configuration

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-updates

The x-update-environment request header tells the server which update channel to serve. Omit it to default to production.

Notes & Credits

This project was inspired by this blog post from Jared Lynskey. Thanks for your time answering my questions!

About

Self-hosted OTA updates server for Expo/React Native apps running on laravel for easy deploment on shared hosting providers

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages