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.

48 घंटे intermediate FlaskSQLite → PostgreSQLStripe CheckoutRender

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

  1. Push to GitHub.
  2. On Render: New → PostgreSQL, then New → Web Service from the repo.
  3. Build command pip install -r requirements.txt, start command gunicorn "app:create_app()".
  4. 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.
  5. 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.

प्रोजेक्ट पूरा हुआ? लिंक समीक्षा के लिए भेजें। समीक्षा के लिए भेजें (50 अंक)

इन ट्रैक के साथ और गहराई में जाएँ

और प्लेबुक