> For the complete documentation index, see [llms.txt](https://mercure-technologies.gitbook.io/expo-open-ota/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/expo-open-ota/deployment/kubernetes.md).

# Kubernetes

Deploy **Expo Open OTA** 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 deploys **the server only**. It creates a `Deployment`, `Service`, `Ingress`, `HorizontalPodAutoscaler`, and `ServiceAccount`.

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

New to Helm or Kubernetes? Use [Custom deployment](/expo-open-ota/deployment/custom-deployment.md) or [Railway](/expo-open-ota/deployment/railway.md).

### The mental model

Your configuration lives in two places:

|                  | `my-values.yaml`              | Kubernetes Secret                               |
| ---------------- | ----------------------------- | ----------------------------------------------- |
| **Role**         | The *shape*: enabled features | The *values*: each variable's contents          |
| **Holds**        | Toggles, Ingress, resources   | `DB_URL`, `JWT_SECRET`, bucket names, passwords |
| **Secret data?** | Never                         | Always                                          |

Toggles decide which environment variables the chart renders. Each rendered variable reads from the Secret. The default Secret is `expo-open-ota-secrets`.

Each Secret key uses the environment variable's name. The chart injects four toggles directly from `my-values.yaml`: `STORAGE_MODE`, `CACHE_MODE`, `USE_DASHBOARD`, and `KEYS_STORAGE_TYPE`.

{% hint style="warning" %}
Every rendered variable must exist as a Secret key. Missing keys cause `CreateContainerConfigError`.

An empty value is valid. An absent key is not.
{% endhint %}

### Deploy

{% stepper %}
{% step %}

#### Describe the deployment shape

Create `my-values.yaml`. This example enables control-plane mode, 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.

For a single-app stateless server, set `controlPlane: "false"`. Choose `keysStorageType` in the [configuration reference](#configuration-reference).
{% endstep %}

{% step %}

#### Create the Secret

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

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

Create a Secret with exactly those keys. This command matches the previous example.

```bash
kubectl create secret generic expo-open-ota-secrets -n NAMESPACE \
  --from-literal=BASE_URL="https://ota.example.com" \
  --from-literal=JWT_SECRET="$(openssl rand -base64 32)" \
  --from-literal=ADMIN_EMAIL="you@example.com" \
  --from-literal=ADMIN_PASSWORD="A-strong-password-1" \
  --from-literal=AWS_REGION="eu-west-3" \
  --from-literal=S3_BUCKET_NAME="my-ota-updates" \
  --from-literal=REDIS_HOST="redis.default.svc.cluster.local" \
  --from-literal=REDIS_PORT="6379" \
  --from-literal=REDIS_PASSWORD="..." \
  --from-literal=REDIS_USE_TLS="true" \
  --from-literal=DB_URL="postgresql://user:pass@postgres:5432/expo_ota" \
  --from-literal=DB_KEYS_MASTER_KEY_B64="$(openssl rand -base64 32)" \
  --from-literal=DB_MAX_CONNS="" \
  --from-literal=DB_MIN_CONNS=""
```

Use your normal secret-management tooling in production. For example, use External Secrets Operator, Sealed Secrets, or SOPS.

On a control-plane deployment, `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](/expo-open-ota/dashboard/users.md).

{% 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 expo-open-ota oci://ghcr.io/mercuretechnologies/charts/expo-open-ota \
  --version 3.X.X -n NAMESPACE -f my-values.yaml
```

Use the new version for upgrades:

```bash
helm upgrade expo-open-ota oci://ghcr.io/mercuretechnologies/charts/expo-open-ota \
  --version 3.1.0 -n NAMESPACE -f my-values.yaml
```

GitOps tools work the same way. Point ArgoCD or Flux at `oci://ghcr.io/mercuretechnologies/charts` as an OCI Helm repository.

Changing a Secret does not restart pods. Restart them manually:

```bash
kubectl rollout restart deployment/expo-open-ota -n NAMESPACE
```

{% endstep %}
{% endstepper %}

#### 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 expo-open-ota . -n NAMESPACE -f my-values.yaml
```

### Configuration reference

See [Environment variables](/expo-open-ota/references/environment-variables.md) for each variable. See [Control Plane mode](/expo-open-ota/controle-plane-mode/overview.md) for multi-app behavior.

#### Toggles in `my-values.yaml`

| Toggle                                | Default                   | Decides                                                               |
| ------------------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `secretName`                          | `"expo-open-ota-secrets"` | Secret containing the values                                          |
| `controlPlane`                        | `"false"`                 | `"true"` enables multi-app PostgreSQL mode                            |
| `dbKeysMasterKeySource`               | `"environment"`           | Master key source; use `"aws-secrets-manager"` alternatively          |
| `storageMode`                         | `"s3"`                    | Update storage: `"s3"`, `"gcs"`, or `"local"`                         |
| `keysStorageType`                     | `"aws-secrets-manager"`   | Stateless signing key store: `"local"` or `"environment"` also work   |
| `cacheMode`                           | `"redis"`                 | `"redis"` or any other value for in-memory caching                    |
| `useRedisTLS`                         | `"false"`                 | Renders `REDIS_USE_TLS` when `"true"`                                 |
| `useCloudfrontRedirect`               | `"false"`                 | Enables CloudFront asset redirects                                    |
| `useAWSAccessKeys`                    | `"false"`                 | Uses static AWS credentials instead of a service-account role         |
| `useDashboard`                        | `"false"`                 | Enables the dashboard and requires `ADMIN_EMAIL` and `ADMIN_PASSWORD` |
| `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                                     |
| `storageMode: "s3"`             | `AWS_REGION`, `S3_BUCKET_NAME`                                                                                  |
| `storageMode: "gcs"`            | `GCS_BUCKET_NAME`, `GOOGLE_APPLICATION_CREDENTIALS_B64`                                                         |
| `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

* **Never put credentials in `my-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 disables single-app variables.** It ignores `KEYS_STORAGE_TYPE`, `EXPO_APP_ID`, `EXPO_ACCESS_TOKEN`, and `*_EXPO_KEY_*`. The CloudFront key remains server-wide.
* **`PRIVATE_CLOUDFRONT_KEY_B64` renders with `keysStorageType: "environment"`.** Add it with an empty value when CloudFront is 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/expo-open-ota/deployment/kubernetes.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.
