Self-host with Docker
docker-compose.yml at the repository root brings up three services and one
init job. Copy the environment file, fill three values, and start it.
cp .env.example .envdocker compose up -dThe services
Section titled “The services”- server — built from the
Dockerfilein the repo root and taggedopenota/server:latest. Node 22 on Alpine, running a single esbuild bundle atdist/entry/node.js. Published on${PORT:-3000}:3000. - postgres —
postgres:17, databaseota, userota, volumeota-postgres. The server waits on itspg_isreadyhealthcheck. - minio — object storage on
:9000, console on:9001, volumeota-minio. Credentials areSTORAGE_ACCESS_KEY/STORAGE_SECRET_KEY. - minio-init — a one-shot
minio/mccontainer that creates the bucket and makes it world-readable, because devices download bundles with no credentials. It retriesmc alias setin a loop instead of waiting on a healthcheck, since the MinIO image ships no tool to probe itself with. The server will not start until this job completes successfully.
The three values you must set
Section titled “The three values you must set”Compose refuses to start without them.
openssl rand -base64 32 # OTA_MASTER_KEY| Variable | What it is |
|---|---|
OTA_MASTER_KEY |
32 random bytes, base64. Seals every project’s RSA private signing key at rest with AES-256-GCM. |
POSTGRES_PASSWORD |
Password for the ota database user; also builds DATABASE_URL. |
STORAGE_SECRET_KEY |
MinIO root password, and the S3 secret the server signs upload URLs with. |
Losing OTA_MASTER_KEY means re-keying every project and shipping new binaries,
because the public half of each key pair is compiled into your app. Keep a copy
somewhere other than the host running the server.
What happens on boot
Section titled “What happens on boot”The Node entry (apps/server/src/entry/node.ts) is the only target that boots
the install by itself:
- Applies pending migrations from
drizzle/. - Seeds the three plans (free, pro, scale).
- On an empty
userstable inOTA_MODE=self, creates the first admin account and its organisation fromOTA_ADMIN_EMAIL/OTA_ADMIN_PASSWORD. The password needs 10 characters or more. If the account cannot be created the server logs the reason and keeps serving. - Serves the built dashboard from
DASHBOARD_DIR(/app/dashboardin the image), falling back toindex.htmlfor any GET that is not under/api,/oauth,/mcp,/healthzor/.well-known.
Leave OTA_ADMIN_EMAIL empty and the first account is instead created by
signing up in the dashboard. In self mode signup closes permanently after
that first account.
GET /healthz reports mode, storage driver and whether billing is on.
The MinIO address
Section titled “The MinIO address”This is the one string that has to be right. The default in .env.example is
http://minio:9000, which resolves only inside the compose network.
STORAGE_FORCE_PATH_STYLE=true is required for MinIO and wrong for S3 and R2.
Swapping MinIO for R2 or S3
Section titled “Swapping MinIO for R2 or S3”Delete the minio and minio-init services, then point the storage variables
at the real bucket:
STORAGE_DRIVER=s3STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.comSTORAGE_REGION=autoSTORAGE_BUCKET=ota-bundlesSTORAGE_ACCESS_KEY=<api-token-id>STORAGE_SECRET_KEY=<api-token-secret>STORAGE_FORCE_PATH_STYLE=falseThe bucket has to allow anonymous reads on the bundles/ prefix, or sit behind
a CDN that does.
Putting a CDN in front
Section titled “Putting a CDN in front”Set PUBLIC_BUNDLE_BASE_URL to the CDN hostname. It is used only to build the
download URL handed to devices; uploads still go to STORAGE_ENDPOINT.
PUBLIC_BUNDLE_BASE_URL=https://cdn.example.comA release is immutable — new content is always a new release with a new id and
a new key — so the objects can be cached forever. Configure the CDN with
cache-control: public, max-age=31536000, immutable. The bundle URL sits
outside the signed manifest precisely so you can change this later without
invalidating any existing release.
Backups
Section titled “Backups”pg_dump plus the bucket. Bundles are immutable, so they never need
versioning. Upgrading is docker compose pull followed by a restart:
migrations run again on boot.