Skip to content

Deployment

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Deployment

One container image, one PostgreSQL database. Nothing else is required.

The image

Published to ghcr.io/reportmate/reportmate-api on every push to main and every v* tag, after the test job passes. Tags follow the branch, the PR, the semver version and major.minor, the long commit SHA, and latest on the default branch. Built for linux/amd64.

The base is python:3.11-slim, it runs as an unprivileged user (uid 1000), and it listens on port 8000 under a single uvicorn worker. A HEALTHCHECK curls /api/v1/health every 30 seconds.

Minimum configuration

DATABASE_URL=postgresql://reportmate:<password>@db.example.com:5432/reportmate
REPORTMATE_PASSPHRASE=<fleet passphrase>

Add DB_SSL=false only if your database does not speak TLS. Everything else is in Configuration.

Compose

services:
  api:
    image: ghcr.io/reportmate/reportmate-api:latest
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://reportmate:password@db:5432/reportmate
      DB_SSL: "false"
      REPORTMATE_PASSPHRASE: changeme
      CORS_ORIGINS: https://reportmate.example.com
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: reportmate
      POSTGRES_PASSWORD: password
      POSTGRES_DB: reportmate
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro

init.sql is the deployment schema that creates devices, events and the module tables, which the API's own migrations do not; take it from schema/init.sql in selfhosted-docker-reportmate. See Data-Model.

A fuller stack, including the dashboard, is in selfhosted-docker-reportmate. Managed deployments are in terraform-azurerm-reportmate and terraform-aws-reportmate.

Migrations

Alembic runs upgrade head from the application lifespan on every start. You do not run a migration step separately, and rolling a new revision out is just deploying the new image.

Two consequences worth knowing:

  • Migration failure is logged, not fatal. The app still starts and can still serve reads. The readiness probe, not the migration, is the source of truth for whether the database is usable — so watch for [STARTUP] Migrations failed: in the logs rather than assuming a healthy container means a migrated schema.
  • Every replica runs it, and nothing coordinates replicas. The statements use IF NOT EXISTS / IF EXISTS so a repeat is a no-op, but a rolling deploy of N replicas means N attempts.

Migration 0009 swaps the partial unique index on events(device_id, module_id) for a plain one, and 0010 and 0011 create small tables; none of them rewrites existing rows.

Migration 0008 drops twelve GIN indexes. A plain DROP INDEX is a catalog change plus a file unlink — it takes an ACCESS EXCLUSIVE lock for milliseconds and needs no rebuild, so it is safe inside the startup transaction even on a large database.

Probes

Path Use it for Behaviour
/api/v1/health/live Liveness Always 200 while the process serves. Checks nothing, so a transient database outage does not get healthy replicas killed.
/api/v1/health/ready Readiness 200 when a SELECT 1 succeeds, 503 otherwise. Point the load balancer here.
/api/v1/health Public status Reports database connectivity and version, 500 when unreachable.

All three are anonymous by design — they have to work before secrets are mounted.

Behind a proxy

If the API sits behind Front Door, an ALB, or a Container Apps ingress, make sure X-Forwarded-For is set. request.client.host is then one of a handful of backend addresses carrying the entire fleet, and both the rate limiter and the ingest-failure record read the first hop of X-Forwarded-For instead. Without it every rate-limit bucket collapses and every recorded failure looks like it came from the same four machines.

Set CORS_ORIGINS to the dashboard's origin. It is empty by default, so cross-origin browser calls are refused until you configure it.

Scaling

The image runs a single uvicorn worker; scale by adding replicas, not workers. Two things are per-replica and not shared:

  • The response cache is in-process, so replicas can serve results up to their TTL apart.
  • The rate limiter counts in process memory, so the effective fleet-wide limit is roughly the configured limit times the replica count.

Size DB_POOL_MAX (default 40) against your database's max_connections divided by the replica count. The default is matched to Starlette's threadpool so a full house of workers can each hold one connection.

Metrics

GET /api/v1/metrics serves Prometheus exposition and requires a credential, so your scraper needs a read-scoped API key. It is excluded from the OpenAPI schema. Series exposed:

Metric Type Labels
reportmate_http_requests_total counter method, path, status
reportmate_http_request_duration_seconds histogram method, path
reportmate_http_requests_in_progress gauge none

path is the matched route template, never the raw path, so a fleet of serial numbers cannot explode label cardinality. Unmatched requests are labelled unmatched.

Real-time events

Optional. Set EVENTS_CONNECTION to an Azure Web PubSub connection string and the API broadcasts each stored event to the events hub, with GET /api/v1/negotiate issuing 60-minute client tokens. Without it the dashboard falls back to polling; nothing errors.

Clone this wiki locally