The Python Flask behind Nginx guide covered the infrastructure pattern — systemd, Gunicorn, proxy_pass. This guide builds something real on that foundation: a drop-in image gallery generator. Point it at a folder of images, and it produces a responsive thumbnail grid with a lightbox. No database. No CMS. No PHP. A single Python file, Pillow for thumbnails, and about fifty lines of CSS and JavaScript for the front end.
If you've ever wanted to share photos from your VPS without installing WordPress or wrestling with a static site generator's image pipeline, this is the lightweight alternative. Drop images in a subdirectory, visit the URL, and the gallery generates thumbnails on first load and caches them for every visitor after.
A live example of the production configuration runs at u.pbin.be/gallery — the code from Step 1 with the hardening from Step 4 applied (Nginx and the app are lightly adapted to serve from a subdirectory rather than a dedicated subdomain).
Prerequisites
- A VPS with Nginx and SSL configured — the Flask + Nginx guide covers the systemd/Gunicorn/proxy_pass setup
- Python 3.10+ with Flask and Gunicorn installed in a virtual environment
- Pillow installed in the same environment (
pip install pillow) - A domain or subdomain pointed to your server
Quick setup: virtual environment and dependencies
If you followed the Flask + Nginx guide you already have a venv running. If you're starting fresh, this gets you from zero to a working environment in four commands:
# Create the app directory and user (skip if the user already exists)
sudo useradd -r -m -s /bin/bash -G www-data username
sudo mkdir -p /home/username/apps/gallery
sudo chown username:username /home/username/apps/gallery
# Set up a Python virtual environment
cd /home/username/apps/gallery
python3 -m venv venv
source venv/bin/activate
# Install Flask, Gunicorn, and Pillow
pip install flask gunicorn pillow
The rest of this article assumes the venv is active and the dependencies are installed. The user needs to be in the www-data group for Nginx to proxy to the app — on RHEL or Fedora, use nginx instead.
Step 1: The gallery app
Save as /home/username/apps/gallery/app.py. The app serves three things: an index page listing available galleries, a thumbnail grid for each gallery, and full-size images. Thumbnails are generated once into a _thumbs subdirectory inside each gallery folder and reused on every subsequent request:
#!/usr/bin/env python3
"""Drop-in image gallery — point it at a folder, get a responsive
thumbnail grid with lightbox. No database, no CMS, one file."""
import os
from pathlib import Path
from flask import Flask, render_template_string, send_file, abort
app = Flask(__name__)
GALLERY_ROOT = "/home/username/www/galleries"
THUMB_SIZE = (320, 240)
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
# ── HTML templates (inline — one file, no template directory) ──────
INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Galleries</title>
<style>{{ style_css | safe }}</style>
</head>
<body>
<h1>Galleries</h1>
<ul class="gallery-list">
{% for g in galleries %}
<li><a href="/{{ g }}">{{ g }}</a></li>
{% endfor %}
</ul>
<p class="footer">Drop images in a subdirectory of the gallery root to create a new gallery.</p>
</body>
</html>"""
GALLERY_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ gallery }}</title>
<style>{{ style_css | safe }}</style>
</head>
<body>
<h1>{{ gallery }}</h1>
<p><a href="/">← Back to galleries</a></p>
<div class="grid">
{% for img in images %}
<a href="/{{ gallery }}/{{ img.name }}" class="thumb">
<img src="/{{ gallery }}/_thumbs/{{ img.name }}" alt="{{ img.name }}" loading="lazy">
</a>
{% endfor %}
</div>
<script>{{ lightbox_js | safe }}</script>
</body>
</html>"""
STYLE_CSS = """
*, *::before, *::after { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f5f5; color: #222; }
h1 { font-size: 1.6rem; margin-bottom: 8px; }
a { color: #0a5a3a; text-decoration: none; }
a:hover { text-decoration: underline; }
.gallery-list { list-style: none; padding: 0; }
.gallery-list li { margin: 8px 0; font-size: 1.2rem; }
.footer { color: #888; font-size: 0.85rem; margin-top: 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px; margin-top: 16px; }
.thumb { display: block; border-radius: 4px; overflow: hidden;
background: #ddd; aspect-ratio: 4/3; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.9); z-index: 999; justify-content: center; align-items: center; }
.lightbox.active { display: flex; }
.lightbox img { max-width: 95vw; max-height: 95vh; object-fit: contain; }
@media (max-width: 600px) {
body { padding: 12px; }
.grid { grid-template-columns: 1fr; }
}
"""
LIGHTBOX_JS = """
document.querySelectorAll(".thumb").forEach(link => {
link.addEventListener("click", e => {
e.preventDefault();
const img = document.createElement("img");
img.src = link.href;
const lb = document.createElement("div");
lb.className = "lightbox active";
lb.appendChild(img);
lb.addEventListener("click", () => lb.remove());
document.body.appendChild(lb);
});
});
"""
# ── Helpers ─────────────────────────────────────────────────────────
def safe_gallery(name):
"""Reject gallery names that try to escape the root directory."""
clean = os.path.normpath(name)
if not clean or clean == "." or clean.startswith("..") or clean.startswith("/"):
return None
gallery_path = os.path.join(GALLERY_ROOT, clean)
if not os.path.isdir(gallery_path):
return None
return clean
def image_files(gallery):
"""Return image files in a gallery, sorted by name."""
gallery_path = os.path.join(GALLERY_ROOT, gallery)
files = []
for f in sorted(os.listdir(gallery_path)):
if Path(f).suffix.lower() in ALLOWED_EXTENSIONS:
files.append(Path(f))
return files
def ensure_thumbnail(gallery, filename):
"""Generate a thumbnail if one doesn't already exist."""
thumb_dir = os.path.join(GALLERY_ROOT, gallery, "_thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, filename)
source_path = os.path.join(GALLERY_ROOT, gallery, filename)
if os.path.exists(thumb_path):
return
from PIL import Image
try:
img = Image.open(source_path)
img.thumbnail(THUMB_SIZE, Image.LANCZOS)
img.save(thumb_path, quality=82)
except Exception:
pass # Corrupt or unreadable image — skip it
def list_galleries():
"""Return all subdirectories in the gallery root."""
if not os.path.isdir(GALLERY_ROOT):
return []
return sorted(
d for d in os.listdir(GALLERY_ROOT)
if os.path.isdir(os.path.join(GALLERY_ROOT, d))
and not d.startswith(".") and not d.startswith("_")
)
# ── Routes ──────────────────────────────────────────────────────────
@app.route("/")
def index():
galleries = list_galleries()
return render_template_string(INDEX_HTML, galleries=galleries,
style_css=STYLE_CSS)
@app.route("/<gallery>")
def gallery_view(gallery):
name = safe_gallery(gallery)
if not name:
abort(404)
images = image_files(name)
# Generate all thumbnails synchronously on first load.
# For galleries with hundreds of images, this blocks the
# request — see the performance note in Step 3.
for img in images:
ensure_thumbnail(name, img.name)
return render_template_string(GALLERY_HTML, gallery=name,
images=images, style_css=STYLE_CSS,
lightbox_js=LIGHTBOX_JS)
@app.route("/<gallery>/<filename>")
def serve_image(gallery, filename):
"""Serve a full-size image."""
name = safe_gallery(gallery)
if not name:
abort(404)
# String-level guard catches traversal in the filename
if ".." in filename:
abort(404)
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
abort(404)
# Canonical path check — catches encoded traversal and symlink
# escapes that survive the string-level check
allowed_dir = os.path.abspath(os.path.join(GALLERY_ROOT, name))
target = os.path.abspath(os.path.join(GALLERY_ROOT, name, filename))
if not target.startswith(allowed_dir + os.sep) and target != allowed_dir:
abort(404)
if not os.path.isfile(target):
abort(404)
return send_file(target)
@app.route("/<gallery>/_thumbs/<filename>")
def serve_thumbnail(gallery, filename):
"""Serve a cached thumbnail."""
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)
if not os.path.isfile(target):
abort(404)
return send_file(target)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5001)
The app is self-contained — templates, CSS, and JavaScript are inline strings in the Python file. No template directory to configure, no static file routing, no build step. render_template_string evaluates the HTML with the gallery's images injected. Rather than relying on {% include %} — which would need a templates/ directory and files on disk — the CSS and JavaScript are passed as Python variables and rendered inline with {{ style_css | safe }} and {{ lightbox_js | safe }}. The | safe filter tells Jinja not to escape the content, since it's trusted markup. It's unconventional but deliberate: this app is one file you can read top to bottom and understand completely.
Path safety: Three layers of protection keep file access inside the gallery root. safe_gallery() normalizes the gallery name with os.path.normpath() and rejects empty strings, dots, and anything starting with .. or /. The filename routes add a string-level check for .. to catch traversal in the filename component. Finally, os.path.abspath() resolves both the allowed directory and the requested target to absolute paths and verifies the target falls under the allowed directory — catching encoded traversal sequences and symlink escapes that survive the string-level guards. For serve_thumbnail, the anchor is the _thumbs directory itself, so the check verifies the resolved target sits inside that specific subdirectory. A gallery name of ../../../etc normalizes to ../etc and is rejected at layer one. An encoded ..%2F..%2Fetc%2Fpasswd or a symlink pointing outside the gallery root is caught at layer three.
Step 2: systemd and Nginx
The systemd service follows the same pattern as the Flask guide. Create /etc/systemd/system/gallery.service:
[Unit]
Description=Gallery Flask application
After=network.target
[Service]
User=username
Group=www-data
WorkingDirectory=/home/username/apps/gallery
Environment="PATH=/home/username/apps/gallery/venv/bin"
ExecStart=/home/username/apps/gallery/venv/bin/gunicorn \
--workers 2 \
--bind 127.0.0.1:5001 \
app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable gallery
sudo systemctl start gallery
The Nginx server block is a standard reverse proxy. Since the gallery is for sharing, this version is public — no allow/deny restrictions:
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;
location / {
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;
}
}
Reload and test:
sudo nginx -t && sudo systemctl reload nginx
# Create a test gallery
mkdir -p /home/username/www/galleries/landscapes
cp ~/photos/*.jpg /home/username/www/galleries/landscapes/
curl https://gallery.example.com/landscapes