Skip to content
Docs

Self-hosting

Architecture and deployment

The services, data stores and event flow behind Metrixed, the hot-path rules, delivery guarantees and how to run the stack.

Metrixed is five Go services, three Next.js portals and a handful of standard data stores. Everything is stateless except the stores, so any service can be scaled out or restarted at will.

Services

ServicePort (dev)Role
edge-redirect18080Serves tracking links, impressions, unilinks and deep-link files. Decides each click from an in-memory config snapshot. Publishes click events.
ingest18081Conversion endpoints (/p, /postback, pixels, JS, S2S events), the web and mobile SDK endpoints, SKAN postbacks, integrations (Shopify, BigCommerce), dynamic number insertion for call tracking. Publishes conversion events.
api18082The control-plane REST API (reference), authentication, billing and every admin function. Proxies reporting requests.
reporting18083The reporting engine over ClickHouse. Reached only through the API.
workers18084One binary running the stream consumers and schedulers: ClickHouse writers, postbacks, webhooks, ledger, notifier, automation, fraud batch, data locker, domain checks, mobile and call jobs, observability and the synthetic monitor.
Portals3100 (admin), 3101 (partner), 3102 (advertiser)Admin, partner and advertiser Next.js apps. Partner and advertiser portals can be white-labelled on custom domains.

Each service exposes /healthz (process is up), /readyz (dependencies reachable) and /metrics (Prometheus).

Data stores

StoreHolds
PostgreSQL 16All configuration entities with row-level security per tenant, the double-entry ledger, statements and invoices, audit log, mobile apps and devices, fraud cases. Services connect as the non-superuser role dt_app, which is what makes row-level security apply.
ClickHouseClicks, impressions, conversions (an append-only event stream plus a current view), events, installs, postback logs, and aggregating materialised views for fast reports.
Redis 7Cap counters, dedupe keys, sessions, rate limits, click cache, SmartLink counters, pending deep-link payloads. Every call has a short timeout and a degrade policy.
NATS JetStreamThe event bus: DT_CLICKS, the workers' DT stream and the DT_MOBILE, DT_MIGRATION, DT_CONVERSIONS, DT_POSTBACK_IN streams.
S3-compatible storageCreatives, report exports, invoices and the data locker (MinIO in development).
SMTPSystem email (Mailpit in development) or a tenant's own provider.

How a click and a conversion flow

text
click -> edge-redirect -> 302 to advertiser
                      \-> NATS dt.click.created.v1 -> workers chwriter -> ClickHouse clicks

postback -> ingest (match, validate, price, status) -> NATS dt.conversion.created.v1
   -> workers: ClickHouse writer, partner postbacks, webhooks, ledger, firehose, notifier
   -> status changes: dt.conversion.status_changed.v1 (restates the original day)

The event schemas are in events and webhooks.

Hot-path rules

  1. The edge and ingest never query Postgres while serving a request. They hold a per-tenant config snapshot in memory, refreshed on config_changed notifications, NATS dt.config.changed.v1, and a periodic poll. A change made in the portal is live within seconds.
  2. Redis is the only synchronous dependency on the hot path. If it is slow or down, caps are treated as unknown (allow), dedupe as unique, and the service keeps serving.
  3. Every event gets a ULID at the edge; downstream writers are idempotent on it.
  4. Responses are computed before logging. Event publication is buffered and never blocks a redirect; overflow is counted (dt_edge_events_dropped_total) and alerted.
  5. Handler latency budgets: redirect p99 under 8 ms, conversion ingest p99 under 25 ms, report API p95 under 800 ms. Alerts exist for each.

Delivery guarantees

  • Clicks reach ClickHouse at least once and are de-duplicated by id.
  • Conversion creation is exactly-once per de-duplication key (Redis claim plus the Postgres conversion_keys table).
  • Outbound postbacks and webhooks are at least once with retries at 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then a visible dead letter.
  • Statuses never overwrite: each change is a new version and reports restate the conversion's original day.

Running the stack

The repository ships a development stack that is also the shape of a small production install.

bash
make up          # compose: Postgres, ClickHouse, Redis, NATS, MinIO, Mailpit; then builds and starts the five Go services
make down        # stop services and remove compose volumes (development only)
  1. Infrastructure. infra/docker-compose.yml defines the stores (host ports 25432, 28123 and 29000, 26379, 24222, 29002, 21025 by default; override with DT_PG_PORT, DT_REDIS_PORT and friends). In production use managed or clustered equivalents.
  2. Postgres schema. Apply the SQL in infra/postgres/migrations in order with golang-migrate (migrate -path infra/postgres/migrations -database "$DSN" up). Migrations are idempotent and create the dt_app role with the development password dt_app; change that password in production and use it in the service DSNs.
  3. ClickHouse schema. scripts/ch-apply.sh applies infra/clickhouse/schema/*.sql and the materialised views in infra/clickhouse/mv; it is idempotent.
  4. Services. Build each services/*/cmd/* binary and run it with the environment from environment variables. The minimum for a working install: Postgres DSNs for api, workers, edge and ingest (DT_API_DATABASE_URL, DT_WORKERS_DATABASE_URL, DT_EDGE_PG_DSN, DT_INGEST_PG_DSN), Redis and NATS addresses, ClickHouse addresses, S3 settings for api and reporting, a shared DT_INTERNAL_TOKEN, and DT_SECRETS_KEY.
  5. Tracking domains. Point DNS at the edge and verify the domain: see tracking domains.
  6. Portals. Build the Next.js apps in apps/ and point them at the API (DT_API_URL).
  7. Observability. Start the optional stack described in observability.

scripts/dev-services.sh start|stop|restart [service] builds and runs the five Go services as background processes for a single machine.

Scaling notes

  • Run several edge-redirect and ingest replicas behind a load balancer; set DT_EDGE_REGION per region. They share Redis for state.
  • Run several api replicas with DT_REDIS_URL so per-key rate limits are shared.
  • workers consumers are JetStream durables with unique names. Some worker state is per process (for example the firehose replay ring), so test a multi-replica layout before relying on it.
  • reporting scales with ClickHouse; keep it behind the API.

Not included

There is no Kubernetes or Terraform packaging in this repository yet, and no automated backup tooling: see backups for what to protect and how.