> 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/3-0-x/controle-plane-mode/getting-started.md).

# Getting Started

By the end of this guide you'll have a control-plane server running locally, an app you created from the dashboard, and your first update published, **without an Expo account**.

> **Prefer the simpler path?** [Stateless mode](/xprem/3-0-x/stateless-mode/overview.md) needs no database and keeps Expo as the source of truth. You can move to the control plane later: set `DB_URL`, restart, and the server migrates your existing app, keys and updates into Postgres on its own. Your clients never notice.

### Prerequisites

* Docker
* An Expo project using `expo-updates`

That's it. No Expo access token, no `eas` CLI, no signing keys to generate — the control plane issues its own.

### 1. Generate your two secrets

```bash
openssl rand -base64 32   # JWT_SECRET
openssl rand -base64 32   # DB_KEYS_MASTER_KEY_B64
```

Keep both. You'll paste them into the command in step 3.

> ⚠️ **Never regenerate `DB_KEYS_MASTER_KEY_B64`.** It seals your apps' signing keys inside Postgres. If it changes, the sealed keys can no longer be unsealed — your apps stop signing manifests, permanently, with no way to repair them. Reuse the exact same value on every restart.

### 2. Start Postgres

```bash
docker network create eoo
```

```bash
docker run -d --name eoo-db --network eoo \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=expo_open_ota \
  postgres:16
```

The shared `eoo` network lets the server reach the database by name (`eoo-db`). No port is published, so this won't collide with a Postgres you already run on 5432.

### 3. Start the server

Paste your two secrets from step 1:

```bash
docker run --rm -it --network eoo \
  -p 3000:3000 \
  -e BASE_URL=http://localhost:3000 \
  -e DB_URL="postgres://postgres:secret@eoo-db:5432/expo_open_ota?sslmode=disable" \
  -e DB_KEYS_MASTER_KEY_B64=your-master-key \
  -e JWT_SECRET=your-jwt-secret \
  -e STORAGE_MODE=local \
  -e LOCAL_BUCKET_BASE_PATH=/updates \
  -e CACHE_MODE=local \
  -e USE_DASHBOARD=true \
  -e ADMIN_EMAIL=admin@example.com \
  -e ADMIN_PASSWORD='Admin123!' \
  -v "$(pwd)/updates:/updates" \
  ghcr.io/mercuretechnologies/expo-open-ota:latest
```

`DB_URL` is what selects the control plane. Set it and the server boots into DB mode, running its migrations automatically; leave it out and you get stateless mode instead. Look for this line in the logs:

```
⚙️  [CONTROL] Initializing Control Plane (DB Mode)..
```

Verify the server is up:

```bash
curl http://localhost:3000/hc
```

### 4. Open the dashboard

Go to [**http://localhost:3000/dashboard**](http://localhost:3000/dashboard) and log in with the `ADMIN_EMAIL` and `ADMIN_PASSWORD` you set above (`admin@example.com` / `Admin123!`).

During the boot, that pair seeded the **first admin account** into Postgres — from now on it lives in the database, and the two variables are never read again. `ADMIN_PASSWORD` is held to the dashboard password policy (at least 8 characters, with an uppercase letter, a lowercase letter, a digit and a special character): a value like `admin` fails the migration and the server refuses to boot, with an error spelling out the rules. Invite teammates (and decide who gets admin rights) from the dashboard's [Users page](/xprem/3-0-x/dashboard/users.md); once this first boot has completed you can remove both variables from the deployment.

### 5. Create your app

Select **New Application**, give it a name (e.g. `consumer-mobile-app`), and choose **Database Managed** for the key storage. The server generates an RSA signing key pair for you and seals it under your master key.

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2FfWvqCFIP7WDea1T2VTiJ%2Fimage.png?alt=media&#x26;token=6aba91c9-a13f-42ba-8d45-e8873c5eabbb" alt="" width="257"><figcaption></figcaption></figure>

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2FGdCocA6JwLcA1qaCgcUU%2Fimage.png?alt=media&#x26;token=4d597a78-eba0-4ddd-8915-cfc215928526" alt="" width="375"><figcaption></figcaption></figure>

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2FnOujjC4Fqr0hOudJsugS%2Fimage.png?alt=media&#x26;token=df0c4593-9c6a-4ff4-ab17-4ac3541b3aee" alt="" width="563"><figcaption></figcaption></figure>

Copy the **app ID** it returns — a UUID. You'll need it twice below.

> This UUID is your app's identity on this server. It is **not** your Expo project ID, and the two are unrelated in control-plane mode.

### 6. Download the signing certificate

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2FfPIKYVPzRxBb4XMmREve%2Fimage.png?alt=media&#x26;token=798446ec-8c90-4720-9230-cad1194f023e" alt=""><figcaption></figcaption></figure>

On the app info page, select **Download Certificate**. Save it into your Expo project as `certs/certificate.pem`:

```bash
mkdir -p certs
mv ~/Downloads/app-<your-app-id>-certificate.txt certs/certificate.pem
```

Your app uses this certificate to verify that updates really came from your server. Commit it to your Expo project.

> **Do not run `npx eoas generate-certs`.** That command is for stateless mode, where you own the keys. Here the server already generated them — this download is your copy of the public half.

### 7. Create an API key

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2FZFy3cSopVsHrp9tt0Sn1%2Fimage.png?alt=media&#x26;token=1e5e7c21-9072-45a0-9d19-08bb587de90d" alt=""><figcaption></figcaption></figure>

On the same page, create an API key (name it after where it will run, e.g. `CI/CD Pipeline`). **Copy it immediately — it is shown only once.**

This is how `eoas` authenticates against a control-plane server:

```bash
export EOO_TOKEN=eoo_your_api_key
```

Setting `EOO_TOKEN` is what tells the CLI to use your server's own credentials instead of looking for Expo ones. Without it, `eoas` falls back to Expo auth and your publish fails.

### 8. Configure your Expo app

```bash
npx eoas init
```

Three answers matter:

| Prompt                                           | Answer                                                                           |
| ------------------------------------------------ | -------------------------------------------------------------------------------- |
| Project id (sent as the `expo-app-id` header)    | **Your dashboard app UUID** from step 5 — *not* the Expo project ID it pre-fills |
| URL of your update server                        | `http://localhost:3000`                                                          |
| Do you have already generated your certificates? | **Yes** — you downloaded it in step 6                                            |

> The app ID prompt pre-fills with your Expo project ID, detected from `app.config`. In control-plane mode that value is wrong — overwrite it with the UUID from the dashboard, or your app will get `404 Unknown app id` on every check.

`eoas init` writes your update URL, the certificate path, and an `expo-app-id` header into `app.config`. Since these live in the native build:

> ⚠️ **You need a new native build for any of this to take effect.** See the [EAS Build guide](https://docs.expo.dev/build/introduction/).

### 9. Publish your first update

```bash
export RELEASE_CHANNEL=production #you can use other value
export EOO_TOKEN=eoo_your_api_key
npx eoas publish --branch production
```

The branch is created for you on first publish — you don't need to create it beforehand.

### 10. Point a channel at your branch

<figure><img src="https://3865040152-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FAPMujKABP50H0CfF1Mrs%2Fuploads%2Ff7bJai5jxvOu68xdokyD%2Fimage.png?alt=media&#x26;token=80c247ed-02fd-4a4c-a43c-6a78c8905d0e" alt=""><figcaption></figcaption></figure>

Your app asks for a **channel**; updates live on a **branch**. The mapping between the two is yours to control, and it's what makes a promotion possible without a rebuild.

In the dashboard, open **Channels**, create a channel named `production`, and map it to the `production` branch you just published to.

Until this mapping exists, your app gets `404 No branch mapping found` — the server has your update but no idea which channel should receive it.

To see exactly what a client would receive, ask for a manifest yourself:

```bash
curl -sD - "http://localhost:3000/manifest" \
  -H "expo-app-id: your-app-id" \
  -H "expo-channel-name: production" \
  -H "expo-runtime-version: 1.0.0" \
  -H "expo-platform: ios" \
  -H "expo-protocol-version: 1"
```

**Your server works.** The `expo-manifest-filters: branch="production"` header confirms it resolved your channel from the database and found your update.

### Tearing down

```bash
docker rm -f eoo-db && docker network rm eoo
```


---

# 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/3-0-x/controle-plane-mode/getting-started.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.
