Skip to main content

Self-host our MIT error tracker with one Docker Compose file

Postgres, API and dashboard from selfhost/ in v1.22.8: three secrets, docker compose up -d, a TLS proxy, and the numbers we measured on one test box

Updated 7 min readMarkdown

Telemetry Tracker is our open-source (MIT) error tracker for side projects and small teams: grouped errors, events, sessions and Web Vitals, with a Fastify API, a Next.js dashboard and PostgreSQL. Since v1.22.8, the repo has a selfhost/ folder that starts all three with one docker compose up -d.

This tutorial walks through it end to end: what you need, the three secrets, what actually happens when the stack starts, how to put TLS in front, how to point your apps at it, and what stays your job afterwards. We also include the numbers we measured, with the one caveat that matters: they were measured on one test box (8 vCPU / 15.6 GiB), not a guarantee.

What you need

  • Docker with Compose v2.20+
  • About 5 GB of free disk for the build (images plus build cache)
  • No Node or pnpm on the host. Everything builds inside Docker.

Images are built locally from the repo. We don't publish prebuilt images yet, so the first up includes a build.

Step 1: Clone and enter selfhost/

git clone https://github.com/Telemetry-Tracker/telemetry-tracker.git
cd telemetry-tracker/selfhost
cp .env.example .env

Step 2: Set the three required secrets

No secrets ship with the stack, and Compose refuses to start until these three are set in selfhost/.env:

POSTGRES_PASSWORD=
CHECKIN_ENCRYPTION_KEY=
DASHBOARD_API_SECRET=

Generate each one separately:

openssl rand -hex 32

If you forget one, docker compose up stops with a message like set POSTGRES_PASSWORD in selfhost/.env instead of starting with an empty value.

CHECKIN_ENCRYPTION_KEY deserves a note: check-in secrets are encrypted with it, so keep it together with your database backups. A backup without the key can't decrypt them.

The same .env file has a few more settings you'll touch later:

DASHBOARD_URL=http://localhost:3000
API_PUBLIC_URL=http://localhost:3001
BIND_ADDRESS=127.0.0.1
DASHBOARD_PORT=3000
API_PORT=3001
TELEMETRY_ALLOW_REGISTRATION=false

Step 3: Start it

docker compose up -d

Here's what happens on start:

  • Startup is ordered by healthchecks: Postgres (postgres:16-alpine) first, then the API, then the dashboard.
  • Migrations run on every start. The API applies database migrations before it begins listening, so there's no separate migrate step on first run or after an upgrade.
  • Non-root: the API and dashboard run as the unprivileged node user.
  • Postgres isn't published. It has no host port and is only reachable on the internal Compose network.
  • Localhost only by default. The API and dashboard ports are published on 127.0.0.1, not on all interfaces.

Once the services are healthy, open http://localhost:3000 and register. The first account can always sign up; after that, signup is invite-only unless you set TELEMETRY_ALLOW_REGISTRATION=true. Then create an organization, a project and an API key.

Email is optional. Invites, password resets and notification emails go through Resend if you set RESEND_API_KEY and TELEMETRY_EMAIL_FROM; you don't need either to ingest errors.

Step 4: Put a TLS reverse proxy in front

Because both services bind to 127.0.0.1, nothing is reachable from outside the box yet. That's deliberate. You have two options:

  1. Recommended: run a reverse proxy you already trust (Caddy, Nginx, Traefik) on the same host and terminate TLS there. For example, forward one hostname to the dashboard on 127.0.0.1:3000 and another to the API on 127.0.0.1:3001.
  2. Set BIND_ADDRESS=0.0.0.0 to listen on all interfaces. That's plain HTTP, so only do it on a network you control.

Then set the public URLs in .env to your real HTTPS addresses (no trailing slash): DASHBOARD_URL for the dashboard and API_PUBLIC_URL for the API your SDKs will send to. The API also uses DASHBOARD_URL for its allowed CORS origin.

One gotcha: DASHBOARD_URL is baked into the dashboard image at build time. After changing it, rebuild:

docker compose up -d --build

Step 5: Point your SDKs at your API

Self-hosting uses the same npm SDKs as our hosted cloud. You only change ingestUrl to your API base URL (no trailing slash) and pass a project API key. For Node, from our docs:

import { init, trackEvent, trackError } from "@telemetry-tracker/node";

init({
  ingestUrl: process.env.TELEMETRY_INGEST_URL || "https://your-api.example.com",
  app: "my-api",
  apiKey: process.env.TELEMETRY_API_KEY,
  platform: "node",
});

Set TELEMETRY_INGEST_URL to your API_PUBLIC_URL (for a local test, http://localhost:3001). The Next.js, core and React Native SDKs take the same ingestUrl and apiKey options; the framework pages in the docs show where init goes. Ingest is authenticated with project API keys, so keep it that way in production.

The Node SDK batches events every few seconds. In our measured run, a Node SDK event showed up on the Events page 7.5 s after the SDK started.

Step 6 (optional): Scheduled alerts, check-ins and retention

Scheduled alert rules (heartbeat / no-events) and cron check-ins need the evaluator, which ships in the same Compose file behind a profile:

docker compose --profile jobs up -d

Retention isn't automatic in the Compose stack. Run the retention job nightly from the host's cron:

docker compose exec api node dist/jobs/run-retention.js

Upgrading

git pull && docker compose up -d --build

Migrations apply on start, so the new API migrates the database before it starts listening. Read the release notes before you pull, especially for security releases.

Backups

The Compose stack doesn't include a backup job. Your telemetry lives in the Postgres data volume (pgdata), so back it up with whatever Postgres tooling you already use, test a restore, and store CHECKIN_ENCRYPTION_KEY alongside it.

What we measured

On 2026-10-19 we measured the selfhost/ stack (v1.22.7 plus the selfhost/ change) on one test box: 8 vCPU / 15.6 GiB, Debian 13, Docker 26.1.5. The steps were scripted, so time spent reading docs isn't included. These numbers come from that one setup and are not a guarantee.

Result
Cold start (no images, no build cache) docker compose up -d took 221 s to pull, build, start and migrate; all services healthy 232 s after up, about 4 minutes from clone
Cached (images already built, empty database) healthy 12 s after up
Idle memory (docker stats, 30 samples, 5 min after last activity) API 77 MiB, dashboard 119 MiB, Postgres 65 MiB: about 261 MiB total
Idle CPU about 0.4% of one core on average
Image sizes API 575 MB, dashboard 357 MB, postgres:16-alpine 294 MB
Build cache after a cold build 3.6 GB (docker builder prune reclaims it)

Most of the cold time is building the two images. An earlier idle sample, with more frequent healthchecks, came to 391 MiB, so your number will depend on your setup. The full method and tables are in DEPLOYMENT.md in the repo.

What stays your job

Self-hosting means you operate it. You're responsible for:

  • Uptime: monitoring the box and the containers, and restarting what falls over
  • Backups: the Postgres volume and the check-in encryption key, plus restore tests
  • TLS: the reverse proxy and its certificates
  • Upgrades: pulling releases, especially security fixes, and rebuilding

Other honest limits:

  • Images are built locally; there are no prebuilt images yet.
  • Hosted plan limits apply only on telemetry-tracker.com, not to your instance.
  • It's an error tracker with events, sessions and Web Vitals, not a full observability suite: no tracing and no session replay.

If you'd rather not run any of this, our hosted cloud has a free plan with no credit card, and it uses the same SDKs, so moving between hosted and self-hosted mostly means changing ingestUrl and the API key.

If something in these steps doesn't match what you see, open an issue or a discussion on the repo. We'd rather fix the docs than have you guess.

No comments yet. Be the first to comment.