> For the complete documentation index, see [llms.txt](https://mercure-technologies.gitbook.io/xprem/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mercure-technologies.gitbook.io/xprem/changelog-and-migrations/migrate-from-v2-to-v3.md).

# Migrate from v2 to v3

v3 adds multi-app support on a single server. On a single-app deployment the server env vars keep their v2 names, so the upgrade comes down to three things: deploy the new image, add the `expo-app-id` header to your Expo config, and upgrade the `eoas` CLI.

{% hint style="warning" %}
**Read this before you upgrade.** The first v3 boot moves your bucket to a new layout. The server answers `503` until the move completes, it needs delete permission on the bucket, and you must stop CI publishes while it runs. See [The bucket re-path](#the-bucket-re-path).
{% endhint %}

### What changes

|                    | v2                                     | v3                                                         |
| ------------------ | -------------------------------------- | ---------------------------------------------------------- |
| App identity       | `EXPO_APP_ID` env var                  | `expo-app-id` header sent by the client                    |
| Bucket layout      | `{prefix}/{branch}/{runtimeVersion}/…` | `{prefix}/{appId}/{branch}/{runtimeVersion}/…`             |
| Publish routes     | `/requestUploadUrl/…`                  | `/{appId}/requestUploadUrl/…`, so the CLI must be upgraded |
| Dashboard login    | `ADMIN_PASSWORD`                       | `ADMIN_EMAIL` + `ADMIN_PASSWORD`                           |
| Prometheus metrics | no app label                           | every metric carries an `appId` label                      |

### Choose your path

| Path                          | When                                                                    | What you need                        |
| ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------ |
| **Stay stateless**            | One Expo app on this server                                             | Nothing new to provision             |
| **Move to the control plane** | Several apps on one server, or you want apps managed from the dashboard | A Postgres database and a master key |

Both start with the same server upgrade. If you are unsure, stay stateless: you can move to the control plane later without redoing any of this.

### Path A: stay stateless

{% stepper %}
{% step %}

#### Grant delete permission on the bucket

The re-path copies each object to its new key and then deletes the original, which a v2 server never had to do. Add `s3:DeleteObject` to your IAM policy on S3, or grant `roles/storage.objectAdmin` on GCS. Nothing to do on the local filesystem backend.
{% endstep %}

{% step %}

#### Stop your CI publishes

Any publish, republish or rollback that lands while the move runs stays at the old path and is lost to the new layout. Keep publishes off until step 3 has finished.
{% endstep %}

{% step %}

#### Deploy the v3 image

Your existing env vars are unchanged. Add `ADMIN_EMAIL` next to `ADMIN_PASSWORD` if you use the dashboard, since the login form now asks for both.

On Kubernetes, upgrade the Helm chart together with the image: v3 moves the readiness probe to `/ready` and keeps liveness on `/hc`, which is what allows a pod to survive a long re-path.

The server re-paths the bucket on this first boot and answers `503` until it is done. Follow the logs and wait for `✅ v1→v2 bucket re-path complete` before moving on.
{% endstep %}

{% step %}

#### Add `expo-app-id` to your Expo config

Do this now, even though you are not rebuilding yet: `eoas` reads this value from your config to know which app it is publishing to, and refuses to run without it.

```ts
// app.config.ts
export default ({ config }) => ({
  ...config,
  updates: {
    url: 'https://ota.mysite.com/manifest',
    requestHeaders: {
      'expo-channel-name': process.env.RELEASE_CHANNEL,
      // New in v3, must match the server's EXPO_APP_ID
      'expo-app-id': 'a45ed151-0387-4419-a97c-d008d8f214c7',
    },
  },
});
```

`npx eoas init` writes it for you if you prefer.
{% endstep %}

{% step %}

#### Upgrade the `eoas` CLI

The CLI and the server share the same version numbers. Pin the CLI to the server release you just deployed, and never install `@latest`:

```bash
npm i -D eoas@3.X.X
```

Publishing works again from here: `npx eoas publish --branch=<branch>`.
{% endstep %}
{% endstepper %}

Your migration is done. Your installed apps keep receiving updates through the [legacy app-id fallback](#retire-the-legacy-fallback), which you can retire whenever it suits you.

### Path B: move to the control plane

Run [Path A](#path-a-stay-stateless) first, then:

{% stepper %}
{% step %}

#### Provision Postgres and set the master key

**Create the database yourself before the first boot.** The server runs its own migrations but never creates the database: `DB_URL` must point at a database that already exists. Most managed Postgres providers create one for you when you provision the instance, otherwise a `CREATE DATABASE expo_ota;` is enough. The server needs no privileges beyond that database.

Set `DB_URL`, plus exactly one of `DB_KEYS_MASTER_KEY_B64` or `AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID`. Generate the master key with:

```bash
openssl rand -base64 32
```

Store it wherever you keep your secrets and keep a backup: it is what unseals your signing keys, and losing it means losing access to them. The master key must be set before the first control-plane boot, since without it the server refuses to start.

Keep all your existing flat env vars in place for that boot, including `EXPO_APP_ID`, `EXPO_ACCESS_TOKEN`, `KEYS_STORAGE_TYPE` and your key variables. The migration reads them. `ADMIN_EMAIL` and `ADMIN_PASSWORD` must both be set as well, since the first boot seeds your first dashboard admin from them.
{% endstep %}

{% step %}

#### Boot once to import your app

A one-time migration seeds your existing app, its branches, channels and update history into the database. If your keys were in `local` or `environment` mode, the same key pair is sealed into the database under the master key, so signed manifests keep verifying and no rebuild is needed. `aws-secrets-manager` keys are left where they are.

From here on, manage apps from the dashboard. You can drop the flat env vars afterwards, except `EXPO_APP_ID`, which still names the app your legacy clients resolve to.
{% endstep %}
{% endstepper %}

There is no fallback on the control plane: a deployment with several apps cannot attribute a header-less request, so every client must send `expo-app-id`.

See the [control plane overview](broken://pages/cLPKVloZBlACsis8gh98) for the full reference.

### The bucket re-path

On its first v3 boot the server moves your bucket data from `{branch}/…` to `{appId}/{branch}/…`. It is automatic, runs exactly once, and there is no opt-out.

**While it runs**, `/hc` answers `200` so your orchestrator does not kill the pod, `/ready` answers `503` so no traffic is routed to it, and any request that gets through receives a `503` with a `Retry-After` header. Clients simply retry on their next update check. With several replicas, one instance does the move and the others hold their boot until it finishes.

**Objects move 16 at a time** (`BUCKET_MIGRATION_CONCURRENCY`), with a progress line every 500 objects. An interrupted or failed run resumes where it stopped on the next boot, so fixing the cause and booting again is always the right move.

**Two things can make it fail:**

* **Missing delete permission.** Object stores have no rename, so the migration copies then deletes. On S3 this shows up as `AccessDenied` on a `DeleteObject` call, on GCS as `403 … does not have storage.objects.delete access`.
* **App id colliding with a branch name.** If `EXPO_APP_ID` equals the name of an existing branch, boot fails with `app id collides with a v1 branch of the same name`. Rename the branch in the bucket and boot again. Real Expo project ids are UUIDs, so this only happens with a hand-written id.

**Publishes that land mid-move are lost to the new layout.** The migration first lists the bucket, then copies. Anything written between those two phases stays at the old root. The v3 server holds its own publishes with a `503`, but v2 replicas still alive during a rolling upgrade will happily accept them, which is why you stop CI first. The local filesystem backend is not affected.

{% hint style="info" %}
A successful run logs `✅ v1→v2 bucket re-path complete`. The internal migration name predates the public version bump: this is the v2 to v3 re-path.
{% endhint %}

### Retire the legacy fallback

This is not part of the migration and nothing forces you to do it now. Your server is fully migrated at the end of Path A, and it keeps serving your existing users because of the legacy fallback: when a request carries no `expo-app-id` header, a single-app deployment resolves it to `EXPO_APP_ID`.

That fallback exists for one reason only. `requestHeaders` are baked into `Expo.plist` and `AndroidManifest.xml` at build time, so an app already installed on a phone can never start sending the header without a store release, and you should not have to wait for the stores to ship an OTA fix. It is deprecated and transitional: it covers the window between your upgrade and the moment your users have the new binary.

Closing that window takes two steps, on your own schedule:

1. **Rebuild and ship a store release.** `npx expo prebuild --clean` regenerates the native projects with the header, then release as usual. There is no deadline.
2. **Turn the fallback off** once that build has reached your users: set `SKIP_LEGACY_APP_ID_FALLBACK=true`. Header-less requests then get `400 No app id provided`, which is how you surface the clients still running an old binary.

Do both before you host a second app on the deployment, since a fallback that resolves to one app cannot serve two.

The boot log tells you which state you are in:

```
🔁 [LEGACY] app id fallback ACTIVE for <app-id> …
🔒 [LEGACY] app id fallback DISABLED by SKIP_LEGACY_APP_ID_FALLBACK …
```

`npx eoas doctor --channel=production` probes your server twice, once as a v2 client and once as a v3 client, and reports whether each is served.

### Dashboard accounts

The shared `ADMIN_PASSWORD` login is gone: the sign-in form now asks for an email and a password.

**Stateless**: set `ADMIN_EMAIL` next to `ADMIN_PASSWORD`. That pair is the only account, always an admin, and nothing is stored server-side. Until `ADMIN_EMAIL` is set every login is rejected, while the server itself boots and serves updates normally.

**Control plane**: accounts live in Postgres. The first boot seeds your first admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, and both must be set or the server refuses to start. `ADMIN_PASSWORD` must also satisfy the dashboard policy (8 characters minimum, with an uppercase, a lowercase, a digit and a special character), so a legacy shared password may fail that boot. After that, manage accounts from the [Users page](/xprem/dashboard/users.md) and drop the variables if you like.

Roles: members are read-only, admins mutate. Creating apps, branches and channels, remapping channels, minting API keys and downloading the signing certificate all require an admin. CLI publishing is unaffected.

### Other changes

* **Every Prometheus metric gained an `appId` label**, and `update_error_users_total` now counts unique clients rather than incrementing per request. Update your Grafana queries; the dashboard shipped in the repo already is.
* **`BUCKET_KEY_PREFIX` replaces `S3_KEY_PREFIX`.** The old name still works but logs a deprecation warning at boot, and the new one applies to every storage backend.
* **App ids are validated more strictly**: 64 ASCII characters max (`a-z A-Z 0-9 - _ .`), and names colliding with top-level routes (`api`, `assets`, `auth`, `dashboard`, `hc`, `manifest`, `metrics`) are rejected at boot. A UUID always passes.

### Troubleshooting

**`400 No app id provided`.** The client sent no header and the server had nothing to fall back to. On a single-app deployment, either `SKIP_LEGACY_APP_ID_FALLBACK` is set or the client needs rebuilding. On the control plane there is no fallback by design: check that `Expo.plist` and `AndroidManifest.xml` actually contain the header, and if they do not, you set `requestHeaders` but never ran `npx expo prebuild --clean`.

**`400 Error validating expo auth`.** The `expo-app-id` sent by the client matches no app on the server. Compare it with `EXPO_APP_ID` (stateless) or with the app in the dashboard (control plane).

**Boot fails with `no app config found`.** Neither `EXPO_APP_ID` nor `DB_URL` is set. Pick a path: [stateless getting started](/xprem/stateless-mode/getting-started.md) or [control plane overview](broken://pages/cLPKVloZBlACsis8gh98).

**Boot fails with `database system is unreachable`.** If the cause reads `SQLSTATE 3D000` or `database "…" does not exist`, the database named in `DB_URL` was never created. Create it and boot again.

**`503` with a `Retry-After` header.** The re-path is still running. This is expected, not an outage. Follow the logs; traffic resumes on its own.

**`eoas` fails with `missing the 'expo-app-id' entry`.** Your Expo config has no `expo-app-id` in `updates.requestHeaders`. Add it, or run `npx eoas init`.

### Manual bucket re-path

The automatic migration handles this and cannot be turned off. These commands are for recovery only, when a run died partway and left objects at the old root. Stop the server first, run them, then boot: the migration finds nothing left at the root and records itself as applied. Replace `<APP_ID>` with your `EXPO_APP_ID` and leave `.migrationhistory` at the bucket root.

{% tabs %}
{% tab title="Local" %}

```bash
cd "$LOCAL_BUCKET_BASE_PATH"
mkdir -p "<APP_ID>"
for d in */; do
  [ "$d" = "<APP_ID>/" ] && continue
  mv -- "$d" "<APP_ID>/"
done
```

{% endtab %}

{% tab title="S3" %}

```bash
# Drop --dryrun once the preview looks right.
aws s3 mv "s3://$S3_BUCKET_NAME/" "s3://$S3_BUCKET_NAME/<APP_ID>/" \
  --recursive --exclude ".migrationhistory" --dryrun
```

{% endtab %}

{% tab title="GCS" %}

```bash
gsutil -m mv "gs://$GCS_BUCKET_NAME/**" "gs://$GCS_BUCKET_NAME/<APP_ID>/"
```

{% endtab %}
{% endtabs %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://mercure-technologies.gitbook.io/xprem/changelog-and-migrations/migrate-from-v2-to-v3.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
