Building PumbaPhoto: A Client Gallery and Proofing Platform for Photographers

How we built a self-hosted photography proofing platform that handles thousands of high-res images on a small VM — with NAS-backed storage, antivirus scanning, smart edit matching, and a complete gallery workflow.

Professional photographers have a workflow problem. After every shoot, they need to upload hundreds or thousands of high-resolution images, share a curated gallery with their client, let the client pick their favorites, then deliver the final edits. The platforms that handle this — Pixieset, ShootProof, Pic-Time — charge monthly subscriptions that scale with storage usage. A working photographer with a few terabytes of client galleries can easily spend $300-500/year on gallery hosting alone, on top of the storage costs they're already paying for local backups.

We built PumbaPhoto to solve this. It's a self-hosted client gallery and proofing platform that runs on a small virtual machine, stores photos on network-attached storage, and gives photographers complete ownership of their client workflow — from upload to selection to edit delivery. No per-image fees. No storage tiers. No monthly subscription to access your own work.

This article covers the technical architecture, the design decisions, and the real-world problems we had to solve to make it work reliably with terabytes of photo data.

The Tech Stack

Framework Django (Python 3)
App Server Gunicorn (4 workers, 2 threads each)
Reverse Proxy Nginx
Database PostgreSQL 15
Media Storage NAS via NFS (5.5 TB mount)
Image Processing Pillow (PIL) with EXIF-aware transforms
Antivirus ClamAV (continuous watch mode)
Email MXRoute SMTP (background threads)
Export openpyxl (styled XLSX spreadsheets)
Deployment Systemd with security hardening

Django was the natural choice for this project. The admin interface, ORM, authentication system, and form handling give you a massive head start on the kind of CRUD-heavy workflow application that a gallery platform demands. Python's Pillow library is the de facto standard for server-side image processing, and Django's file upload handling is mature and well-documented. Gunicorn runs 4 worker processes with 2 threads each behind Nginx, which handles SSL termination, static file serving, and upload buffering.

The Storage Problem: 5.5 TB on a Small VM

Here's the fundamental tension in hosting a photography platform: the application itself is lightweight — a Django app with a PostgreSQL database barely needs 2 GB of RAM and a few gigabytes of disk. But the photos are enormous. A single wedding shoot can produce 2,000-4,000 RAW files at 25-50 MB each. That's 50-200 GB per shoot, and a busy photographer might have 20-30 active galleries at any time.

Running the app on a VM with a massive local disk would work, but it's wasteful and inflexible. Instead, we separated compute from storage. The VM runs with a small OS disk — just enough for the operating system, application code, database, and logs. All photo storage lives on a NAS mounted via NFS, providing 5.5 TB of dedicated media space. The NAS handles redundancy (RAID), snapshots, and can be expanded without touching the VM.

The NAS-backed architecture means the VM stays small and cheap to back up, while photo storage scales independently. Adding another 4 TB of photo capacity is a drive swap on the NAS, not a VM migration.

This separation creates one critical configuration requirement: Django's FILE_UPLOAD_TEMP_DIR must point to the NAS mount, not the default /tmp on the OS disk. When a photographer uploads 500 photos in a single batch, the temporary upload files would fill the OS disk in seconds. By staging uploads directly on the NAS, we avoid that entirely.

# Django settings — point upload temp to NAS
FILE_UPLOAD_TEMP_DIR = "/mnt/nas/pumbaphoto/tmp"

# Media root also on NAS
MEDIA_ROOT = "/mnt/nas/pumbaphoto/media"

Nginx needs matching configuration. We set client_max_body_size 0 (unlimited) to avoid rejecting large batch uploads, and the request body temp directory is also pointed at the NAS. Upload timeouts are set to 1,200 seconds (20 minutes) to accommodate large batches over slower connections. Static files get served directly by Nginx with immutable caching headers, bypassing Django entirely for thumbnails and display images.

Upload Pipeline: From Camera to Gallery

The upload system supports batch sizes of 500+ photos per request, with a hard ceiling of 5,000 files. In practice, most photographers upload 200-800 images at a time. The pipeline has three stages: reception, scanning, and processing.

Stage 1: File Reception and Validation

When files arrive, Django validates the file type before writing anything to permanent storage. PumbaPhoto supports standard image formats (JPEG, PNG, TIFF) plus the RAW formats that professional photographers actually use: Canon CR2 and CR3, Nikon NEF, Sony ARW, Adobe DNG, Olympus ORF, Panasonic RW2, Pentax PEF, Samsung SRW, and Fujifilm RAF. Files that don't match an allowed type are rejected immediately.

Stage 2: ClamAV Antivirus Scanning

Every uploaded file is scanned by ClamAV before it's processed. This isn't paranoia — image files have been used as malware delivery vectors for decades, and accepting user uploads without scanning is an unnecessary risk. Our ClamAV integration runs as a management command in continuous watch mode, scanning pending uploads every 5 seconds.

# ClamAV watch mode — scans pending uploads every 5 seconds
# Runs as: python manage.py scan_uploads --watch

class Command(BaseCommand):
    def handle(self, *args, **options):
        while True:
            pending = Photo.objects.filter(scan_status="pending")
            for photo in pending:
                result = scan_file(photo.original.path)
                if result == "CLEAN":
                    photo.scan_status = "clean"
                    photo.save()
                    generate_thumbnails.delay(photo.id)
                else:
                    photo.scan_status = "infected"
                    photo.original.delete()
                    photo.save()
            time.sleep(5)

Files that pass the scan move to thumbnail generation. Files flagged as infected are deleted immediately, and the photo record is marked so the photographer sees what happened. The scan runs continuously as a systemd service, so there's no backlog buildup even during large batch uploads.

Stage 3: Multi-Tier Thumbnail Generation

Once a photo passes the antivirus scan, the system generates three derivative images asynchronously:

  • Grid thumbnails (300x300): Used in the gallery grid view. Small enough to load dozens at once without bandwidth issues, even on mobile.
  • View thumbnails (800x800): Used in the lightbox when a client clicks on a photo. Good enough for evaluation on a laptop screen without serving the full-resolution file.
  • Display images (2400x2400, 92% JPEG quality): The high-quality preview. Large enough for detailed inspection on a retina display, but compressed enough to be practical for web delivery.

Thumbnail generation runs asynchronously after upload, so the photographer's upload speed isn't bottlenecked by image processing. A batch of 500 photos uploads in minutes; the thumbnails generate in the background over the next 10-20 minutes depending on file sizes.

Every image runs through Pillow's ImageOps.exif_transpose() before any resizing. This is a detail that trips up a lot of image processing pipelines — cameras store orientation in EXIF metadata rather than physically rotating the pixel data. If you skip the transpose step, portrait photos show up sideways and images shot at unusual angles render incorrectly. The fix is one line of code, but missing it ruins the entire client experience.

from PIL import Image, ImageOps

def generate_thumbnail(source_path, output_path, size, quality=85):
    with Image.open(source_path) as img:
        # Fix camera orientation BEFORE resizing
        img = ImageOps.exif_transpose(img)
        img.thumbnail(size, Image.LANCZOS)
        img.save(output_path, "JPEG", quality=quality)

The Gallery Workflow

PumbaPhoto models the real-world photography workflow as a state machine with five stages:

  1. Active: Gallery is live. The photographer has uploaded originals and the client can browse and select favorites.
  2. Selections Submitted: The client has finished choosing their photos and submitted their selection list.
  3. Editing In Progress: The photographer is working on edits for the selected photos.
  4. Edits Delivered: Final edited photos have been uploaded and are available for the client to download.
  5. Archived: The project is complete. Gallery moves to cold storage.

Each transition triggers appropriate actions — email notifications, UI changes, and access control updates. A client can't submit selections on an archived gallery, and a photographer can't deliver edits before selections are submitted. The state machine prevents workflow confusion and keeps both parties in sync.

Client Proofing: Share Links and Selections

When a gallery is ready for the client, the photographer generates a share link — a URL with a UUID token that grants access without requiring the client to create an account. No passwords to forget, no registration friction. The client clicks the link and lands in a responsive gallery with all their photos displayed in a grid.

Clicking any photo opens a lightbox for full-screen viewing. Clients can navigate through all images, mark their favorites with a single click, and add optional notes to individual photos ("Love this one but can you crop tighter?" or "Can we get this in black and white?"). The selection interface is deliberately simple — photographers told us that the biggest complaint about existing platforms is that clients get confused by too many options.

When the client is satisfied with their selections, they submit the list. The photographer receives an email notification with a summary, and the gallery state transitions to "Selections Submitted." The photographer's dashboard shows the selected photos highlighted, along with any per-photo notes.

Smart Edit Matching

This is the feature that saves photographers the most time. After editing the selected photos in Lightroom, Photoshop, or Capture One, the photographer uploads the edited files back to PumbaPhoto. The system automatically matches each edit to its original based on filename.

The matching is intentionally fuzzy to accommodate real-world naming conventions. Photographers rarely export edits with the exact original filename. They append suffixes: IMG_4521_edit.jpg, IMG_4521_edited.jpg, IMG_4521_v2.jpg, IMG_4521_final.jpg, IMG_4521-edit.jpg. The matching system strips these known suffixes and compares against the original filenames:

# Suffix variants for edit matching
EDIT_SUFFIXES = [
    "_edit", "_edited", "_v2", "_final",
    "-edit", "-edited", "-v2", "-final",
]

def match_edit_to_original(edit_filename, originals):
    stem = Path(edit_filename).stem.lower()
    for suffix in EDIT_SUFFIXES:
        if stem.endswith(suffix):
            stem = stem[:-len(suffix)]
            break
    for original in originals:
        if Path(original.filename).stem.lower() == stem:
            return original
    return None

When a match is found, the edit is automatically linked to the original photo record. The photographer's dashboard shows match status — which selections have edits, which are still pending. Once all edits are uploaded and matched, the photographer can deliver the gallery with one click, transitioning the state to "Edits Delivered" and triggering a notification email to the client.

Without the smart matching system, a photographer delivering 150 edited photos would need to manually associate each edit with its original — a tedious, error-prone process that takes 20-30 minutes. The automatic matching does it in seconds with zero manual intervention.

XLSX Export with openpyxl

Photographers often need selection data outside the platform — for invoicing, production planning, or sharing with second shooters and assistants. PumbaPhoto exports selection data to styled XLSX spreadsheets using openpyxl. The export includes a gallery info sheet with client details, shoot date, and selection count, plus a detailed selections sheet with photo filenames, client notes, and selection timestamps. Headers are formatted with bold text and borders for readability.

Email Notifications

The platform sends email notifications at key workflow transitions: when an upload batch completes, when a client submits selections, and when edits are delivered. Emails are sent via MXRoute SMTP in background threads so they never block the request/response cycle. If the SMTP server is temporarily unavailable, the request still succeeds — the notification failure is logged but doesn't affect the core workflow.

Security: Defense in Depth

A platform that accepts file uploads and stores client photos needs security at every layer. Here's what we implemented:

Login rate limiting: Five failed attempts per IP address within a five-minute window triggers a temporary lockout. This prevents brute force attacks on photographer accounts without affecting legitimate users who mistype a password once or twice.

CSRF protection: Django's built-in CSRF middleware validates tokens on all state-changing requests. The share link system uses UUID tokens that are separate from session authentication.

Secure cookies: Session cookies are configured with HttpOnly (preventing JavaScript access), SameSite=Lax (preventing cross-site request attachment), and the Secure flag (requiring HTTPS transmission).

HTTP security headers: Nginx adds HSTS (forcing HTTPS), Content Security Policy headers restricting script and resource origins, and standard protections against clickjacking and MIME sniffing.

File type validation: Beyond the extension check, uploaded files are validated against expected magic bytes. The supported format list includes professional RAW formats (CR2, CR3, NEF, ARW, DNG, ORF, RW2, PEF, SRW, RAF) alongside standard web formats.

ClamAV scanning: As described above, every uploaded file is scanned before processing. This catches malware-laden files that might pass extension and magic byte checks.

Systemd hardening: The application service runs with strict security directives — NoNewPrivileges=true, ProtectSystem=strict, PrivateTmp=true, and ReadWritePaths limited to the application directory, log directory, and NAS mount. The process can't write anywhere else on the filesystem, even if the application is compromised.

# Systemd service security directives
[Service]
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
ReadWritePaths=/opt/pumbaphoto /var/log/pumbaphoto /mnt/nas/pumbaphoto

Nginx Configuration for Large Uploads

Handling batch uploads of 500+ high-resolution photos requires specific Nginx tuning. The default client_max_body_size of 1 MB would reject virtually every upload. We set it to 0 (unlimited) and let the application layer enforce size limits per file. The request body temporary directory points to the NAS mount — same reasoning as Django's temp directory — to prevent large uploads from filling the OS disk.

Proxy timeouts are set to 1,200 seconds (20 minutes). A batch upload of 500 RAW files over a residential upload connection can legitimately take 10-15 minutes. The default 60-second timeout would kill these uploads mid-transfer, creating a terrible user experience. Static files — thumbnails and display images — are served directly by Nginx with immutable caching headers, keeping Gunicorn free to handle application logic.

What Makes This Different from Pixieset

The value proposition isn't that PumbaPhoto has more features than Pixieset or ShootProof. Those platforms have years of development, mobile apps, print fulfillment integrations, and large teams. The value is ownership and economics.

A photographer hosting 3 TB of client galleries on Pixieset's top tier is paying $400+/year. With PumbaPhoto running on a small VM and NAS storage, the marginal cost of adding another terabyte is the cost of a hard drive. There are no per-image fees, no storage tiers, no feature gates. The photographer owns the platform, the data, and the client relationship completely.

Self-hosting also means the photographer's data never leaves their infrastructure. For clients in industries with data sensitivity requirements — corporate headshots for financial firms, medical professional portraits, legal team photos — this can be a meaningful differentiator.

Photographer Dashboard

The photographer's dashboard is the command center for the entire workflow. From here, they can create new galleries, upload originals in large batches, view real-time upload progress, and manage gallery states. When a client submits selections, the dashboard highlights the selected photos and displays any per-photo notes.

The edit delivery flow is streamlined: upload edited files, watch the smart matching system link them to originals in real time, review the match results, and deliver with one click. If the system can't match a file (unusual filename format, or the photographer edited a photo the client didn't select), it's flagged for manual resolution.

The XLSX export function generates professionally styled spreadsheets that photographers can hand to clients, assistants, or accountants without reformatting. Gallery metadata, selection counts, and per-photo details are organized across multiple sheets with consistent formatting.

Lessons Learned

The biggest lesson was that storage architecture matters more than application architecture for media-heavy platforms. We spent more time getting the NAS mount, temp directories, and Nginx buffering right than we spent on any single Django feature. When your application handles terabytes of data, every file path configuration becomes a potential point of failure.

The EXIF transpose issue was a humbling reminder that image processing is full of invisible edge cases. Photos looked fine in testing because test images happened to be shot in landscape orientation. The first real batch of portrait photos from a client shoot all displayed sideways. One line of code fixed it, but only after we understood why it was happening.

ClamAV integration added minimal overhead (seconds per file) but provided meaningful peace of mind. The continuous watch mode pattern — a management command that polls every 5 seconds — turned out to be more reliable than trying to scan synchronously during upload. It decouples the scan step from the upload step, so a slow scan doesn't timeout a large upload request.

PumbaPhoto is live at pumbaphoto.com. If you're a photographer tired of paying monthly gallery fees, or a business that needs a custom media management platform built for your specific workflow, reach out to us.

← All Blog Posts

Need a custom platform built for your business?

Start a Conversation