Building a Minimal Flask Blog
A short walk through the architecture of a small Flask application that serves markdown content. The goal is to demonstrate how little code is actually needed to ship a working blog when you're comfortable in Python.
The Core Application
The entire Flask app fits in about 50 lines. Here's the basic structure:
from flask import Flask, render_template, abort
from pathlib import Path
import frontmatter
import markdown
app = Flask(__name__)
POSTS_DIR = Path(__file__).parent / "posts"
def load_post(slug):
"""Load a single post by slug, return None if not found."""
post_path = POSTS_DIR / f"{slug}.md"
if not post_path.exists():
return None
post = frontmatter.load(post_path)
html = markdown.markdown(post.content, extensions=['fenced_code'])
return {
'slug': slug,
'title': post.get('title', 'Untitled'),
'date': post.get('date'),
'tags': post.get('tags', []),
'html': html,
}
def load_all_posts():
"""Load all posts, sorted by date descending."""
posts = []
for post_path in POSTS_DIR.glob("*.md"):
slug = post_path.stem
post = load_post(slug)
if post:
posts.append(post)
return sorted(posts, key=lambda p: p['date'], reverse=True)
The frontmatter library handles the YAML metadata at the top of each markdown file. The markdown library converts the body to HTML. Everything else is just file system iteration.
The Routes
Three routes handle the entire site:
@app.route('/')
def index():
posts = load_all_posts()
return render_template('index.html', posts=posts)
@app.route('/post/<slug>')
def post(slug):
post_data = load_post(slug)
if not post_data:
abort(404)
return render_template('post.html', post=post_data)
@app.route('/about')
def about():
return render_template('about.html')
That's the whole routing layer. Each route does the minimum work needed and delegates rendering to Jinja2 templates.
Cache Headers
For performance, we add cache control to responses. The Varnish layer on the hosting provider respects these:
from functools import wraps
from flask import make_response
def cached(seconds=300):
"""Decorator to add Cache-Control headers."""
def decorator(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
response = make_response(view_func(*args, **kwargs))
response.headers['Cache-Control'] = f'public, max-age={seconds}'
return response
return wrapper
return decorator
@app.route('/')
@cached(seconds=60)
def index():
posts = load_all_posts()
return render_template('index.html', posts=posts)
Five minutes feels right for the index page — long enough to benefit from caching, short enough that new posts appear quickly.
Deployment
The WSGI entry point is one line:
# wsgi.py
from app import app as application
The hosting provider's server picks this up and serves the application. No additional configuration needed beyond a requirements.txt:
flask
markdown
python-frontmatter
What This Demonstrates
Custom Python beats configured frameworks when you have the skills to write it. The whole blog is small enough to understand completely. There's no framework magic, no plugin system, no theme conventions. Just markdown files in a directory and Python that reads them.
The trade-off is real — you build what frameworks would give you for free. For someone who writes Python comfortably, the trade favors custom code. For someone who'd rather configure than code, frameworks are the right choice.
I'd been telling myself for years that I should write more publicly. The infrastructure was always the excuse. Building the infrastructure took an afternoon. The remaining work is the part I'd actually been avoiding — writing the things.
Next Steps
A few things to add as content accumulates:
- RSS feed generation for readers who use feed readers
- Photo gallery with structured metadata in PostgreSQL
- Generative visual elements (the AI dust idea)
- Cloudflare in front for global edge caching
None of these are urgent. The basic structure works. Content first, infrastructure refinements later.