When to use this runbook
File uploads in a Flask blog usually fail long before anyone notices a security issue. The common production pain is misalignment: the browser hints one size, Nginx enforces another, Flask uses a different MAX_CONTENT_LENGTH, and the image processing code adds its own CPU and memory cost. In the Stepnex blog project, the upload path is concrete and easy to reason about: MAX_CONTENT_LENGTH is set to 8388608, files are stored under static/uploads/YYYYMM, the editor posts to /admin/upload, and save_uploaded_image() validates extensions, normalizes the name, generates a random filename, resizes the image, and often converts it to WebP. That is already a production pipeline, even if it runs on one server.
This article is for teams that already have uploads working in development but see confusing production behavior: a browser gets 413 Request Entity Too Large, an editor retries until the request ends as 499, a Markdown image URL comes back with the wrong scheme, or a large screenshot ties up a Gunicorn worker during Pillow conversion. The goal is not to redesign the storage layer or move everything to object storage. The goal is to make failure predictable, observable, and maintainable on a Flask + Gunicorn + Nginx stack.
If you still need a checklist for file type validation and attack surface, start with the related Stepnex note on Flask image upload security. If the admin endpoint already attracts automated probing, pair this runbook with the existing admin upload rate limit checklist. This runbook sits one layer lower: it is about aligning limits and timeouts across the reverse proxy and the Flask application so the upload path behaves the same way every day.
Steps to align the baseline
Start with one rule: every layer should describe the same upload contract. In this repository, the relevant application configuration is already visible in code and docs: MAX_CONTENT_LENGTH=8388608, IMAGE_MAX_DIMENSION=1600, WebP conversion enabled by default, and uploads written to static/uploads/. If Nginx is left at its default client_max_body_size 1m, requests larger than 1 MB are rejected before Flask ever sees them. That means the app route never runs, no Flask error handler is involved, and your application log may look clean even though editors keep failing.
A minimal aligned configuration can look like this:
APP_ENV=production
BEHIND_PROXY=true
MAX_CONTENT_LENGTH=8388608
IMAGE_MAX_DIMENSION=1600
IMAGE_CONVERT_TO_WEBP=true
server {
client_max_body_size 8m;
client_body_timeout 30s;
location /admin/upload {
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_read_timeout 120s;
proxy_send_timeout 120s;
proxy_request_buffering on;
}
}
The technical trade-off here is pragmatic. For a personal or small-team blog, authenticated image uploads are usually small and infrequent. Keeping proxy_request_buffering on lets Nginx absorb slow clients and hand the complete body to Gunicorn, which is a better fit for synchronous workers than streaming partial request bodies upstream. Do not solve every upload complaint by increasing size limits. First decide what a normal editorial asset looks like, then encode that boundary consistently in the browser, the reverse proxy, and the Flask configuration.
Production hardening: know which layer rejected the request
A useful upload runbook distinguishes failures by layer, not by emotion. 413 means the request body exceeded the proxy or application limit. 400 from /admin/upload usually means the route ran but rejected the file because it was missing, invalid, or corrupted. 499 in an Nginx access log means the client closed the connection before the server completed the response. 504 or worker timeout symptoms point in a different direction: the upstream app or image processing path took too long.
The first operational improvement is to write the rejection order down. For this project, the order is straightforward:
- Browser picks a file and submits a multipart request.
- Nginx checks body size and body timeout before proxying upstream.
- Flask checks the request size and the presence of the uploaded file.
save_uploaded_image()validates the extension, loads the image with Pillow, resizes it, and writes the final asset.- The route returns a JSON payload that the editor inserts into Markdown.
That ordering matters because it tells you where to look. A proxy-layer rejection may leave only Nginx evidence. An application-layer rejection returns a structured JSON error. A client abort shows up in the access log, not as a Flask exception. Treat this as a validate step, not just a debugging habit: every deploy should preserve the same layer boundaries.
There is another production detail that often gets missed: ProxyFix. This codebase enables proxy trust when BEHIND_PROXY=true, and it expects Nginx to pass X-Forwarded-For, X-Forwarded-Proto, and host information. If those headers are wrong, the upload may succeed but the generated image URL can come back with the wrong scheme or host. That creates mixed-content warnings, broken image links, and misleading SEO signals. In other words, the public URL generated after upload is part of the pipeline, not an afterthought.
Performance, safety, and log signals
The next step is to stop thinking about upload size as the only limit. In this project, the real work happens after the body is accepted. Pillow calls such as image.load(), ImageOps.exif_transpose(), thumbnail(), and format conversion can consume far more CPU than the HTTP body size suggests. A 5 MB image from a phone camera may still decode into a very large bitmap. Raising client_max_body_size from 8 MB to 20 MB does not remove that cost; it only allows more expensive inputs into the processing path.
A better production approach is to keep size and processing complexity as separate controls:
- Keep the upload limit near the editorial reality of the blog. An 8 MB ceiling is often enough for screenshots, cover images, and documentation illustrations.
- Keep
IMAGE_MAX_DIMENSIONclose to the largest useful display size instead of preserving oversized originals. - Preserve login and CSRF protections on
/admin/upload; do not open large anonymous upload capacity because the route feels “internal”. - Back up
static/uploads/alongsidedatabase.db, because content without media is still a broken publication.
The key pitfall is mismatched limits. If Nginx allows 10 MB while Flask still rejects at 8 MB, editors experience a failure that feels random even though the configuration is deterministic. If Flask is increased but Nginx stays at the default 1 MB, the application never sees the request and your app log stays quiet. Another pitfall is turning proxy_request_buffering off without a clear reason. For this kind of blog, streaming request bodies directly to a synchronous upstream is usually more complexity than value. One more pitfall is ignoring disk usage in the temp and upload directories. Request-body buffering, Pillow processing, and monthly upload storage all consume space that should be tracked explicitly.
Your log review should reflect those boundaries. If editors report upload failures, check the access log and the service log before touching configuration:
sudo nginx -t
sudo systemctl reload nginx
sudo journalctl -u flask-blog -n 100 --no-pager
sudo grep ' /admin/upload ' /var/log/nginx/access.log | tail -n 20
sudo grep ' 413 ' /var/log/nginx/access.log | tail -n 20
df -h /var/www/blog /var/lib/nginx
That command set is intentionally small. It answers the only questions that matter in a single-server Flask blog: did the proxy reload cleanly, did the app log a route-level failure, did Nginx reject or record the request, and is the server running out of space?
Automation and maintenance
Once the baseline is stable, turn it into maintenance rather than folklore. Every change to Nginx, image quality, timeout values, server class, or admin workflow should include a repeatable validate sequence. I recommend three checks after each change. First, upload a normal small image and confirm the editor receives a success response plus a public URL that opens over HTTPS. Second, upload a file slightly above the documented limit and confirm you get a predictable 413 or a clear application-side message. Third, review the log output for the attempt and confirm the observed layer matches the expected one.
For a site like Stepnex, automation should stay proportional. You do not need a large observability platform to manage uploads well. A morning review can parse yesterday’s /admin/upload events, count 4xx and 5xx responses, and record the latest backup time for static/uploads/. If you already run deployment or publishing scripts, extend that workflow with one upload smoke test and one storage check. The point of automation here is not sophistication; it is preventing the same class of incident from being rediscovered during every release.
Maintenance also includes recovery. A lot of teams restore the SQLite or MySQL data and call the site healthy, then discover that old articles reference missing images. Treat upload storage as a first-class asset. During a recovery review, open a published article with historical images, confirm the files still exist under the expected monthly path, and verify that public URLs resolve correctly through Nginx. That review step closes the loop between upload success, publication quality, and long-term operability.
Review checklist
Close each incident or deploy with a short review checklist instead of a vague note that “the limit was increased”:
- When to use: is the route still meant for blog illustrations, or has it quietly become a large asset pipeline?
- Configuration: do Nginx
client_max_body_size, FlaskMAX_CONTENT_LENGTH, and the editor’s documented limit still match? - Command evidence: did
nginx -t, service reload, upload smoke tests, and disk checks all pass after the change? - Log evidence: can you tell whether the failure was a proxy
413, an application400, a client499, or an upstream timeout? - Recovery review: are
static/uploads/and the database backed up and validated together?
That is the difference between “uploads usually work” and a production upload pipeline that survives maintenance. In a long-lived Flask blog, the winning move is not to make limits bigger. It is to keep the proxy, the application, the processing code, the log signals, and the review routine aligned.