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
| Service | Port (dev) | Role |
|---|---|---|
edge-redirect | 18080 | Serves tracking links, impressions, unilinks and deep-link files. Decides each click from an in-memory config snapshot. Publishes click events. |
ingest | 18081 | Conversion 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. |
api | 18082 | The control-plane REST API (reference), authentication, billing and every admin function. Proxies reporting requests. |
reporting | 18083 | The reporting engine over ClickHouse. Reached only through the API. |
workers | 18084 | One 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. |
| Portals | 3100 (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
| Store | Holds |
|---|---|
| PostgreSQL 16 | All 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. |
| ClickHouse | Clicks, impressions, conversions (an append-only event stream plus a current view), events, installs, postback logs, and aggregating materialised views for fast reports. |
| Redis 7 | Cap 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 JetStream | The event bus: DT_CLICKS, the workers' DT stream and the DT_MOBILE, DT_MIGRATION, DT_CONVERSIONS, DT_POSTBACK_IN streams. |
| S3-compatible storage | Creatives, report exports, invoices and the data locker (MinIO in development). |
| SMTP | System email (Mailpit in development) or a tenant's own provider. |
How a click and a conversion flow
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
- The edge and ingest never query Postgres while serving a request. They hold a per-tenant config snapshot in memory, refreshed on
config_changednotifications, NATSdt.config.changed.v1, and a periodic poll. A change made in the portal is live within seconds. - 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.
- Every event gets a ULID at the edge; downstream writers are idempotent on it.
- Responses are computed before logging. Event publication is buffered and never blocks a redirect; overflow is counted (
dt_edge_events_dropped_total) and alerted. - 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_keystable). - 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.
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)
- Infrastructure.
infra/docker-compose.ymldefines the stores (host ports 25432, 28123 and 29000, 26379, 24222, 29002, 21025 by default; override withDT_PG_PORT,DT_REDIS_PORTand friends). In production use managed or clustered equivalents. - Postgres schema. Apply the SQL in
infra/postgres/migrationsin order with golang-migrate (migrate -path infra/postgres/migrations -database "$DSN" up). Migrations are idempotent and create thedt_approle with the development passworddt_app; change that password in production and use it in the service DSNs. - ClickHouse schema.
scripts/ch-apply.shappliesinfra/clickhouse/schema/*.sqland the materialised views ininfra/clickhouse/mv; it is idempotent. - Services. Build each
services/*/cmd/*binary and run it with the environment from environment variables. The minimum for a working install: Postgres DSNs forapi,workers,edgeandingest(DT_API_DATABASE_URL,DT_WORKERS_DATABASE_URL,DT_EDGE_PG_DSN,DT_INGEST_PG_DSN), Redis and NATS addresses, ClickHouse addresses, S3 settings forapiandreporting, a sharedDT_INTERNAL_TOKEN, andDT_SECRETS_KEY. - Tracking domains. Point DNS at the edge and verify the domain: see tracking domains.
- Portals. Build the Next.js apps in
apps/and point them at the API (DT_API_URL). - 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-redirectandingestreplicas behind a load balancer; setDT_EDGE_REGIONper region. They share Redis for state. - Run several
apireplicas withDT_REDIS_URLso per-key rate limits are shared. workersconsumers 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.reportingscales 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.