ideas.
June 10, 2026 3 min read self-hostedsolo-devfinancedata

Self-hosted revenue dashboard for indie SaaS

A Docker-deployable dashboard that pulls MRR, churn, and LTV from Stripe, Gumroad, and Paddle into one view — no third-party analytics subscription needed.

The idea

A single-page dashboard you deploy on your own server that connects to your payment processors via read-only API keys and computes the metrics that matter for an indie SaaS: monthly recurring revenue, active subscriptions, churn rate, and customer lifetime value. You get one URL to bookmark instead of six browser tabs spread across three vendor dashboards.

Everything runs in a Docker container with a DuckDB cache. The app polls each processor API hourly, normalizes the data into a unified model, and serves a static dashboard. No external analytics service, no per-seat pricing, no vendor lock-in.

Why build this

Stripe's own dashboard is fine if you only use Stripe. Indie developers who split revenue across Stripe (subscriptions), Gumroad (digital products), and Paddle (international payments) have no single view of their business. Tools like Baremetrics, ChartMogul, and ProfitWell solve this well but cost $50–150 per month — often more than a developer's net profit in the early months.

The read-only nature of payment API keys makes this low-risk to self-host. None of the processors restrict this kind of integration. What does not exist is a well-packaged open-source version that handles multi-processor normalization, runs in a single container, and produces a clean dashboard without requiring a data engineering background.

Stack sketch

  • Backend: FastAPI polling Stripe (stripe-python), Gumroad (REST), and Paddle (paddle-python) on a configurable schedule; normalized event rows stored in DuckDB for fast analytical queries
  • Scheduler: APScheduler embedded in the same process — lighter than Celery for a workload that is one hourly batch job
  • Dashboard: Observable Framework static site built at container startup and served as static HTML; charts are Plot.js; page auto-refreshes every 10 minutes via a <meta> tag
  • Storage: DuckDB for aggregated metrics history; API keys stored in SQLite, encrypted at rest with AES-GCM via the cryptography package
  • Auth: a single admin password (bcrypt-hashed, set via ADMIN_PASSWORD env var) protecting the dashboard and config pages
  • Config UI: a minimal HTMX + Jinja2 form for connecting processors and setting the poll interval; no separate frontend build pipeline

Scope for v1

  • Connect up to three processor accounts: Stripe, Gumroad, Paddle
  • Compute and display MRR, new MRR, churned MRR, active subscriber count, and average LTV
  • 12-month MRR history line chart and a 30-day new-vs-churned subscriber waterfall
  • Hourly background sync with a "sync now" button in the UI
  • Single Docker image; only ADMIN_PASSWORD is required to get started
  • Deliberately out of scope for v1: multi-user access, revenue attribution by acquisition channel, email digests, trial-to-paid conversion tracking, multi-currency normalization beyond USD

Where it could go

The most natural follow-on is a weekly email digest: a plain-text email sent every Monday with the week's MRR delta, new subscribers, cancellations, and one flagged metric that moved notably. This requires no new data collection — it is a scheduled query over existing DuckDB tables and an SMTP send. For a solo founder who opens their laptop over coffee, this is worth more than any always-on dashboard.

A second expansion worth building is cohort retention: group subscribers by the month they first converted and show the percentage still active at 1, 3, 6, and 12 months. That single heatmap tells you more about product-market fit than any aggregate MRR number. DuckDB handles the window function queries trivially, and Observable Framework renders the chart in about 15 lines of Plot.js.

Watch out for

Stripe's API is generous but Paddle's Billing API limits requests to 100 per minute. Implement exponential backoff on every processor client from the start and log every rate-limit response explicitly — a stalled sync that fails silently will look like accurate data until it obviously isn't.