Why Flask blogs often feel stable locally but fragile in production

A personal Flask blog usually starts in the simplest possible way: one app file, one database file, a few templates, and flask run during development. That is exactly the right way to start. The trouble begins when that same setup is pushed to a public server without redefining responsibilities.

The first symptom is often uptime. The app works after deployment, then disappears after a reboot or a dropped SSH session because nothing is supervising the process. The second symptom is incorrect request metadata. Once Nginx is added in front, Flask may no longer see the original scheme, host, or client IP unless the proxy headers are forwarded and trusted correctly. The third symptom is operational drift: static files, upload directories, environment variables, database files, and publish scripts all live together, but nobody is quite sure which component owns what.

For a long-lived Flask blog, the production goal is not sophistication for its own sake. The goal is a layout that is easy to reason about and easy to recover. Gunicorn should run the Python application. systemd should manage lifecycle and restarts. Nginx should accept public traffic and serve static assets. Your publishing workflow should update search discovery through sitemaps after the article URL actually exists.

That separation of concerns is what turns a hobby deployment into something you can keep operating without guesswork.

A deployment shape that stays maintainable

The following structure works well for a self-hosted Flask blog on Debian or Ubuntu style Linux servers:

  1. Application code lives in /var/www/blog.
  2. The virtual environment lives in /var/www/blog/.venv.
  3. Gunicorn listens only on 127.0.0.1:8000 or on a Unix socket.
  4. Nginx listens on ports 80 and 443 and proxies dynamic requests.
  5. Static assets are served directly by Nginx.
  6. systemd starts the app at boot and restarts it on failure.
  7. Article publishing is followed by sitemap submission rather than relying on manual indexing requests.

Install the base packages first:

sudo apt update
sudo apt install -y python3 python3-venv nginx

Then prepare the project directory and virtual environment:

cd /var/www
sudo mkdir -p blog
sudo chown -R $USER:$USER blog
cd blog
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
deactivate

At minimum, verify the production environment variables before you start the service:

FLASK_DEBUG=0
DATABASE_URL=sqlite:////var/www/blog/database.db
SITE_PUBLIC_URL=https://example.com
ONLINE_BLOG_SITE_URL=https://example.com
AUTO_PUBLISH_TOKEN=replace-with-a-random-token

Even if your blog still uses SQLite, the deployment pattern remains useful. Later, if you switch to MySQL or move uploads to object storage, you can keep the same process model.

Step 1: stop using the development server in production

Flask's own documentation is explicit here: the built-in development server is for local development, not for production use. In production, use a dedicated WSGI server.

For a personal Flask blog, Gunicorn is a pragmatic default. A minimal starting command looks like this:

/var/www/blog/.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 'app:create_app()'

If your project exposes a module-level app object instead of only an application factory, this is also common:

/var/www/blog/.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app

The worker count does not need to be clever on day one. For a content-heavy personal site, starting with 2 to 4 workers is usually more sensible than trying to maximize concurrency immediately. The cost of over-provisioning on a small VPS is often memory pressure and restarts, not better throughput.

Two deployment habits matter here:

  • Bind Gunicorn to localhost or a Unix socket, not directly to the public internet.
  • Install Gunicorn inside the same virtual environment as the application so the runtime dependencies stay aligned.

Step 2: let systemd own process lifecycle

Running Gunicorn manually in an interactive shell is fine for testing and a bad idea for operations. If the SSH session dies, your app may die with it. If the machine reboots, nothing starts unless you remember to log back in.

systemd fixes that by making the Flask blog a managed service.

Create /etc/systemd/system/blog.service:

[Unit]
Description=Flask Blog Gunicorn Service
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/blog
EnvironmentFile=/var/www/blog/.env
ExecStart=/var/www/blog/.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 'app:create_app()'
Restart=always
RestartSec=5
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Then reload systemd and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now blog.service
sudo systemctl status blog.service

When something fails, inspect logs before changing configuration blindly:

sudo journalctl -u blog.service -n 100 --no-pager

This is where many production issues become obvious. Wrong WorkingDirectory values break template loading, static path assumptions, SQLite file paths, and ad hoc backup scripts all at once. Missing EnvironmentFile wiring often explains why an app works locally but fails to connect to the production database or rejects API publishing requests.

Step 3: keep Nginx focused on edge traffic and static files

Nginx should not try to become your Python app. Its job is to terminate public HTTP traffic, serve cacheable files efficiently, and proxy dynamic requests to Gunicorn.

A clean starting configuration looks like this:

server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 10m;

    location /static/ {
        alias /var/www/blog/static/;
        access_log off;
        expires 7d;
    }

    location / {
        proxy_pass http://127.0.0.1:8000/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Prefix /;
    }
}

After editing the config, validate syntax first:

sudo nginx -t
sudo systemctl reload nginx

The client_max_body_size line is easy to overlook and becomes important as soon as the blog supports image uploads. If Nginx allows 10 MB but Flask rejects 4 MB through MAX_CONTENT_LENGTH, users get an application-level failure. If Flask allows the upload but Nginx blocks at 1 MB, the request never reaches the app. Keep both limits aligned so troubleshooting stays deterministic.

Step 4: tell Flask that it is behind a proxy

Once Nginx sits in front of Flask, the app no longer receives the original request directly. From Flask's perspective, the request may appear to come from a local address and plain HTTP unless the forwarded headers are both set by Nginx and explicitly trusted by the app.

Flask recommends wrapping the application with Werkzeug's ProxyFix middleware when the app is genuinely behind a trusted proxy:

from werkzeug.middleware.proxy_fix import ProxyFix

app = create_app()
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)

This step affects more than request logging. It changes whether absolute URLs generated by url_for(..., _external=True) use the correct scheme and hostname. It also affects canonical URLs, sitemap entries, login redirects, and any automation that constructs article URLs from application responses.

The security caveat matters: do not enable ProxyFix casually, and do not guess the proxy count. Forwarded headers can be spoofed. Trust only the number of proxy hops you actually control.

Step 5: separate code, static assets, uploads, and writable paths

A common operational anti-pattern is using broad permissions to "just make deployment work." The classic version is recursively opening the whole project directory after one upload or database write fails.

A better model is to separate responsibilities:

  • The code directory is maintained by the deploy user.
  • Nginx only needs read access to static files.
  • The Flask process only needs write access where writes are expected.
  • Upload directories and database files are managed deliberately.

Example:

sudo mkdir -p /var/www/blog/static/uploads
sudo chown -R www-data:www-data /var/www/blog/static/uploads
sudo chmod 755 /var/www/blog/static
sudo chmod 775 /var/www/blog/static/uploads

If the blog still runs on SQLite, treat the database as an operational asset, not as a convenience file you forget exists. Schedule backups. Keep the database path stable. Verify that the service user can write to the database file and its directory. Many "random SQLite errors" in production are really permission or filesystem placement errors.

Step 6: connect publishing to search discovery

Publishing an article is not the same thing as making it discoverable. Search engines may eventually find a new URL, but relying on eventual discovery slows down feedback loops for a content site.

A stronger workflow is:

  1. Publish the article through the application API.
  2. Wait until the canonical article URL and sitemap are actually available.
  3. Submit discovery signals through your existing automation.

In this project, the useful pattern is already visible:

python scripts/publish_article_api.py --input article.en.json --submit-after-publish

That structure is sound because it avoids premature submission. The script can publish first, derive the actual public article URL from the API response, wait for the site and sitemap to settle, and only then call the submission helper.

If you rely on sitemap-based discovery, verify three details every time:

  • The sitemap is UTF-8 encoded.
  • URLs are absolute, not relative.
  • The sitemap is ideally hosted at the site root so it covers the intended URL space.

Google's documentation also makes an important point: submitting a sitemap is a hint, not a guarantee of instant crawling. That is exactly why consistent canonical URLs, reachable pages, and predictable publication flow matter more than any single indexing call.

A short release checklist is worth more than another hotfix

For a solo-maintained Flask blog, a repeatable release checklist saves more time than any clever deployment trick. Before and after each production update, verify:

  • systemctl status blog.service shows a healthy running service.
  • nginx -t succeeds before reload.
  • Home page, article pages, and admin login all load.
  • Absolute URLs use the correct public domain and protocol.
  • /sitemap.xml includes the newly published article.
  • ONLINE_BLOG_PUBLISH_URL and AUTO_PUBLISH_TOKEN are present for publishing automation.
  • Missing Baidu or Google credentials produce explicit logs instead of silent failure.

That last point is operationally important. Silent search submission failures are easy to ignore for weeks, and by the time someone notices, nobody remembers which environment variable was missing.

Conclusion

A Flask blog does not need a complicated production platform, but it does need clear boundaries. Gunicorn runs the WSGI app. systemd keeps it alive and starts it at boot. Nginx handles public traffic and static delivery. ProxyFix restores correct request context behind the reverse proxy. Sitemap submission closes the loop between publishing and discovery.

Once those pieces are in place, the rest of the stack becomes easier to evolve. You can add upload security controls, migrate from SQLite to MySQL, introduce a CDN, split admin permissions, or automate article publishing without rewriting the fundamentals of how the site is deployed.