Case study

CivicGrid

CivicGrid is an application programming interface (API) and website I built and run on my own. This case study covers why I built it, how it works, and what I learned.

A civic-data API for U.S. local government, starting with verified city leadership for 3,064 cities.

The problem

Basic facts about U.S. cities and towns, like who the mayor is, are spread across thousands of official websites, and the directories that collect them go out of date. I built CivicGrid to test whether that information could be kept live, dynamic, and correct, instead of a snapshot that slowly goes stale. It's a personal project, not a commercial product with paying users.

My role

Solo builder: design, infrastructure, backend, frontend, and data pipeline.

Architecture

CivicGrid's website runs on Vercel and gets its data from the CivicGrid API, the same API that customers use. Requests to the API pass through Cloudflare, which handles the domain name system (DNS) and the public security certificate. Next they reach a load balancer in front of a DigitalOcean Kubernetes cluster, where Envoy Gateway routes them to the API. The API is written in Python with FastAPI. It checks each request's API key, then reads from a Supabase Postgres database that lives outside the cluster. The cluster's setup is code: Terraform creates the cluster, the DNS record and the secrets, then installs Argo CD. From then on, Argo CD keeps the cluster matching a Git repository, so every change goes through Git and manual edits are reverted automatically. Prometheus and Grafana monitor the API, and the API adds pods automatically under load. A separate verifier runs on my homelab. It reads each city's official mayor page with qwen2.5:7b, a language model running locally through Ollama, and sends any difference to a review queue. No mayor's name changes until I approve it.

CivicGrid architectureRequest path, solid lines: Visitor to Vercel; Vercel to Cloudflare, calling the API with an API key; API customer to Cloudflare; Cloudflare to the DigitalOcean load balancer; Load balancer to Envoy Gateway; Envoy Gateway to the HTTPRoute; HTTPRoute to the API Service; API Service to the FastAPI pods; FastAPI pods to Supabase Postgres; Visitor to Supabase Auth, to sign in; Webhook delivery job to Supabase Postgres; Webhook delivery job to webhook subscribers; Prometheus scrapes metrics from the FastAPI pods. Verification path, dotted lines: Verifier reads official city websites; Verifier writes proposals to the review queue in Supabase Postgres; Admin review page on Vercel approves proposals through the API. Deployment path, dashed lines: GitHub Actions builds the image and pushes it to the container registry; FastAPI pods pull the image from the container registry; Terraform Cloud creates the Cloudflare DNS record; Terraform Cloud creates the cluster and its secrets; Terraform Cloud installs Argo CD; Argo CD watches the GitOps repository; Argo CD syncs the cluster to match Git.UsersEdgeKubernetes cluster (DigitalOcean)SupabaseDeploymentHomelabsigns inscrapes metricsreadswrites proposalsadmin approves proposalspulls imageprovisionsinstallssyncsVisitor(browser)API customerVercelNext.js websiteCloudflareDNS and proxyDigitalOceanload balancerEnvoyGatewayHTTPRouteAPI ServiceFastAPI pods1 to 4Prometheusand GrafanaWebhookdelivery jobSupabaseAuthSupabasePostgresWebhooksubscribersTerraformCloudGitHubActionsContainerregistryArgo CDGitOpsrepositoryVerifierqwen2.5:7b via OllamaOfficial citywebsites
Request path Verification path Deployment path
Read the diagram as a list

Request path

  1. Visitor to Vercel
  2. Vercel to Cloudflare, calling the API with an API key
  3. API customer to Cloudflare
  4. Cloudflare to the DigitalOcean load balancer
  5. Load balancer to Envoy Gateway
  6. Envoy Gateway to the HTTPRoute
  7. HTTPRoute to the API Service
  8. API Service to the FastAPI pods
  9. FastAPI pods to Supabase Postgres
  10. Visitor to Supabase Auth, to sign in
  11. Webhook delivery job to Supabase Postgres
  12. Webhook delivery job to webhook subscribers
  13. Prometheus scrapes metrics from the FastAPI pods

Verification path

  1. Verifier reads official city websites
  2. Verifier writes proposals to the review queue in Supabase Postgres
  3. Admin review page on Vercel approves proposals through the API

Deployment path

  1. GitHub Actions builds the image and pushes it to the container registry
  2. FastAPI pods pull the image from the container registry
  3. Terraform Cloud creates the Cloudflare DNS record
  4. Terraform Cloud creates the cluster and its secrets
  5. Terraform Cloud installs Argo CD
  6. Argo CD watches the GitOps repository
  7. Argo CD syncs the cluster to match Git

Technology

  • Python and FastAPI
  • Supabase Postgres
  • Next.js on Vercel
  • DigitalOcean Kubernetes
  • Terraform Cloud
  • Argo CD (GitOps, app-of-apps pattern)
  • Helm
  • Envoy Gateway and cert-manager
  • Cloudflare DNS and TLS
  • GitHub Actions
  • Prometheus and Grafana (kube-prometheus-stack)

Key decisions

  • Self-hosted a local model for the verifier, to keep costs at zero and keep the data on my own machine.
  • Ran Kubernetes on DigitalOcean because it was cheaper than the big cloud providers for a personal project.
  • Used Argo CD to learn GitOps hands-on.
  • The verifier hashes each page's text and skips pages that haven't changed since the last run, so the model only reruns when something is different.

The challenge

CivicGrid's first verifier asked a hosted model (Claude Haiku with web search) for each city's mayor, and trusted the search model's answer and its self-reported confidence. It was fooled by an out-of-date search snapshot of a page that had changed (Bullhead City, AZ), a fabricated source (Wausau, WI), and a same-named city in another state (Lancaster, Ohio, mixed up with Lancaster, Pennsylvania). I replaced it with a rule-based pipeline on my homelab: code confirms each page is the city's validated official page, still names the right state, and isn't a news or press page, a local model at temperature 0 only extracts the mayor's name, and a guard rejects any name that doesn't appear on the page. Those three failures are now permanent test cases in every evaluation.

Results

  • Covers 3,064 U.S. cities.
  • City pages served from cache load in about 0.1 to 0.15 seconds; an uncached request took about 3.3 seconds (measured October 2026).
  • In an October 2026 evaluation of 53 test cases (52 cities: the 50 most recently verified plus 3 known hard cases), the local model extracted the correct mayor 97.9% of the time when it answered (47 of 48). It declined to answer for 2, the guard never had to block an invented name, and 3 requests were refused by 2 city websites.

Lessons

  • Don't trust a model's answer just because it sounds confident. Build checks around it. CivicGrid has a review page that shows each proposed change with its source page, so I approve it before it reaches the database.
  • Keeping data correct is harder than collecting it. A mayor can be voted out after their record is verified, so correct data needs a way to notice changes, not just a one-time check.
  • Build infrastructure you can easily spin up. I wanted to be able to recreate CivicGrid's infrastructure whenever I needed to, so it's defined in code with Terraform and kept in sync through GitOps.

Next steps

  • Finish verifying the remaining city records with the rebuilt pipeline.
  • Build alerts that tell me when a city elects a new mayor, so records get rechecked when they change.
  • Add local news for each city and town, so each page shows what's happening there, not just who's in charge.