Why a Flask blog often breaks right after it goes live
A Flask blog can feel finished when it works on a laptop, but production problems usually begin the moment real traffic and server restarts enter the picture. Many solo developers launch with flask run, keep the process attached to an SSH session, and assume they will “clean it up later.” That works until the terminal closes, the machine reboots, or a few concurrent requests arrive at the same time.
The second class of failure is more subtle. The homepage loads, so the deployment looks fine, but the application does not actually understand that it sits behind a reverse proxy. Then login redirects switch to http, generated absolute URLs are wrong, sitemap links use the wrong scheme, and logs only show the proxy address instead of the real client IP.
For a personal blog, the right starting point is not a complex platform. It is a small, durable stack with clear responsibilities:
- Gunicorn runs the Flask application.
- Nginx accepts public traffic and serves static assets.
- systemd keeps the service alive across crashes and reboots.
That baseline is simple enough to maintain alone and strong enough to support later additions such as HTTPS, automated publishing, analytics, or SEO improvements.
The deployment shape that stays manageable
A clean layout matters because it reduces guesswork when something goes wrong. A typical directory structure looks like this:
/var/www/flask-blog/
app.py
config.py
requirements.txt
database.db
static/
templates/
venv/
Gunicorn should not listen on a public interface. Bind it to localhost, for example 127.0.0.1:8000, and let Nginx proxy requests to it. That gives you three concrete advantages.
First, static files under /static/ can be returned directly by Nginx instead of going through Python. Second, request headers, timeouts, logging, and TLS termination can be managed in one place. Third, the application process itself is no longer exposed to the internet, which narrows the attack surface.
That is the production pattern Flask’s deployment guidance points toward as well: use a real WSGI server and place a reverse proxy in front when appropriate.
Step 1: Build the virtual environment and prove Gunicorn works
Before adding services and proxy rules, make sure the application starts cleanly in its own environment.
cd /var/www/flask-blog
python3 -m venv venv
source venv/bin/activate
pip install -U pip
pip install -r requirements.txt
pip install gunicorn
In this project, app.py exposes app = create_app(...), so Gunicorn can start the application directly with:
gunicorn --bind 127.0.0.1:8000 --workers 2 --threads 4 app:app
For a personal blog, that is a reasonable starting point. Two workers and a small thread count are usually enough unless traffic or background work is unusually heavy. A common early mistake is over-sizing Gunicorn on a small VPS. On a low-memory machine, too many workers create instability instead of headroom.
If the process fails immediately, check the basics before touching Nginx:
- Is Gunicorn installed inside the virtual environment you are actually using?
- Are all runtime dependencies present, such as
flask_sqlalchemy,Pillow, orbcrypt? - Does the project read environment variables that are missing on the server?
At this stage, success means one thing only: a local request to 127.0.0.1:8000 reaches the Flask app consistently.
Step 2: Make the app trust proxy headers correctly
Once Nginx sits in front, the Flask application needs accurate request metadata. Flask and Werkzeug documentation both emphasize this point: do not blindly trust proxy headers, but when you do have a trusted proxy, configure middleware explicitly.
This project already contains the right building blocks. In D:\project\web\boke\blog\app.py, the app wraps the WSGI stack with ProxyFix, and D:\project\web\boke\blog\config.py exposes settings such as:
BEHIND_PROXY = True
PROXY_FIX_X_FOR = 1
PROXY_FIX_X_PROTO = 1
PROXY_FIX_X_HOST = 1
Those values only work as intended if Nginx forwards the matching headers:
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
The most common operational bug here is missing X-Forwarded-Proto. When TLS is terminated at Nginx but the application never receives the original scheme, Flask may still think the request is plain HTTP. That can break admin redirects, canonical URLs, sitemap entries, and any absolute link generated for search engines or social sharing.
Another mistake is setting ProxyFix counts too high. If you only have one trusted reverse proxy, keep those values at 1. If there is a CDN, load balancer, and Nginx in front, you must reason carefully about the actual trusted chain. Over-trusting headers means a client may be able to spoof request metadata.
Step 3: Move process lifecycle into systemd
A production service should survive disconnects and reboots without manual intervention. That is exactly what systemd is for.
Create /etc/systemd/system/flask-blog.service with a minimal unit like this:
[Unit]
Description=Flask Blog Gunicorn Service
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/flask-blog
Environment="FLASK_ENV=production"
Environment="BEHIND_PROXY=true"
Environment="SESSION_COOKIE_SECURE=true"
ExecStart=/var/www/flask-blog/venv/bin/gunicorn --workers 2 --threads 4 --bind 127.0.0.1:8000 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Then enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable flask-blog
sudo systemctl start flask-blog
sudo systemctl status flask-blog
When it fails, do not guess. Go straight to the journal:
sudo journalctl -u flask-blog -n 100 --no-pager
In small self-hosted projects, most real deployment issues show up immediately there: a wrong working directory, a bad virtualenv path, a missing secret, or a permission error when the application tries to write SQLite data or uploaded files.
Step 4: Put Nginx in front and let it do the public-facing work
A straightforward Nginx server block is usually enough for a blog:
server {
listen 80;
server_name example.com www.example.com;
location /static/ {
alias /var/www/flask-blog/static/;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60;
}
}
The /static/ section is not just an optimization. It prevents routine asset requests from consuming Python worker time. On a small VPS, that difference is noticeable.
Two details deserve special attention.
The first is file access. If CSS, JavaScript, or images appear broken, Nginx may simply be pointing at the wrong absolute path or running under a user that cannot read the directory. The second is the trailing slash in alias paths. A surprising number of static-file issues come from path concatenation mistakes rather than application bugs.
After every config change, validate and reload instead of restarting blindly:
sudo nginx -t
sudo systemctl reload nginx
Step 5: Add HTTPS, backups, and a sane upgrade path
Once the HTTP deployment is stable, HTTPS should be the next priority. For a blog, TLS is not only a security checkbox. It also affects session cookies, redirect consistency, canonical URLs, and search-engine trust in the site’s public links.
On the application side, pairing HTTPS with SESSION_COOKIE_SECURE=true is the right default in production.
For storage, SQLite is still a valid choice for a low-traffic personal blog if you are disciplined about backups. This repository already includes D:\project\web\boke\blog\scripts\backup_sqlite.py, which is enough to build a simple scheduled backup routine. There is no need to move to MySQL before the workload actually justifies it. Premature database migration adds complexity faster than it adds reliability.
The three mistakes that cause most deployment pain
1. Running the development server in production
Flask’s built-in development server is not intended for production traffic. Even if it seems fine during light testing, it is the wrong foundation for a public blog.
2. Proxying requests without forwarding the right headers
This is the classic “homepage works, but everything else is weird” failure mode. Redirects, admin authentication flows, sitemap URLs, and generated absolute links all depend on accurate request context.
3. Mixing users, permissions, and writable paths carelessly
If Gunicorn runs as www-data, then the application directory, SQLite database, and upload folders must have compatible ownership and permissions. Otherwise the site may read fine but fail with 500 errors when publishing articles or saving uploads.
Final takeaway
A personal Flask blog does not need Kubernetes or a complicated platform to be production-ready. What it needs is a reliable baseline: Gunicorn on localhost, Nginx as the public entry point, systemd managing the process lifecycle, and correct proxy handling so Flask understands the original request.
If you only prioritize four things before launch, make them these: run Gunicorn instead of flask run, bind the app locally, let systemd own the service, and forward the proxy headers that Flask needs to generate correct URLs. Once that foundation is in place, HTTPS, automated publishing, and SEO work become additive improvements instead of emergency fixes.