Deploy on a single VM
One Docker host, Caddy in front for TLS, the one-file stack behind it. Suitable for a pilot or a small production app. This page builds on Deploy with Docker Compose: do that page’s steps on the server first, then add the three files here.
What you need
Section titled “What you need”- A Linux VM with Docker Engine and the Compose plugin 2.24 or later. Tested on Ubuntu 24.04 (
apt install docker.io docker-compose-v2). - 4 GB of RAM and 20 GB of disk to start; disk grows with your traffic up to the retention you set.
- Two DNS names pointing at the VM: one for Grafana (
ouo.example.com), one for the collector (otel.ouo.example.com). - Ports 22, 80 and 443 open. 80 is needed for Let’s Encrypt.
- Pull access to the ouo11y images (early access). Log in with
sudo: systemd pulls as root and does not see a login made as your own user.
Install
Section titled “Install”-
Create the folder and follow Run the stack steps 2 and 3 in it (download
compose.yaml, log in to the registry), then Your own projects and secrets steps 1 to 4. Do not start the stack yet.Terminal window sudo mkdir -p /opt/ouo11y && sudo chown "$USER" /opt/ouo11y && cd /opt/ouo11y -
Add the hostnames and the certificate source to
.env.OUO_TLSis the email for your Let’s Encrypt account, orinternalfor Caddy’s own CA on a network without public DNS.OUO_DOMAIN=ouo.example.comOUO_OTEL_DOMAIN=otel.ouo.example.comOUO_TLS=you@example.comOUO_ALLOWED_ORIGINS=https://shop.example.comCOMPOSE_FILE=compose.yaml:compose.override.yaml:compose.vm.yamlCOMPOSE_FILEmakes everydocker composecommand in this folder use all three files. Add:compose.s3.yamlif you keep replays in MinIO. -
Save this as
Caddyfile:{admin off}# Grafana: people log in here.{$OUO_DOMAIN} {tls {$OUO_TLS:internal}encode zstd gzipreverse_proxy grafana:3000}# The collector: apps send here. Caddy only terminates TLS; the collector checks# the project credential, answers CORS and rate-limits.{$OUO_OTEL_DOMAIN} {tls {$OUO_TLS:internal}@replay path /v1/replay/*handle @replay {reverse_proxy otel-collector:4319}@sentry path /api/*handle @sentry {reverse_proxy otel-collector:4320}handle {reverse_proxy otel-collector:4318}} -
Save this as
compose.vm.yaml:# Server overrides: Caddy terminates TLS on 80/443 and is the only public port;# every other port is bound to 127.0.0.1; containers come back after a reboot.services:caddy:image: caddy:2.11.7restart: unless-stoppedports:- "80:80"- "443:443"- "443:443/udp"environment:OUO_DOMAIN: ${OUO_DOMAIN:?set OUO_DOMAIN in .env}OUO_OTEL_DOMAIN: ${OUO_OTEL_DOMAIN:?set OUO_OTEL_DOMAIN in .env}OUO_TLS: ${OUO_TLS:-internal}volumes:- ./Caddyfile:/etc/caddy/Caddyfile:ro- caddy-data:/data- caddy-config:/configdepends_on: [grafana, otel-collector]grafana:restart: unless-stoppedports: !override- "127.0.0.1:${OUO_GRAFANA_PORT:-3000}:3000"environment:GF_SERVER_ROOT_URL: https://${OUO_DOMAIN}GF_SECURITY_COOKIE_SECURE: "true"otel-collector:restart: unless-stoppedports: !override- "127.0.0.1:${OUO_OTLP_GRPC_PORT:-4317}:4317"- "127.0.0.1:${OUO_OTLP_HTTP_PORT:-4318}:4318"- "127.0.0.1:${OUO_REPLAY_PORT:-4319}:4319"- "127.0.0.1:${OUO_SENTRY_PORT:-4320}:4320"- "127.0.0.1:${OUO_COLLECTOR_HEALTH_PORT:-13133}:13133"environment:# Every request arrives from Caddy: rate-limit clients by the X-Forwarded-For hop it sets.OUO_RATELIMIT_CLIENT_KEY: x_forwarded_forrum-api:restart: unless-stoppedenvironment:GRAFANA_URL: https://${OUO_DOMAIN}loki:restart: unless-stoppedtempo:restart: unless-stoppedmimir:restart: unless-stoppedvolumes:caddy-data:caddy-config:Caddy replaces any
X-Forwarded-Fora client sends, so the collector’s per-client rate limit cannot be dodged by sending a fake one. -
Install the systemd unit, so the stack starts at boot and stops cleanly:
Terminal window printf '[Unit]\nDescription=ouo11y stack (docker compose)\nRequires=docker.service\nAfter=docker.service network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nWorkingDirectory=/opt/ouo11y\nExecStart=/usr/bin/docker compose up -d --remove-orphans\nExecStop=/usr/bin/docker compose down\nTimeoutStartSec=0\n\n[Install]\nWantedBy=multi-user.target\n' | sudo tee /etc/systemd/system/ouo11y.service -
Start it:
Terminal window sudo systemctl daemon-reload && sudo systemctl enable --now ouo11ysudo systemctl status ouo11yshowsactive (exited):docker compose up -dsucceeded. The first start pulls the images and takes a few minutes.
Check it from outside
Section titled “Check it from outside”From your laptop, the collector answers 401 without a credential, through Caddy and with a valid certificate:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://otel.ouo.example.com/v1/tracesWith the project credential, 200:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://otel.ouo.example.com/v1/traces -u "web-store:<token>" -H 'Content-Type: application/json' -d '{"resourceSpans":[]}'Grafana is at https://ouo.example.com, as admin with the password in .env. Every other port is closed from outside:
nmap -p 3000,3100,3200,4318,4319,4320,8080,9009 ouo.example.comThey show closed or filtered.
Then open 22, 80 and 443 in ufw as well, so the host’s own services stay closed:
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp && sudo ufw enablePoint an app at it
Section titled “Point an app at it”initRUM({ endpoint: "https://otel.ouo.example.com", // replay goes to the same host, /v1/replay/chunks projectId: "web-store", projectToken: "pt_…", // the content of web-store.token service: { name: "web-store", version: "1.4.2", environment: "production" },});A backend with any OpenTelemetry SDK:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.ouo.example.comexport OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20$(printf 'web-store:%s' "$(cat web-store.token)" | base64)"Without public DNS
Section titled “Without public DNS”With OUO_TLS=internal, Caddy signs certificates with its own CA. Browsers warn until you trust its root, and SDKs refuse it the same way. Copy the root out of the container and install it where needed:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./ouo11y-root.crtcurl --cacert ouo11y-root.crt … then verifies. For a name that resolves to a private address without editing hosts files, use a wildcard DNS service such as nip.io: OUO_DOMAIN=ouo.192-168-1-20.nip.io.
Operate
Section titled “Operate”| Task | Command, in /opt/ouo11y |
|---|---|
| status | docker compose ps |
| logs | docker compose logs -f caddy otel-collector rum-api |
apply a change to .env |
sudo systemctl restart ouo11y |
| add or rotate a project | edit projects.htpasswd, then docker compose restart otel-collector (Project tokens) |
| upgrade | Upgrading |
| stop, keep the data | sudo systemctl stop ouo11y |
Where the data lives
Section titled “Where the data lives”| Data | Location |
|---|---|
| traces, events, metrics | volumes ouo11y_tempo-data, ouo11y_loki-data, ouo11y_mimir-data |
| replays | volume ouo11y_rum-api-data, or your bucket |
| Grafana users and dashboards | volume ouo11y_grafana-data |
| certificates, the ACME account, the internal CA | volume ouo11y_caddy-data; losing it only means new certificates |
| configuration and secrets | /opt/ouo11y: .env, projects.htpasswd, the compose files |
Back up /opt/ouo11y, the replays and Grafana’s volume. Disk grows with traffic up to your retention: watch df -h /var/lib/docker.