Ship a Flask SaaS starter with sign-up and Stripe
Build a small subscription app with accounts, a paid plan and a live URL, from an empty folder to production in one weekend.
1 Friday evening: scope it and scaffold the project
Pick one job your app does for the customer and write it in one sentence ("Upload a CSV, get a cleaned CSV back"). Anything that is not needed for that sentence waits for version 2.
mkdir saas-starter && cd saas-starter
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install flask flask-login flask-sqlalchemy flask-wtf stripe python-dotenv gunicorn
pip freeze > requirements.txt
git init && echo ".venv/\n.env\n*.db" > .gitignore
Create the app factory so tests and production can build their own app:
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
db = SQLAlchemy()
login = LoginManager()
def create_app():
app = Flask(__name__)
app.config.from_prefixed_env() # FLASK_SECRET_KEY, FLASK_SQLALCHEMY_DATABASE_URI
app.config.setdefault("SQLALCHEMY_DATABASE_URI", "sqlite:///app.db")
db.init_app(app)
login.init_app(app)
from .routes import bp
app.register_blueprint(bp)
return app
Done when: flask --app app run shows a "Hello" page and the first commit exists.
2 Saturday morning: accounts
Add a User model with a hashed password and an is_paid flag, then sign-up, login and logout.
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_paid = db.Column(db.Boolean, default=False)
stripe_customer_id = db.Column(db.String(64))
def set_password(self, raw):
self.password_hash = generate_password_hash(raw)
def check_password(self, raw):
return check_password_hash(self.password_hash, raw)
- Use Flask-WTF forms so every POST has a CSRF token.
- Logout must be a POST form, never a link.
- Protect the product page with
@login_required.
Done when: you can sign up, log out, log back in, and a second browser can't see your page.
3 Saturday afternoon: the one feature
Build the feature from Friday's sentence, behind login. Keep it boring: one form, one result page. Free users get a limit (for example 3 runs per day); paid users get more.
@bp.route("/app", methods=["GET", "POST"])
@login_required
def product():
if request.method == "POST" and not current_user.is_paid and runs_today(current_user) >= 3:
flash("Free plan limit reached. Upgrade for unlimited runs.")
return redirect(url_for("main.pricing"))
...
Done when: a free user hits the limit and is sent to the pricing page.
4 Saturday evening: Stripe Checkout + webhook
Create a product and a recurring price in the Stripe dashboard (test mode). Checkout creates the session; only the webhook marks a user as paid, because the success redirect can be faked.
@bp.post("/billing/checkout")
@login_required
def checkout():
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": os.environ["STRIPE_PRICE_ID"], "quantity": 1}],
customer_email=current_user.email,
client_reference_id=str(current_user.id),
success_url=url_for("main.product", _external=True) + "?paid=1",
cancel_url=url_for("main.pricing", _external=True),
)
return redirect(session.url, code=303)
@bp.post("/billing/webhook")
def webhook():
event = stripe.Webhook.construct_event(
request.data, request.headers.get("Stripe-Signature"), os.environ["STRIPE_WEBHOOK_SECRET"])
if event["type"] == "checkout.session.completed":
user = db.session.get(User, int(event["data"]["object"]["client_reference_id"]))
user.is_paid = True
db.session.commit()
return "", 200
Exempt the webhook from CSRF, and test it locally with stripe listen --forward-to localhost:5000/billing/webhook.
Done when: a test card (4242 4242 4242 4242) upgrades the account and the limit disappears.
5 Sunday morning: deploy
- Push to GitHub.
- On Render: New → PostgreSQL, then New → Web Service from the repo.
- Build command
pip install -r requirements.txt, start commandgunicorn "app:create_app()". - Environment:
FLASK_SECRET_KEY(random 64 hex chars),FLASK_SQLALCHEMY_DATABASE_URI(the internal Postgres URL),STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_WEBHOOK_SECRET. - Add the production webhook endpoint in Stripe and copy its signing secret.
Done when: the live URL works end to end with a test card.
6 Sunday afternoon: polish and launch
- A pricing page with exactly two plans (Free, Pro).
- A privacy page and a terms page (even short ones).
- An error page for 404 and 500.
- Tell 10 people who have the problem. Write down every question they ask: that is your version 2 backlog.
Done when: a stranger can sign up and pay without asking you anything.
Go deeper with these tracks
More playbooks
- schedule_sendAutomate a daily report with Python, cron and emailA script that pulls data every morning, builds a short summary with a chart, and emails it to you, running unattended on a small Linux server.
- account_balance_walletBuild an offline budget tracker app (PWA)An installable app that records income and expenses, works offline, shows a monthly chart and exports to CSV, with no server and no account.
- event_availableBuild an online booking system for a small businessCustomers pick a free time slot, book it and get an email confirmation; the owner sees and cancels bookings in a simple admin page.