A self-hosted, multi-tenant Expo OTA Updates server written in Go. Implements the Expo Updates protocol v0/v1 (manifest, multipart response, rollBackToEmbedded / noUpdateAvailable directives, code signing) and adds JWT-based user/project management plus API-key authenticated publishing.
Build the binary once (requires Go 1.26+), then run it standalone:
go build -o server ./cmd/main.go ./server
Or just run from source during development:
go run ./cmd/main.go
Use the prebuilt image:
docker run -d --name expo-updates-server \
-p 8080:8080 \
-v $PWD/data:/data \
--env-file .env \
zhxycn/expo-updates-server
Or build it yourself from this repo:
docker build -t expo-updates-server .
docker run -d --name expo-updates-server \
-p 8080:8080 \
-v $PWD/data:/data \
--env-file .env \
expo-updates-server
When running in Docker, set
DATA_DIR=/data(or leave it at the default and mount a volume at./data). The image declares/dataas a volume, so anything written elsewhere will be lost when the container is recreated.
All settings are loaded from environment variables (and a .env file if present):
| Variable | Default | Description |
|---|---|---|
HOST / PORT | 0.0.0.0 / 8080 | Listen address. |
HOSTNAME | http://localhost:8080 | Public base URL used in manifest asset URLs. |
STORAGE_TYPE | local | local or s3 (S3-compatible, e.g. Cloudflare R2). |
DATA_DIR | ./data | Base directory for all local data. Updates are stored in DATA_DIR/updates, the SQLite database at DATA_DIR/ota.db. Directories are created automatically if they don't exist. |
S3_ENDPOINT / S3_BUCKET / S3_REGION / S3_ACCESS_KEY / S3_SECRET_KEY | – | S3 credentials (only when STORAGE_TYPE=s3). |
PRIVATE_KEY | – | RSA private key (PEM contents) or path to a .pem file, for code signing. See Code signing. |
JWT_SECRET | – | HMAC secret used to sign user JWTs. |
/api/auth/*).id. Membership has roles owner and member. Owners may manage members and API keys.(project, runtimeVersion) and identified by a Unix-timestamp updateId. The latest update for a (project, runtimeVersion) pair is served to clients.Three different auth mechanisms are used depending on the route group:
| Route group | Auth |
|---|---|
/api/auth/* | None (public). |
/api/projects/* | Authorization: Bearer <jwt> issued by login/register, valid for 72 h. |
/api/updates/:project/manifest and /assets | None – uses the Expo client protocol headers / query params (and optional expo-expect-signature for code signing). |
/api/updates/:project/publish | Authorization: Bearer <api_key_secret>, scoped to the matching project. |
All error responses use the shape { "error": "<message>" } unless otherwise noted.
Create a user account and receive a JWT.
Request body:
{ "username": "alice", "email": "alice@example.com", "password": "secret" }
Responses:
201 Created – { "user": User, "token": "<jwt>" }400 Bad Request – missing parameters.409 Conflict – username or email already exists.Exchange credentials for a JWT.
Request body (login may be either the username or the email):
{ "login": "alice", "password": "secret" }
Responses:
200 OK – { "user": User, "token": "<jwt>" }400 Bad Request – missing parameters.401 Unauthorized – invalid credentials.All routes below require Authorization: Bearer <jwt>.
Create a new project. The caller is automatically added as owner.
Request body:
{ "name": "My App", "slug": "my-app" }
Responses:
201 Created – the created Project object.400 Bad Request – missing parameters.List projects the current user is a member of.
Response: 200 OK – Project[].
Add a user to a project. Owner only.
Request body:
{ "userId": "<user_id>", "role": "member" }
role is one of owner | member.
Responses:
201 Created – the created ProjectUser membership.403 Forbidden – caller is not an owner of the project.409 Conflict – the user is already a member.Remove a user from a project. Owner only. Owners cannot remove themselves.
Responses:
204 No Content400 Bad Request – tried to remove yourself.403 Forbidden – not an owner.Create a new API key for the project. Owner only.
Request body (the name is optional; one is auto-generated when omitted):
{ "name": "ci-key" }
Response: 201 Created
{
"key": { "id": "...", "projectId": "...", "name": "ci-key", "keyPrefix": "abcd1234", "createdBy": "...", "createdAt": "..." },
"secret": "<plain-text token shown only once — use this for publishing>"
}
List API keys (without the secret) for the project. Owner only.
Response: 200 OK – Key[].
Rename an API key. Owner only.
Request body: { "name": "new-name" } → 204 No Content.
Revoke an API key. Owner only. → 204 No Content.
These endpoints implement Expo Updates v0/v1. The path parameter :project is the project id.
Returns the latest manifest (or a directive) for the requested runtime/platform.
Request headers:
| Header | Required | Notes |
|---|---|---|
expo-platform | yes | ios or android. |
expo-runtime-version | yes | Runtime version baked into the native client. |
expo-protocol-version | no | 0 (legacy) or 1. Required (>=1) for rollback/no-update directives. |
expo-current-update-id | no | UUID of the update currently installed. With protocol v1 enables the noUpdateAvailable directive when already up to date. |
expo-embedded-update-id | no | UUID of the update embedded in the binary; used by the rollback flow. |
expo-expect-signature | no | Presence enables code signing; the response will include expo-signature. |
Response: 200 OK multipart/mixed; boundary=… with parts:
manifest – application/json body matching the Expo manifest spec:
{
"id": "<uuid>",
"createdAt": "2026-01-01T10:00:00Z",
"runtimeVersion": "1.0.0",
"launchAsset": { "key": "...", "contentType": "application/javascript", "url": "https://.../assets?asset=...&platform=...&runtimeVersion=..." },
"assets": [{ "hash": "...", "key": "...", "contentType": "image/png", "fileExtension": ".png", "url": "..." }],
"metadata": {},
"extra": { "expoClient": { /* contents of app config */ } }
}
extensions – application/json body containing assetRequestHeaders (one entry per asset key, currently always empty maps).
directive – returned instead of manifest when the latest update is a rollback or when the client is already up to date. Body example:
{ "type": "rollBackToEmbedded", "parameters": { "commitTime": "2026-04-17T10:00:00Z" } }
or
{ "type": "noUpdateAvailable" }
Response headers: expo-protocol-version, expo-sfv-version: 0, cache-control: public, s-maxage=5, max-age=0 (clients will not cache, but a reverse proxy such as Nginx can cache for up to 5 seconds). When code signing is enabled the manifest/directive part also includes a part header expo-signature: sig=:<base64>:, keyid="main".
Errors:
400 Bad Request – missing/invalid expo-platform or expo-runtime-version; or expo-expect-signature was sent but the server has no PRIVATE_KEY configured.404 Not Found – no update for this (project, runtimeVersion), the update is missing expoConfig.json, or rollback requested with expo-protocol-version: 0.Stream a single asset belonging to the latest update.
Query parameters:
| Param | Required | Notes |
|---|---|---|
asset | yes | Asset path as referenced in the manifest URL. |
platform | yes | ios or android. |
runtimeVersion | yes | Same value used in the manifest request. |
Responses:
200 OK – binary stream. Content-Type is application/javascript for files under bundles/, otherwise inferred from the file extension (defaults to application/octet-stream). Cache-Control: public, max-age=31536000, immutable.400 Bad Request – missing parameter.404 Not Found – asset does not exist.Publish a new update for the project. Requires a project API key:
Authorization: Bearer <project_api_key_secret>
Request: multipart/form-data with:
| Field | Type | Description |
|---|---|---|
runtimeVersion | text | Runtime version of the update. Must match the value baked into the native client. |
metadata.json | file | Standard Expo metadata.json produced by npx expo export. Lists per-platform bundle and assets. |
expoConfig.json | file | Public Expo app config (npx expo config --type public --json). Surfaced to clients as manifest.extra.expoClient and required for serving manifests. |
| (bundle + assets) | file | One file field per path listed in metadata.json — e.g. _expo/static/js/<platform>/entry-<hash>.hbc and assets/<hash> files. The form field name must equal that path with forward slashes. |
The server reads metadata.json, hashes every referenced file (SHA-256 base64url for hash, MD5 hex for key), generates per-platform index.<platform>.json files, and stores everything under <project>/<runtimeVersion>/<updateId>/ where updateId is the current Unix timestamp. Backslashes in metadata.json paths (which expo export produces on Windows) are normalized to forward slashes automatically.
Responses:
200 OK – { "updateId": "<unix_ts>", "message": "Update published successfully" }400 Bad Request – missing runtimeVersion or malformed multipart form.401 Unauthorized – missing/invalid API key, or the key does not belong to :project.500 Internal Server Error – missing metadata.json, or a bundle/asset referenced by metadata.json is absent from the upload.Build the update artifacts in your Expo project:
npx expo export --platform ios --platform android --output-dir dist
npx expo config --type public --json > dist/expoConfig.json
This produces (Expo SDK 50+):
dist/ metadata.json expoConfig.json _expo/static/js/ios/entry-<hash>.hbc _expo/static/js/android/entry-<hash>.hbc assets/<md5>...
Upload everything with curl (bash). Each file's -F field name must be its path inside dist/:
cd dist
FORMS=(
-F "runtimeVersion=1.0.0"
-F "metadata.json=@metadata.json"
-F "expoConfig.json=@expoConfig.json"
)
while IFS= read -r f; do
FORMS+=(-F "$f=@$f")
done < <(find _expo assets -type f)
curl -X POST "$HOSTNAME/api/updates/$PROJECT_ID/publish" \
-H "Authorization: Bearer $API_KEY" \
"${FORMS[@]}"
Windows PowerShell equivalent (uses curl.exe, works on 5.1+):
cd dist $cargs = @( '-X','POST',"$env:HOSTNAME/api/updates/$env:PROJECT_ID/publish", '-H',"Authorization: Bearer $env:API_KEY", '-F','runtimeVersion=1.0.0', '-F','metadata.json=@metadata.json', '-F','expoConfig.json=@expoConfig.json' ) Get-ChildItem -Recurse -File _expo, assets | ForEach-Object { $rel = (Resolve-Path -Relative $_.FullName).TrimStart('.\').Replace('\','/') $cargs += '-F'; $cargs += "$rel=@$rel" } & curl.exe @cargs
Tip: when generating
expoConfig.jsonon Windows, redirect withcmd /c "npx expo config --type public --json > dist\expoConfig.json"rather than PowerShellOut-File, which adds a UTF-8 BOM that prevents the server from parsingmanifest.extra.expoClient.
Set the PRIVATE_KEY environment variable to enable manifest signing. The value can be either:
-----BEGIN RSA PRIVATE KEY----- and PKCS#8 -----BEGIN PRIVATE KEY----- formats); ideal for Docker / 12-factor deployments where you don't want to mount a key file..pem file (used when the value does not contain BEGIN).Example .env:
PRIVATE_KEY="-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ... ... -----END PRIVATE KEY-----"
When a client sends expo-expect-signature, the server signs the JSON body of the manifest (or directive) part using RSASSA-PKCS1-v1_5 + SHA-256 and adds an expo-signature: sig=:<base64>:, keyid="main" part header (the signature value uses the Expo SFV byte-string syntax). Configure the matching public certificate in the Expo client with keyid: "main" and alg: "rsa-v1_5-sha256" (see the Expo code-signing guide). If expo-expect-signature is sent but the server has no key configured, the request fails with 400.
Each update is written under <DATA_DIR/updates or S3 bucket>/<project>/<runtimeVersion>/<updateId>/. The exact subpaths depend on what metadata.json references; for an Expo SDK 50+ export it looks like:
<project>/<runtimeVersion>/<updateId>/ metadata.json # uploaded expoConfig.json # uploaded index.ios.json # generated by server index.android.json # generated by server _expo/static/js/ios/entry-<hash>.hbc # uploaded (ios bundle) _expo/static/js/android/entry-<hash>.hbc # uploaded (android bundle) assets/<md5>... # uploaded rollback # optional marker (see below)
For pre-SDK-50 exports the bundles live under bundles/index.<platform>.js instead; the server doesn't care — it stores whatever paths metadata.json declares.
There is no dedicated API for rollbacks. To roll a (project, runtimeVersion) back to the embedded update, drop an empty file named rollback into the desired update directory:
touch <DATA_DIR>/updates/<project>/<runtimeVersion>/<updateId>/rollbackPUT an empty object at <project>/<runtimeVersion>/<updateId>/rollbackWhen that update becomes the latest, the manifest endpoint returns a rollBackToEmbedded directive with parameters.commitTime set to the update's timestamp (see internal/service/update.go and internal/storage).
Cache[T]), used by the storage and signing layers.manifest.go (manifest resolution, asset reader) and update.go (publish, hashing).local and s3 storage backends, plus cached.go (TTL-cached decorator).See LICENSE.