> 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/deployment/helm-template.md).

# Helm template

Deploy **xprem** on Kubernetes with the official Helm chart. The chart is an OCI artifact in the GitHub Container Registry. No repository clone is needed.

Use Helm **3.8+** for OCI support.

Chart versions match [releases](https://github.com/mercuretechnologies/expo-open-ota/releases), without the `v` prefix. Release `v3.X.X` uses chart `3.X.X`. Always pin `--version`.

The chart creates a `Deployment`, `Service`, `Ingress`, `HorizontalPodAutoscaler`, and `ServiceAccount`. It can also create the Kubernetes Secret holding your configuration values, from a file you keep out of git.

Provide PostgreSQL for the control plane. Provide Redis for cache mode.

New to Helm or Kubernetes? Use [Docker](/xprem/deployment/docker-image.md) or [Railway](/xprem/deployment/railway.md)

### The mental model

Your configuration lives in two files, both generated by `npx eoas server:init` when you pick the Helm deployment, and both passed to the same `helm install` command:

|             | `values.yaml`                 | `secrets.yaml`                                  |
| ----------- | ----------------------------- | ----------------------------------------------- |
| **Role**    | The *shape*: enabled features | The *values*: each variable's contents          |
| **Holds**   | Toggles, Ingress, resources   | `DB_URL`, `JWT_SECRET`, bucket names, passwords |
| **In git?** | Yes, safe to commit           | Never; add it to `.gitignore`                   |

Toggles in `values.yaml` decide which environment variables the chart renders. Each rendered variable reads from a Kubernetes Secret, named by `secretName` (`xprem-secrets` by default). `secrets.yaml` fills the `secretEnv` map, and the chart renders that map into the Secret itself: there is no `kubectl create secret` step, and editing a value followed by `helm upgrade` restarts the pods automatically through a checksum annotation.

Each Secret key uses the environment variable's name. A few variables never come from the Secret: the chart injects `STORAGE_MODE`, `CACHE_MODE`, `USE_DASHBOARD`, `KEYS_STORAGE_TYPE`, `TRUST_PROXY_HEADERS`, and `ARCHIVE_AUDIT_LOGS` directly from `values.yaml`, adds `TRUST_PROXY_DEPTH` when `trustProxyHeaders` is `"true"`, and sets `PROMETHEUS_ENABLED` itself from `podAnnotations`.

Prefer managing the Secret with your own tooling (External Secrets Operator, Sealed Secrets, SOPS)? Leave `secretEnv` empty and see [Bring your own Secret](#bring-your-own-secret).

### Deploy

{% stepper %}
{% step %}

#### Generate the pair

```bash
npx eoas server:init
```

Answer the questions and pick **Helm** as the deployment target. The wizard writes `xprem-helm/values.yaml` and `xprem-helm/secrets.yaml`, pre-filled with everything it can infer from your answers; whatever it could not know yet appears as a `<placeholder>`.

You can also write both files by hand. This `values.yaml` enables the control plane, S3, Redis, and the dashboard:

```yaml
controlPlane: "true" # Apps live in Postgres, managed from the dashboard
dbKeysMasterKeySource: "environment"

storageMode: "s3"
cacheMode: "redis"
useRedisTLS: "true"
useDashboard: "true"

ingress:
  className: "nginx"
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 100m
  hosts:
    - host: ota.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: ota-example-com-tls
      hosts:
        - ota.example.com
```

Do not add connection strings, bucket names, or passwords to this file. Do not set `image.tag` either. Each chart defaults to the image from its own release.
{% endstep %}

{% step %}

#### Fill the values

`secrets.yaml` holds every value in the `secretEnv` map. This example matches the shape above:

```yaml
secretEnv:
  BASE_URL: "https://ota.example.com"
  JWT_SECRET: "..." # openssl rand -base64 32
  ADMIN_EMAIL: "you@example.com"
  ADMIN_PASSWORD: "A-strong-password-1"
  AWS_REGION: "eu-west-3"
  S3_BUCKET_NAME: "my-ota-updates"
  REDIS_HOST: "redis.default.svc.cluster.local"
  REDIS_PORT: "6379"
  REDIS_PASSWORD: "..."
  REDIS_USE_TLS: "true"
  DB_URL: "postgresql://user:pass@postgres:5432/xprem"
  DB_KEYS_MASTER_KEY_B64: "..." # openssl rand -base64 32
```

`ADMIN_PASSWORD` seeds the first dashboard admin at the first boot and must meet the dashboard password policy (at least 8 characters, with an uppercase letter, a lowercase letter, a digit and a special character), or the pod fails to boot. See [Users](/xprem/dashboard/users.md).

Check the pair before deploying:

```bash
npx eoas server:validate xprem-helm
```

{% hint style="danger" %}
`DB_KEYS_MASTER_KEY_B64` encrypts every per-app signing key at rest. It is not recoverable.

Back it up before deployment.
{% endhint %}
{% endstep %}

{% step %}

#### Install or upgrade

```bash
helm install xprem oci://ghcr.io/mercuretechnologies/charts/xprem \
  --version 3.X.X -n NAMESPACE \
  -f xprem-helm/values.yaml -f xprem-helm/secrets.yaml
```

Use the new version for upgrades:

```bash
helm upgrade xprem oci://ghcr.io/mercuretechnologies/charts/xprem \
  --version 3.1.0 -n NAMESPACE \
  -f xprem-helm/values.yaml -f xprem-helm/secrets.yaml
```

To change a configuration value later, edit `secrets.yaml` and run the same `helm upgrade`: the chart stamps a checksum of `secretEnv` on the pods, so they restart automatically.

GitOps tools work the same way. Point ArgoCD or Flux at `oci://ghcr.io/mercuretechnologies/charts` as an OCI Helm repository, and keep `secrets.yaml` out of the repository: in a GitOps setup, prefer [bringing your own Secret](#bring-your-own-secret).
{% endstep %}
{% endstepper %}

### Bring your own Secret

Leave `secretEnv` empty (the default) and the chart creates no Secret. Create one named after `secretName` with your usual tooling, such as External Secrets Operator, Sealed Secrets, or SOPS.

Ask the chart which keys it renders. Run this after changing a toggle.

```bash
helm template xprem oci://ghcr.io/mercuretechnologies/charts/xprem \
  --version 3.X.X -f values.yaml \
  | grep -A2 secretKeyRef | grep 'key:' | awk '{print $2}' | sort -u
```

{% hint style="warning" %}
Every variable your toggles make required must exist as a Secret key. A missing required key causes `CreateContainerConfigError`, and an empty value is valid.

Every other rendered variable is marked `optional: true` in the `Deployment`, so it may be absent from the Secret entirely.
{% endhint %}

With an out-of-band Secret, the chart cannot detect value changes: after updating the Secret, restart the pods yourself.

```bash
kubectl rollout restart deployment/xprem -n NAMESPACE
```

#### Work from source

Install from the repository to test unreleased chart changes or contribute.

```bash
git clone https://github.com/mercuretechnologies/expo-open-ota
cd expo-open-ota/helm
helm install xprem . -n NAMESPACE -f values.yaml -f secrets.yaml
```

### Configuration reference

See [Environment variables](/xprem/references/environment-variables.md) for each variable.

#### Toggles in `values.yaml`

| Toggle                                | Default                 | Decides                                                                                            |
| ------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| `secretName`                          | `"xprem-secrets"`       | Secret containing the values                                                                       |
| `secretEnv`                           | `{}`                    | Values the chart renders into that Secret; empty means you manage the Secret yourself              |
| `controlPlane`                        | `"false"`               | `"true"` enables multi-app PostgreSQL mode                                                         |
| `dbKeysMasterKeySource`               | `"environment"`         | Master key source; use `"aws-secrets-manager"` alternatively                                       |
| `storageMode`                         | `"s3"`                  | Update storage: `"s3"`, `"gcs"`, `"azure"`, or `"local"`                                           |
| `keysStorageType`                     | `"aws-secrets-manager"` | CloudFront private key source; also the stateless signing key store                                |
| `cacheMode`                           | `"redis"`               | `"redis"`, `"redis-sentinel"`, or any other value for in-memory caching                            |
| `useRedisTLS`                         | `"false"`               | Renders `REDIS_USE_TLS` when `"true"`                                                              |
| `useCloudfrontRedirect`               | `"false"`               | Enables CloudFront asset redirects                                                                 |
| `useGenericCDN`                       | `"false"`               | Renders `CDN_BASE_URL` and `S3_CDN_PREFIX`; without it, a CDN key in `secretEnv` is never injected |
| `useAWSAccessKeys`                    | `"false"`               | Uses static AWS credentials instead of a service-account role                                      |
| `useDashboard`                        | `"false"`               | Enables the dashboard and requires `ADMIN_EMAIL` and `ADMIN_PASSWORD`                              |
| `trustProxyHeaders`                   | `"false"`               | Reads the client IP from `X-Forwarded-For` instead of the connection address                       |
| `trustProxyDepth`                     | `"1"`                   | Number of proxies in front of the server; used only when `trustProxyHeaders` is `"true"`           |
| `archiveAuditLogs`                    | `"false"`               | Exports the audit log to a dedicated bucket (enterprise, control plane only)                       |
| `replicaCount`                        | `1`                     | Keep `1` with `"local"` storage; disks are not shared                                              |
| `podAnnotations.prometheus.io/scrape` | unset                   | Sets `PROMETHEUS_ENABLED=true` when `"true"`                                                       |

#### Secret keys by configuration

| When                            | Required Secret keys                                                                                            |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Always                          | `BASE_URL`, `JWT_SECRET`                                                                                        |
| `useDashboard: "true"`          | `ADMIN_EMAIL`, `ADMIN_PASSWORD`                                                                                 |
| `cacheMode: "redis"`            | `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`; plus `REDIS_USE_TLS` with TLS                                     |
| `cacheMode: "redis-sentinel"`   | `REDIS_SENTINEL_ADDRS`, and optionally `REDIS_SENTINEL_MASTER_NAME`; plus `REDIS_USE_TLS` with TLS              |
| `storageMode: "s3"`             | `AWS_REGION`, `S3_BUCKET_NAME`                                                                                  |
| `storageMode: "gcs"`            | `GCS_BUCKET_NAME`, `GOOGLE_APPLICATION_CREDENTIALS_B64`                                                         |
| `storageMode: "azure"`          | `AZURE_BLOB_CONTAINER_NAME`, `AZURE_STORAGE_ACCOUNT_NAME`, `AZURE_STORAGE_ACCOUNT_KEY`                          |
| `storageMode: "local"`          | `LOCAL_BUCKET_BASE_PATH`                                                                                        |
| `useAWSAccessKeys: "true"`      | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`                                                                    |
| `useCloudfrontRedirect: "true"` | `CLOUDFRONT_DOMAIN`, `CLOUDFRONT_KEY_PAIR_ID`, plus the key for `keysStorageType`                               |
| `controlPlane: "true"`          | `DB_URL`, `DB_MAX_CONNS`, `DB_MIN_CONNS`, plus `DB_KEYS_MASTER_KEY_B64` or `AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID` |
| `controlPlane: "false"`         | `EXPO_APP_ID`, `EXPO_ACCESS_TOKEN`, plus signing keys for `keysStorageType`                                     |

#### Signing keys by `keysStorageType`

| `keysStorageType`       | App signing keys                                                      | CloudFront private key                   |
| ----------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
| `"aws-secrets-manager"` | `AWSSM_EXPO_PUBLIC_KEY_SECRET_ID`, `AWSSM_EXPO_PRIVATE_KEY_SECRET_ID` | `AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID` |
| `"local"`               | `PUBLIC_LOCAL_EXPO_KEY_PATH`, `PRIVATE_LOCAL_EXPO_KEY_PATH`           | `PRIVATE_CLOUDFRONT_KEY_PATH`            |
| `"environment"`         | `PUBLIC_EXPO_KEY_B64`, `PRIVATE_EXPO_KEY_B64`                         | `PRIVATE_CLOUDFRONT_KEY_B64`             |

#### Good to know

* **Secrets belong in `secrets.yaml` or your own Secret, never in `values.yaml`.** An empty `secretName` writes every value as plaintext into the `Deployment` manifest.
* **`BASE_URL` must match the Ingress host.** The server builds manifest and asset URLs from it.
* **Control-plane mode creates apps in the database instead of from the single-app variables.** That does not make those variables dead weight: a deployment upgraded from v2 must keep `EXPO_APP_ID`, `EXPO_ACCESS_TOKEN`, `KEYS_STORAGE_TYPE` and the matching `*_EXPO_KEY_*` values set, because they drive the one-shot legacy import into Postgres, the v1 to v2 bucket re-path, and the fallback that keeps serving clients whose requests carry no `expo-app-id` header. Set `SKIP_LEGACY_APP_ID_FALLBACK=true` to turn that last one off once every client has been rebuilt. Only a control plane created from scratch leaves the variables unset. The CloudFront key remains server-wide.
* **`PRIVATE_CLOUDFRONT_KEY_B64` renders with `keysStorageType: "environment"`.** When you manage the Secret yourself, add it with an empty value even with CloudFront disabled.

Keep `/metrics` private at the Ingress (for prometheus):

```yaml
ingress:
  annotations:
    nginx.ingress.kubernetes.io/server-snippet: |
      location ~* "^/metrics" {
        deny all;
        return 403;
      }
```


---

# 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/deployment/helm-template.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.
