Step 3: Performance and limitations
Thumbnail generation blocks the request. The first visitor to a gallery triggers thumbnail generation for every image in that directory. A gallery with 200 images will hang for several seconds while Pillow processes them sequentially. For most use cases — a personal photo gallery with a few dozen images per folder — the delay is a second or two and happens exactly once. For larger collections, pre-generate thumbnails before deploying or move the thumbnail work to a background task. The synchronous approach is deliberate: it keeps the app simple at the cost of first-load latency.
Pillow quality and disk usage. Thumbnails are saved at JPEG quality 82, which produces files around 15–30 KB each. A gallery of 100 images adds roughly 2–3 MB of thumbnail cache to the _thumbs directory. Acceptable at any realistic scale. If disk space is tight, add a cron job that cleans _thumbs directories older than N days — the thumbnails regenerate on next access.
Concurrent requests. Gunicorn runs two workers. Two people browsing different galleries simultaneously is fine. Ten people hammering the same gallery while thumbnails generate will queue and time out. For a personal site, two workers is plenty. If you're serving a public-facing gallery with real traffic, bump the worker count and add a limit_req zone at the Nginx level. The rate limiting guide covers the setup.
Step 4: Production hardening
The app as written in Step 1 is deliberately simple — one Python file, one proxy_pass, no moving parts. It's the right starting point for understanding the flow. Before pointing real traffic at it, three changes move it from "works on a test subdomain" to "won't fall over under load."
Rate limiting
Every request — gallery pages, thumbnails, full-size images — hits Gunicorn in the Step 2 configuration. Two sync workers means two concurrent requests. After that, requests queue. A scraper downloading every full-size original behind a gallery page can tie up both workers in seconds.
Add a dedicated rate-limit zone to the http block of /etc/nginx/nginx.conf, alongside the existing zones:
limit_req_zone $binary_remote_addr zone=gallery:1m rate=10r/s;
The zone name is arbitrary — gallery keeps it self-documenting. 1m of shared memory tracks roughly 16,000 IPs, more than enough for a personal site. 10r/s is the steady-state rate: 10 requests per second refill into the bucket. Combined with a burst allowance, it absorbs the thumbnail storm of a normal page load while capping sustained throughput to what two workers handle comfortably.
Apply it to a revised server block that also moves static file serving to Nginx:
server {
include snippets/listen.conf;
server_name gallery.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
access_log /var/log/nginx/gallery.access.log combined if=$log_ip;
error_log /var/log/nginx/gallery.error.log;
include snippets/drop.conf;
root /home/username/www/galleries;
# Cached thumbnails — Nginx serves directly from disk
location ~ ^/([^/]+)/_thumbs/ {
try_files $uri @gallery;
}
# Full-size images — Nginx serves directly from disk
location ~ ^/([^/]+)/[^/]+\.(jpg|jpeg|png|gif|webp)$ {
try_files $uri @gallery;
}
# Gallery pages, index, everything else
location / {
limit_req zone=gallery burst=60 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5001;
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;
}
# Fallback — cache misses and missing files hit Python
location @gallery {
limit_req zone=gallery burst=30 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5001;
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;
}
}
How the request flow changes. Before, every request hit proxy_pass — Python touched everything. Now Nginx checks disk first for thumbnails and full-size images. A request for /urban/_thumbs/urban_035.webp resolves to /home/username/www/galleries/urban/_thumbs/urban_035.webp. If the file exists — and it will for every visitor after the first — Nginx serves it with sendfile and Python never sees the request. If the file is missing, Nginx internally redirects to the @gallery named location, which proxies to Python.
The gallery index page and any malformed paths don't match the image or thumbnail regex, so they fall through to location / and proxy to Python as before. The limit_req on that block caps sustained throughput to 10 requests per second with a burst of 60 — enough for a gallery page with 50+ thumbnails firing all at once on first load. A human clicking between galleries won't notice. A bot methodically downloading every full-size image hits 429s within seconds.
The @gallery fallback has its own limit_req with a lower burst of 30. In normal operation it's barely touched — thumbnails are generated during the gallery page request, before the browser ever asks for them — so the only traffic through this path is the rare edge case: a direct thumbnail request for an image that hasn't been cached yet, or a race condition where the gallery page is still generating thumbnails when the browser's requests arrive.
Directory permissions. For Nginx to traverse the path to the gallery root, the www-data user needs execute permission on each directory in the chain:
chmod 755 /home/username
chmod 755 /home/username/www
chmod 755 /home/username/www/galleries
The gallery subdirectories and the files inside them need to be readable by www-data. If the app user and www-data share a group, setting the gallery root's group and permissions accordingly is cleaner than making files world-readable.
drop.conf — bots never reach Python. The include snippets/drop.conf; line pulls in a blocklist of file extensions and hidden paths that scanners probe for — .env, .git, wp-admin, and so on. Nginx evaluates location blocks in order of specificity, and the regex locations in drop.conf are more specific than the catch-all location /. A request for /.env is denied with a 403 before it ever reaches the rate limiter or the Python worker. The Securing Nginx and PHP guide covers the full drop.conf pattern, including the honeypot.conf variant for HTTPS catch-all servers.
Modified application code. Only one function needs to change: serve_thumbnail. In the Step 1 version, a missing thumbnail returned a 404. In the production version, a cache miss triggers generation — which is the whole reason Nginx fell back to Python in the first place:
@app.route("/<gallery>/_thumbs/<filename>")
def serve_thumbnail(gallery, filename):
"""Serve a cached thumbnail — generate on cache miss."""
name = safe_gallery(gallery)
if not name:
abort(404)
if ".." in filename:
abort(404)
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
abort(404)
allowed_thumb_dir = os.path.abspath(
os.path.join(GALLERY_ROOT, name, "_thumbs"))
target = os.path.abspath(
os.path.join(GALLERY_ROOT, name, "_thumbs", filename))
if (not target.startswith(allowed_thumb_dir + os.sep)
and target != allowed_thumb_dir):
abort(404)
# Generate on cache miss — the only reason Nginx fell back to us
if not os.path.isfile(target):
ensure_thumbnail(name, filename)
if not os.path.isfile(target):
abort(404)
return send_file(target)
The rest of app.py is unchanged from Step 1 — serve_image, gallery_view, index, and all the helpers remain identical. The path-safety model is the same three layers: safe_gallery() normalization, string-level .. rejection, and os.path.abspath() prefix verification. Nginx's try_files inside root can't escape the gallery directory, so files served by Nginx directly are implicitly constrained to the same path.
After updating the Nginx configuration and the serve_thumbnail function, test and reload:
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart gallery
In the production configuration, Python handles roughly two requests per page view — the gallery HTML and the index redirect — plus the occasional thumbnail cache miss. Nginx handles the remaining 95%+ of requests directly from disk. Two Gunicorn workers are now idle most of the time, and the rate limiter ensures they stay that way under load.
What to build from here
With Nginx serving static files and rate limiting in place, the gallery is ready for real traffic. A few directions worth exploring on top of this baseline:
- Add WebP thumbnails. Generate WebP copies alongside JPEG thumbnails and serve them conditionally with the
Acceptheader. The WebP guide covers the Nginx content-negotiation pattern — the samemap $http_accept $webp_suffixapproach works for gallery thumbnails. Add a secondtry_filesvariant that checks for the.webpversion first. - Add upload support. A POST endpoint that accepts image files, validates them, and writes them to the gallery directory turns this into a self-hosted photo sharing tool. A follow-up article — the Webhook Receiver extension of the Flask guide — will cover the upload half. The gallery app is the front-end half.
- Add basic auth. If the gallery is private, an
auth_basicblock in Nginx gates the entire site. The Protecting Folders with Nginx guide covers password files and IP allowlists.
Security considerations
The gallery serves files from a directory you control. A few things to keep in mind:
- Path traversal is handled — three layers of protection:
safe_gallery()normalization, string-level filename checks for.., andos.path.abspath()prefix verification against the allowed directory (the_thumbssubdirectory itself for the thumbnail route). An attacker can't read/etc/passwdthrough a crafted gallery name, an encoded traversal sequence, or a symlink pointing outside the gallery root. - Extension whitelist — only
.jpg,.jpeg,.png,.gif, and.webpare served. Drop a.phpfile in the gallery directory and the app returns a 404. - No write access from the web. The app only reads files and writes thumbnails. There is no upload endpoint. An attacker who compromises the app can't modify your images — they'd need filesystem access as the
usernameuser, which requires SSH. - Don't serve sensitive directories. The gallery root should contain only images. Don't point it at
/home/usernameor any directory with config files, SSH keys, or database backups.
This guide documents a self-contained Python Flask application that generates a responsive image gallery from a directory of photos. Thumbnails are generated once with Pillow and cached to disk. The app is a single file — CSS and JavaScript are passed as Python variables and rendered inline with render_template_string. Built on the same systemd/Gunicorn/proxy_pass pattern as the Flask + Nginx guide. Step 4 adds rate limiting, Nginx serving static files directly from disk via try_files + named location fallback, drop.conf integration, and cache-miss thumbnail generation. Path safety is enforced at three layers: os.path.normpath() normalization, string-level traversal rejection, and os.path.abspath() prefix verification against the allowed directory.
Compatibility: Flask 3.x, Gunicorn 22.x, Pillow 11.x. The path-sanitization logic uses os.path.normpath() and os.path.abspath() which are available in all Python versions. The CSS grid layout works in all modern browsers. The lightbox JavaScript is ES6 and runs without a build step or framework. The hardened Nginx configuration in Step 4 is compatible with Nginx 1.24+.
2026-06-10: Initial publication — drop-in Python image gallery with Pillow thumbnail generation, CSS grid layout, vanilla JavaScript lightbox, and three-layer path-traversal protection (normpath normalization, string-level .. block, abspath prefix verification). Cross-linked to the Flask + Nginx guide (infrastructure pattern), WebP guide (image optimization), rate limiting guide (public-facing hardening), and folder protection guide (basic auth).