AS.

Nullify

Incident management that turns an alert storm into one incident, and won’t let it close until someone writes down why.

  • Deployed to EC2 from tag v2.0.0
  • FastAPI · PostgreSQL · Redis

View the repo Skip to the storm

Nullify’s incident list: six incidents from P0 to P3, RDBMS_PRIMARY open as P0 Critical with 214 linked signals

Screens recreated from the app’s source with sample incidents.

One incident, start to finish.

  1. It opens itself.

    The first signal for a component opens an incident. The component’s type sets the priority and starts the SLA clock: a database is P0, 15 minutes.

    Nullify incidents: RDBMS_PRIMARY open as P0 Critical, SLA breached, 214 linked signals, a Start investigating button
  2. Someone owns it.

    Assign it, comment, move it to Investigating. Every change is written to the audit history in the same transaction.

    API_GATEWAY investigating, owned by aswin, with a resolution note being written and the timeline of comments below
  3. Resolved isn’t closed.

    Close stays disabled until the root cause analysis is on file: when the impact started and ended, the cause, the fix, and how to stop it happening again.

    SQS_QUEUE_01 resolved, the Close incident button disabled with “Submit the RCA to close”, and the root cause analysis form
  4. And the team learns.

    Filing the RCA records the MTTR, from first signal to RCA. Analytics tracks it by component, beside the SLA breach rate.

    Nullify analytics: open incidents by priority, SLA breach rate, average MTTR, signal volume, and MTTR by component

On a real server.

Tagged v2.0.0 on 9 October 2026 and deployed to EC2 by the pipeline further down. This is the dark theme, signed in, with test signals.

The deployed Nullify in its dark theme: KAFKA_BROKER_01 open as P1 High, SLA due in 59 minutes, 9 linked CONNECTION_REFUSED signals
The deployed app on 9 October 2026: nine test CONNECTION_REFUSED signals for KAFKA_BROKER_01 became one P1 incident with a one-hour SLA.

Orchid, light and dark.

Every colour is read from the app’s orchid.css: one hue in two themes. Severity always carries its label, never colour alone.

Turn alert storms into one clear incident.

Switzer · the whole interface

RDBMS_PRIMARY_01 · SLA due in 14m 32s

A mono stack · IDs, timers, signal names

Light

  • background#FBF9FC
  • surface#FFFFFF
  • border#E3E0E5
  • muted#676169
  • accent#8E32A7
  • text#1B161D

Dark

  • background#0F0C11
  • surface#19141A
  • border#2C262E
  • muted#A9A2AB
  • accent#CE81E5
  • text#F3F1F4
  • P0 Critical
  • P1 High
  • P2 Medium
  • P3 Low
  • Open
  • Investigating
  • Resolved
  • Closed

Under the hood.

It started from an assignment. In September I rebuilt the core so Postgres, not memory, decides what counts as one incident, and tested it against a real database: 328 tests on the backend, 531 on the frontend.

Start a storm.

A simulation of the two rules at the core, with the app’s own priorities, channels, SLAs and refusal message.

Component

Signals arriving0 / 110

No incidentAll quiet

-

Linked signals
-
SLA
-
Notified
-
RCA
-
  1. OPEN
  2. INVESTIGATING
  3. RESOLVED
  4. CLOSED

All quiet. Pick a component and start a storm.

In the real thing, four worker processes race for the same component. A unique index on open incidents lets only one exist, and the test suite fires 100 concurrent signals and expects exactly one incident.

Measured, not promised.

The first version’s README claimed 10,000 signals a second. Nothing had measured it. When I did, every signal took its own transaction and queued for the same incident row.

about 525 signals/s

One transaction per signal

about 17,400 signals/s

Batched: one transaction per component per batch, up to 200 signals
bench_ingest.py on one laptop under Docker Compose, 7 October 2026: four processes writing to the same incident, the worst case. The database write path only, not HTTP; signal counts matched exactly.

The parts.

Only Caddy faces the internet. The database, Redis and the API have no public port, and Grafana is reached through an SSH tunnel.

One EC2 instance running Docker Compose

Edge

  • CaddyLet’s Encrypt TLS, HSTS. The only public port
  • nginxserves the React app with a strict CSP

App

  • FastAPI4 uvicorn workers, REST and WebSocket

Data

  • PostgreSQL 16source of truth and audit history
  • Redis 7cache and rate limits

Out

  • Dashboardslive over WebSocket

Ops

  • GitHub Actionstag → images → SSM runs deploy.sh
  • Prometheus, Grafana127.0.0.1 only, 11 alert rules

Built by Terraform: EC2, Elastic IP, security group, GitHub OIDC role

Tag it, ship it, watch it.

A version tag runs the pipeline, and nothing in CI holds a cloud key. On the server, Prometheus watches the app with eleven alert rules.

  1. Tag v*Pushing to main runs nothing. A version tag is the release.
  2. Backendruff, then pytest against a real Postgres. Under 80% coverage fails.
  3. Frontendeslint, vitest, a production build.
  4. Securitygitleaks over all history, pip-audit, npm audit, bandit, zizmor, terraform validate, promtool.
  5. ImagesBuilt for amd64, Trivy fails any fixable high or critical, tagged with the commit, never latest.
  6. ApprovalThe production environment waits for a click.
  7. DeployOIDC for a short-lived role, then SSM runs deploy.sh. No SSH, no stored keys.
  8. HealthHTTPS /health from outside. A failed pull, migration or check rolls back.
GitHub Actions graph for ci.yml: backend, frontend and security checks passed, then Docker images, then Deploy to EC2
The real run for v2.0.0: three checks in parallel, the images, then the deploy to EC2.

The API exports its own metrics: signals received, rejected and processed, queue depth, open incidents, state changes, database retries and failed notifications, summed across all four workers.

  • NullifyApiDown
  • PostgresDown
  • RedisDown
  • IngestQueueFilling
  • SignalsDropped
  • SignalProcessingFailing
  • IngestBlockedOnDatabase
  • NotificationsFailing
  • DbRetriesSpiking
  • HighErrorRate
  • SlowRequests
Grafana's Nullify overview dashboard: API up, requests per second, 5xx share, p95 latency by route, signals per second, worker queue depth, open incidents by priority
The provisioned dashboard during a test on my laptop, light traffic. Read it as the setup, not a benchmark.

What broke.

  1. 28 Sep 2026 · Local

    Two SREs moved the same incident, and both won.

    A status change read the row, checked the move was legal, then wrote by id. Two people at once both passed the check: five requests, five successes. Now the write is compare-and-set, an UPDATE that only lands if the status is still the one it checked, and the loser gets a 409. The first race test passed even on the broken code because its requests never overlapped; it now holds a row lock to force the race.

    The commit, 7b8b917
  2. 1 Oct 2026 · Local

    Eight signals, and the live feed stayed empty.

    The API runs four worker processes. The request that stored a signal and the browser’s WebSocket usually sat in different ones, and each worker only told its own sockets. Now every worker publishes events to a Redis channel and every worker passes them on, so any open dashboard sees everything.

    The commit, b14d46b
  3. 8 Oct 2026 · Local

    A Postgres outage sent 1,878 signals nowhere.

    I stopped Postgres under load. Compose drops a stopped container’s DNS name, so the driver raised a lookup error the code treated as permanent: 1,878 of 2,000 signals went to the raw log with no incident, and the API answered 500. Now network errors count as transient, a circuit breaker holds signals and answers 503 with Retry-After, and a replay command recovered 1,875 of the lost ones. The rerun stored 1,500 of 1,500 through a 30-second outage.

    The commit, e3d3a32
  4. 9 Oct 2026 · CI

    The scanners said all clear.

    Gitleaks ran in a container, tripped git’s dubious-ownership check, scanned 0 commits and still printed “no leaks found”. It now scans every commit. Then Trivy found 44 fixable high and critical CVEs in the nginx base image; moving to 1.30 with an apk upgrade took it to 0.

    The commit, 0129c50

What I’d do next.

  • Automated Postgres backups. Today the database lives on the instance’s own disk.
  • Alertmanager, so the eleven alert rules reach a person instead of waiting in Prometheus.
  • A second instance. The app already scales sideways (stateless workers, Redis fan-out, the one-incident rule in the database); the infrastructure doesn’t yet.
  • A durable ingest queue. A signal that was accepted but not yet written is lost if a process crashes hard.
  • Terraform state in S3 before a second person touches the infrastructure.

Tech specs.

Frontend
React 19, Vite, HeroUI v3, Tailwind CSS v4, served by nginx
API
FastAPI on uvicorn (4 workers), Pydantic v2, WebSocket
Data
PostgreSQL 16 (SQLAlchemy 2 async, asyncpg, Alembic), Redis 7, a JSONL lake
Edge
Caddy with Let’s Encrypt and HSTS, nginx with a strict Content Security Policy
Infrastructure
Terraform: EC2 c7i-flex.large, Elastic IP, security group, GitHub OIDC role, SSM
Delivery
GitHub Actions, ruff, pytest, vitest, gitleaks, pip-audit, npm audit, bandit, zizmor, Trivy, Dependabot
Observability
Prometheus, Grafana, Postgres and Redis exporters, 11 alert rules
Tests
328 backend against real Postgres (80% coverage gate), 531 frontend