The 220-app strategy rests on one piece of infrastructure: a Flask template we call dangercorn-saas-template, dcst for short. Every new vertical — cheesemaking, honeybees, shiftfill, all of them — starts as a clone of this template with a rename script.
I've gotten a few emails asking what's actually in the template. So here it is, component by component, with the reasoning for each choice.
Why Flask
Django has more batteries. FastAPI is faster. Next.js has a bigger community. I picked Flask anyway.
The reason is that the template has to be boring. Every person who might self-host one of our apps needs to be able to open the code, understand what's going on, and trust that they can keep running it if we disappear. Flask is the most legible Python web framework. Routes are functions. Templates are HTML files. There's no implicit magic. A Python developer from 2015 can read a 2026 Flask app and know what's happening. That matters more than raw performance for our use case.
None of our apps have a performance problem that Flask can't handle. Most will peak at a few hundred requests per minute across all hosted customers combined. We are not building the next TikTok.
SQLite as the Default Database
Same reasoning. SQLite is a file. Anyone can copy it, back it up, inspect it with a GUI, or port it to Postgres later. For a self-host user, this is huge — their whole database is one file on their laptop. For us hosting, SQLite comfortably handles thousands of users per app before we'd need to consider Postgres. And we're nowhere near that.
The template has a dcst.db helper module that wraps the standard sqlite3 module with per-app database naming, simple schema migration (idempotent CREATE IF NOT EXISTS), and connection pooling that doesn't leak connections when Flask recycles a worker. Nothing clever. Just the things I was tired of writing for the twentieth time.
Auth: Single Password, Done
Most of our verticals are used by one person or one small team. They do not need email verification, magic links, social OAuth, 2FA, passkeys, or role-based access control. They need "does this person know the password." That's it.
The template ships with a single-password auth flow. The password is stored as a bcrypt hash in an environment variable (or a config file, your choice). There's a login page, a logout button, session management via Flask's signed cookies, and a @requires_auth decorator. Total code: about 80 lines.
For the small number of verticals that actually need multi-user auth — bookcircle is one, shiftfill is another — the template has an optional dcst.auth.multiuser module that adds user accounts and invite codes. But the default is single-password, because that's what 70% of verticals need and it's the simplest thing that can possibly work.
Deterministic Port Assignment
This one is my favorite. When you run 40 Flask apps on one development machine, you have to assign ports somehow. Most scaffolds default to 5000 and tell you to pick your own from there. That works until you want to run 10 at once.
The template has dcst.config.port_for(slug) which takes an app slug and returns a deterministic port number in the 8400-8699 range, computed from the SHA-1 of the slug. Every time you start cheesemaking it binds to the same port. Every time you start honeybees it binds to a different port, also the same every time. No collisions. No lookup table. I wrote a separate post about why this is the right call.
# In every app's app.py:
from dcst.config import port_for
app.run(host="0.0.0.0", port=port_for("cheesemaking"))
# → always binds to port 8437
Stripe: Wired But Off by Default
Stripe is wired in. Every template has the upgrade button, the checkout flow, the webhook handler for payment events, and the "you have an active subscription" check in the auth decorator. But it's all gated behind a STRIPE_ENABLED=false env var by default.
The reason: when you self-host, you probably don't want Stripe. When we host, we do. Same code, environment decides. And when we want to flip a vertical from free to paid, it's a env-var flip, not a rewrite.
The Claude Wrapper (dcch)
Every app that wants AI features imports from dangercorn-claude-helper (dcch). It's a shared package, not vendored into each app. So updates to retry logic, model fallback behavior, and cost logging happen in one place and every app picks them up.
Typical usage in a vertical:
from dcch import claude
def generate_discussion_questions(book_title, author):
return claude.structured(
prompt=f"Generate 10 discussion questions for '{book_title}' by {author}",
model="claude-3-5-sonnet-20241022",
fallback_to_template="discussion_questions_default",
)
If Anthropic is down or the user hasn't provided an API key, fallback_to_template returns a curated default. The app keeps working. This matters for the self-host experience — the AI feature being optional rather than blocking is the difference between "your app is broken" and "your app is working, the AI bit is turned off." Huge difference in how it feels.
Base Templates and Design System
Every vertical extends the same base Jinja template. Gold-on-deep-brown color palette. DM Serif Display for headings. DM Sans for body. JetBrains Mono for code. The design system is defined in dcst/static/dangercorn.css with CSS variables for everything, so a vertical can override specific colors without rewriting the whole sheet.
Form helpers, table helpers, CRUD list/detail/form fragments — all shared. When I need a table with search and pagination, I use {% include "dcst/table.html" %} and pass it the data. That's 20 lines of custom work instead of 200.
The Deploy Path
Every vertical's GitHub repo has a CI workflow that runs the tests, builds a Python wheel, and pushes it to our internal package index. A deploy script on the fleet node pulls the latest wheel and restarts the systemd service for that app. The landing page deploys separately via Vercel on push to main.
From git push to running in production: about 90 seconds, fully automated. No kubectl. No Docker (yet). Just systemd services, one per app, each binding to its deterministic port.
What's NOT In the Template
Things I have deliberately kept out, even though they'd make some individual vertical easier:
- An ORM. SQLAlchemy is used for some apps, raw SQL for others. Not forcing a choice keeps each vertical's code simpler when that's appropriate.
- A frontend framework. Everything is server-side rendered Jinja with a tiny bit of vanilla JS. No React, no Vue, no build step. Self-hosters don't want a node_modules directory.
- WebSockets, real-time features, job queues. If a vertical needs them, we add them; most don't.
- Docker. Systemd + a virtualenv is fine for our scale. Docker adds operational complexity that doesn't pay for itself until you're running at larger scale than we are.
The template isn't trying to be everything. It's trying to be the 80% that every app needs, minus everything that would make the 80% worse for one app's sake.
Why This Works
None of the choices in the template are novel. Flask has been around since 2010. SQLite is older than most of the web. Jinja is standard. bcrypt auth is standard. Stripe checkout is standard. The template's value isn't any individual component — it's that they're all integrated, all tested together, all documented in one place, and all kept current in one codebase.
Every new vertical we ship benefits from improvements to the template. When I caught a Python 3.12 f-string bug in CI, the fix went into the template and every vertical automatically got the better CI configuration on next push. That compounding effect is the whole reason the 220-app strategy works.
If you want the template, it's not public yet. We'll open-source it once the corners are rounded. In the meantime, every shipped vertical's repo shows you exactly what the template-generated output looks like — pick any of the live products and dig through the code.