Deploy with Docker Compose
Run the stack starts everything with development credentials. This page turns the same file into an install you keep: your own projects and secrets, debug ports closed to the network, retention you chose, and optionally replays in an S3 bucket. Nothing in compose.yaml changes; you add files next to it.
For a server on the internet, with TLS and a reboot-proof service, continue with Single VM after this page.
Your own projects and secrets
Section titled “Your own projects and secrets”-
In the stack’s folder, make a project token and keep it: your apps will need it. Replace
web-storewith your project id (lowercase letters, digits,.,_,-).Terminal window echo "pt_$(openssl rand -hex 16)" > web-store.token -
Write the project registry. Each line is
<project id>:{SHA}<base64 of the SHA-1 of the token>, exactly whathtpasswd -nbsprints:Terminal window printf '%s:{SHA}%s\n' web-store "$(tr -d '\n' < web-store.token | openssl dgst -binary -sha1 | openssl base64)" > projects.htpasswdA second project for the platform’s own telemetry (rum-api’s traces and metrics) keeps its rate limits apart from your apps:
Terminal window echo "pt_$(openssl rand -hex 16)" > internal.tokenTerminal window printf '%s:{SHA}%s\n' internal "$(tr -d '\n' < internal.token | openssl dgst -binary -sha1 | openssl base64)" >> projects.htpasswd -
Write
.envwith new secrets. Every value here replaces a public development default:Terminal window printf 'OUO_INGEST_TOKEN=it_%s\nOUO_READ_TOKEN=rt_%s\nOUO_ADMIN_TOKEN=at_%s\nOUO_GRAFANA_ADMIN_PASSWORD=%s\nOUO_UPLOAD_TOKENS=web-store:ut_%s\nOUO_BACKEND_OTLP_HEADERS=Authorization=Basic%%20%s\n' "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" "$(openssl rand -base64 18)" "$(openssl rand -hex 16)" "$(printf 'internal:%s' "$(tr -d '\n' < internal.token)" | base64)" > .env && chmod 600 .env -
Save this as
compose.override.yaml. It mounts your registry in place of the demo one, and binds rum-api, Loki, Tempo and Mimir to127.0.0.1so only this host reaches them:# Your own projects instead of the public demo one, and loopback-only debug ports.# Compose reads this file automatically next to compose.yaml.services:otel-collector:configs: !override- source: collectortarget: /etc/otelcol/config.yaml- source: projectstarget: /etc/otelcol/projects.htpasswdrum-api:ports: !override- "127.0.0.1:${OUO_RUM_API_PORT:-8080}:8080"loki:ports: !override- "127.0.0.1:${OUO_LOKI_PORT:-3100}:3100"tempo:ports: !override- "127.0.0.1:${OUO_TEMPO_PORT:-3200}:3200"mimir:ports: !override- "127.0.0.1:${OUO_MIMIR_PORT:-9009}:9009"configs:projects:file: ./projects.htpasswd -
Apply it. Compose recreates only the containers whose configuration changed.
Terminal window docker compose up -d -
Check that the demo credential is gone and yours works:
Terminal window curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:4318/v1/traces -u demo:pt_dev_demo_0000000000000000 -H 'Content-Type: application/json' -d '{"resourceSpans":[]}'You see
401.Terminal window curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:4318/v1/traces -u "web-store:$(cat web-store.token)" -H 'Content-Type: application/json' -d '{"resourceSpans":[]}'You see
200. Grafana now takes the password in.env, nottesting567.
Give your apps projectId: "web-store" and the token in web-store.token. Browser apps also need their origin allowed, in .env:
OUO_ALLOWED_ORIGINS=https://shop.example.com,https://*.preview.example.comSettings in .env
Section titled “Settings in .env”| Variable | Default | What it sets |
|---|---|---|
OUO_ALLOWED_ORIGINS |
http://localhost:*,http://127.0.0.1:* |
browser origins allowed by the collector, for all projects |
OUO_RATELIMIT_PROJECT_RATE, _BURST |
1000, 5000 |
requests per second per project |
OUO_RATELIMIT_CLIENT_RATE, _BURST |
100, 300 |
requests per second per client address |
OUO_RATELIMIT_CLIENT_KEY |
address |
x_forwarded_for behind a reverse proxy that sets the header |
OUO_RETENTION_LOGS |
720h |
Loki: events and logs (at least 24h) |
OUO_RETENTION_TRACES |
336h |
Tempo |
OUO_RETENTION_METRICS |
2160h |
Mimir |
OUO_RETENTION_REPLAYS |
720h |
rum-api replays, from a session’s last chunk; 0 keeps them |
OUO_*_VERSION |
latest |
pin OUO_COLLECTOR_VERSION, OUO_RUM_API_VERSION, OUO_GRAFANA_VERSION |
OUO_*_PORT |
see Run the stack | host ports |
OUO_SENTRY_UPSTREAM, OUO_SENTRY_PROJECT_ID, OUO_SENTRY_PUBLIC_KEY |
none, 1, a placeholder |
the Sentry bridge |
After any change: docker compose up -d. A change to projects.htpasswd alone needs a restart, because the collector reads it once at start:
docker compose restart otel-collectorReplays in MinIO or S3
Section titled “Replays in MinIO or S3”By default rum-api keeps replays in the rum-api-data Docker volume. To keep them in a bucket instead, add MinIO and point rum-api at it.
-
Add the MinIO credentials to
.env:Terminal window printf 'OUO_MINIO_ROOT_USER=minioadmin\nOUO_MINIO_ROOT_PASSWORD=%s\nOUO_RUM_API_S3_ACCESS_KEY=rum-api\nOUO_RUM_API_S3_SECRET_KEY=%s\n' "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" >> .env -
Save this as
compose.s3.yaml.minio-initcreates the bucket and arum-apiuser that can reach only that bucket; rum-api never uses the root credentials and never creates buckets.# rum-api stores replays in MinIO instead of a Docker volume.# docker compose -f compose.yaml -f compose.override.yaml -f compose.s3.yaml up -dservices:minio:image: pgsty/minio:RELEASE.2026-08-04T00-00-00Zcommand: ["server", "/data", "--console-address", ":9001"]ports:- "127.0.0.1:${OUO_MINIO_PORT:-9000}:9000"- "127.0.0.1:${OUO_MINIO_CONSOLE_PORT:-9001}:9001"environment:MINIO_ROOT_USER: ${OUO_MINIO_ROOT_USER:?set OUO_MINIO_ROOT_USER in .env}MINIO_ROOT_PASSWORD: ${OUO_MINIO_ROOT_PASSWORD:?set OUO_MINIO_ROOT_PASSWORD in .env}volumes:- minio-data:/data# One-shot: creates the bucket and a user that can reach only that bucket.minio-init:image: pgsty/mc:RELEASE.2026-09-16T00-00-00Zdepends_on: [minio]environment:MINIO_ROOT_USER: ${OUO_MINIO_ROOT_USER}MINIO_ROOT_PASSWORD: ${OUO_MINIO_ROOT_PASSWORD}BUCKET: ouo-replaysRUM_API_KEY: ${OUO_RUM_API_S3_ACCESS_KEY:?set OUO_RUM_API_S3_ACCESS_KEY in .env}RUM_API_SECRET: ${OUO_RUM_API_S3_SECRET_KEY:?set OUO_RUM_API_S3_SECRET_KEY in .env}entrypoint: ["/bin/sh", "-ec"]command:- |i=0; until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; doi=$$((i+1)); [ $$i -lt 60 ] || { echo "minio not reachable"; exit 1; }; sleep 1; donemc mb --ignore-existing "local/$$BUCKET"cat > /tmp/policy.json <<POLICY{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket","s3:GetLifecycleConfiguration","s3:PutLifecycleConfiguration"],"Resource":["arn:aws:s3:::$$BUCKET"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:GetObjectTagging","s3:PutObjectTagging"],"Resource":["arn:aws:s3:::$$BUCKET/*"]}]}POLICYmc admin policy create local rum-api /tmp/policy.jsonmc admin user add local "$$RUM_API_KEY" "$$RUM_API_SECRET"mc admin policy attach local rum-api --user "$$RUM_API_KEY" || trueecho "bucket $$BUCKET and user $$RUM_API_KEY ready"rum-api:environment:STORAGE_BACKEND: s3S3_ENDPOINT: minio:9000S3_BUCKET: ouo-replaysS3_ACCESS_KEY_ID: ${OUO_RUM_API_S3_ACCESS_KEY}S3_SECRET_ACCESS_KEY: ${OUO_RUM_API_S3_SECRET_KEY}S3_INSECURE: "true"S3_FORCE_PATH_STYLE: "true"S3_LIFECYCLE_RULES: "true"depends_on:minio-init:condition: service_completed_successfullyvolumes:minio-data:The official
minio/minioimages are no longer published;pgsty/minioandpgsty/mcare the community-maintained builds of the same server and client. -
Start with all three files. Name them every time from now on, or set
COMPOSE_FILE=compose.yaml:compose.override.yaml:compose.s3.yamlin.env.Terminal window docker compose -f compose.yaml -f compose.override.yaml -f compose.s3.yaml up -dminio-initends withbucket ouo-replays and user rum-api ready, and rum-api’s readiness now checks the bucket too:Terminal window curl -s localhost:8080/readyzYou see
"storage":"ok"next tolokiandtempo.
For AWS S3 or another S3-compatible store, skip MinIO and set only rum-api’s environment: leave S3_ENDPOINT empty for AWS (it uses s3.<region>.amazonaws.com) and set S3_REGION; drop S3_INSECURE and S3_FORCE_PATH_STYLE. Leave the keys empty to use the AWS credential chain. Every key: rum-api configuration.
Replays already in the volume are not moved. They use the same layout as the bucket, so mc mirror <volume path>/replays local/ouo-replays/replays copies them over.
Back up
Section titled “Back up”| What | Where | Why |
|---|---|---|
.env, projects.htpasswd, the override files |
the stack’s folder | your secrets and projects; not in any image |
| replays | volume <folder>_rum-api-data, or the bucket |
cannot be rebuilt |
| Grafana’s database (users, your dashboards) | volume <folder>_grafana-data |
cannot be rebuilt |
| Loki, Tempo, Mimir | volumes <folder>_loki-data, _tempo-data, _mimir-data |
telemetry; back up as your retention needs require |
docker volume inspect <name> shows where a volume lives on the host.