Why Flask Still Matters
There is a certain elegance to Flask that I keep coming back to. While the ecosystem has grown crowded with frameworks promising more magic, Flask remains steadfastly minimal. It gives you routing, templating, and a request context. Everything else is your choice.
This philosophy resonates with how I think about building software. Start with just enough structure, then add what you actually need. What follows is a collection of the patterns and decisions that have served me well — drawn directly from building and maintaining this site in production.
Project Structure: The Case for One File
The instinct on a growing project is to reach for blueprints and application factories early. Sometimes that's right. More often, it's premature.
This site's entire application — routes, models, helpers, and database setup — lives in a single app.py. That's not a shortcut; it's a deliberate choice. When every route and model lives in one file, you can read the full application in under an hour. Onboarding is instant. Finding behavior is a grep. The cognitive overhead of "where does this live?" disappears.
Blueprint architecture makes sense when you have independent subsystems developed by different teams, or when a deployment boundary requires separating concerns at a service level. For a single-maintainer project, it adds navigation cost without adding capability.
The one separation worth making early: config.py. Keeping configuration separate from application logic makes it easy to swap settings between development and production without touching your routes:
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-prod')
SQLALCHEMY_DATABASE_URI = 'sqlite:///flask_users.db'
SESSION_COOKIE_SECURE = False
class ProductionConfig(Config):
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
Models and the SQLite Sweet Spot
Flask doesn't dictate a database layer, so you get to make a real choice. For most projects I build, SQLAlchemy with SQLite is the right answer.
SQLite is underestimated. It handles concurrent reads gracefully, tolerates millions of rows without performance issues, and its entire database is a single file you can copy, back up, or inspect with any SQL client. The production database for this site is a single file. Backups are a cron job that copies one file.
The pattern I use is straightforward SQLAlchemy ORM — no fancy base classes or mixins unless they're genuinely reused:
class Article(db.Model):
__tablename__ = 'article'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(200), unique=True, nullable=False)
body_markdown = db.Column(db.Text)
body_html = db.Column(db.Text)
is_published = db.Column(db.Boolean, default=False)
access_level = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
One important note on migrations: Flask-Migrate and Alembic are the standard tools, and they're excellent for team projects. For a single-maintainer app, I've found the overhead of a migration history rarely justified. I add columns directly via ALTER TABLE against the live database and mirror the change in the model. It's not elegant, but it's honest about what's actually happening, and it keeps the codebase clean.
Security as a Foundation
Flask gives you nothing on security by default — which means you have to be intentional. Three libraries handle most of it.
Flask-WTF provides CSRF protection. Every form carries a CSRF token that Flask-WTF validates automatically. The only addition to your templates is {{ form.hidden_tag() }} or a {{ csrf_token() }} call for AJAX endpoints.
Flask-Limiter handles rate limiting. Login endpoints are the most important to protect — brute-force attacks on authentication are the most common pattern I see in production access logs:
limiter = Limiter(get_remote_address, app=app, default_limits=["200 per day"])
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
...
Werkzeug handles password hashing. generate_password_hash and check_password_hash use PBKDF2-SHA256 by default. Never roll your own.
One subtle trap: Flask sees every request as coming from 127.0.0.1 when running behind nginx, unless you apply ProxyFix. Without it, rate limiting by IP is useless and auth logs are meaningless:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
I discovered this the hard way after deploying an app behind nginx and watching Flask-Limiter do nothing useful for a week.
Tailwind Without Node
Most Tailwind tutorials assume a Node.js build pipeline. You don't need one.
Tailwind ships a standalone binary that handles compilation with no npm, no webpack, and no node_modules. Drop it in your project root and run:
./tailwindcss -i ./static/css/tailwind.src.css -o ./static/css/tailwind.css --config tailwind.config.js --minify
One gotcha: Tailwind's build process purges any class it doesn't see statically in your source files. Classes toggled dynamically via JavaScript need to be explicitly safelisted in tailwind.config.js, or they disappear from the compiled output:
module.exports = {
content: ["./templates/**/*.html", "./static/js/**/*.js"],
safelist: ['hidden', 'bg-green-100', 'bg-red-100'],
}
Run the build after any template change. Commit the compiled CSS — your production server doesn't need the Tailwind binary.
Deploying to Production
The stack I use is Gunicorn behind nginx, managed by systemd. It's boring, reliable, and well-documented.
The part most tutorials skip is the systemd service file, which is also where the most important configuration lives — specifically, how secrets reach the application without touching the codebase:
[Unit]
Description=My Flask App
After=network.target
[Service]
User=lex
WorkingDirectory=/home/lex/my_app
EnvironmentFile=/home/lex/.env
ExecStart=/home/lex/my_app/venv/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 app:app
Restart=on-failure
[Install]
WantedBy=multi-user.target
With EnvironmentFile=, every line in your .env file becomes an environment variable for the process. SECRET_KEY, API keys, database URLs — none of them touch the repository. The .env file stays out of git entirely.
One production detail worth knowing early: VPS providers commonly block SMTP ports (25, 465, 587) to reduce spam. If you're building user authentication with password reset emails, build against a transactional email API — Brevo, Postmark, Resend — from the start. Discovering the SMTP block after you've implemented email-based verification is a painful afternoon.
The CMS Pattern
The most useful thing I've added to this site is a simple article CMS — and it turned out to be more straightforward than expected.
The Article model carries both body_markdown and body_html. On save, the markdown is rendered to sanitized HTML and stored so pages don't re-render on every request. Slugs are generated from the title, deduplicated if necessary, and never changed after creation — changing a slug breaks any incoming links.
Access control is a single integer column — access_level — with three values: public (0), members-only (1), staff-only (2). Every listing query filters by the current user's access tier. It covers 90% of content access patterns without a separate permissions table.
This approach gives you full ownership of your content pipeline: you control the data model, the rendering, the access rules, and the admin interface. No external services, no vendor lock-in, no surprise pricing changes.
The Art of Progressive Enhancement
The best Flask applications I've maintained grew incrementally. This site started as a few static pages. Then user authentication. Then a CMS. Later, analytics, series groupings for multi-part content, an AI chat interface backed by local LLMs, and an MCP server exposing the CMS to AI agents for content management via natural language.
Each feature emerged from a real need, not from a boilerplate. This approach keeps complexity in check — every line of code serves a purpose that was felt, not anticipated. Flask accommodates this growth naturally. Add SQLAlchemy when you need persistence. Add Flask-Login for auth. Add Flask-Limiter when bots find your login form. The framework stays out of your way.
Flask rewards developers who value simplicity and intentionality. It won't make architectural decisions for you, and that is precisely its strength.