Overview
The Arcanum runs on a Raspberry Pi with a small SD card, so a growing photo library was never going to live there. The solution behind the Visage Obscurus gallery: store the images on a NAS with room to spare, have the Pi mount that share read-only and serve it through Nginx, and let Cloudflare cache every image at its edge. The NAS never gets a public door of its own, and — critically — no photo ships with the GPS coordinates of where it was taken.
The full chain, end to end:
NAS (photos live here)
└─ NFS export, read-only
└─ Raspberry Pi ── mounts /mnt/nas_photos (ro)
└─ Nginx ── serves it at /photos/
└─ Cloudflare ── caches at the edge
└─ browser ── the gallery fetches a JSON manifest
| Component | Detail |
|---|---|
| Storage | NAS (Synology or any NFS-capable box) |
| Protocol | NFS, read-only export to the LAN |
| Mount | Pi mounts the share read-only via systemd automount |
| Web Server | Nginx alias under /photos/ |
| Edge Cache | Cloudflare, 30-day Cache-Control |
| Pipeline | Python 3 + Pillow (metadata strip + variants) |
| Manifest | A single photos.json the gallery fetches |
Step 1 — Export the Share from the NAS
On the NAS, create a dedicated shared folder for the photos and enable the NFS service. Then grant the LAN subnet read-only access — the web server only ever needs to read.
On Synology DSM: Control Panel → File Services → NFS to enable NFS, then Shared Folder → your folder → Edit → NFS Permissions → Create:
# NFS rule
client: 10.0.0.0/24 # the whole home subnet
privilege: Read Only
squash: Map all users to admin # so the Pi reads every file
security: sys (AUTH_SYS)
DSM shows a mount path at the bottom of the dialog — something like /volume1/Photos. Note it exactly; the Pi needs it character-for-character, spaces included.
Step 2 — Mount It on the Pi (Read-Only)
Install the NFS client and create a mount point:
sudo apt install nfs-common -y
sudo mkdir -p /mnt/nas_photos
Add a resilient entry to /etc/fstab. The options are doing real work here:
# /etc/fstab
# NAS_IP = your NAS's LAN IP. \040 escapes a space in the path.
NAS_IP:/volume1/Photos /mnt/nas_photos nfs \
ro,noatime,nofail,_netdev,x-systemd.automount,x-systemd.idle-timeout=600 0 0
ro— read-only. The web server can never write back to the NAS.x-systemd.automount— mount on first access, not at boot.nofail+_netdev— a NAS reboot can't wedge the Pi; boot proceeds regardless.noatime— don't write access timestamps back (pointless on a read-only mount).
Reload and trigger the mount:
sudo systemctl daemon-reload
sudo mount -a
ls /mnt/nas_photos # should list the share's contents
If the path has a space in it, quote it when mounting by hand: sudo mount -t nfs "NAS_IP:/volume1/My Photos" /mnt/nas_photos.
Step 3 — Serve It Through Nginx
Point a /photos/ location at the mount with alias. Nginx streams each file straight off the NAS as if it were local:
# inside the server { } block
location /photos/ {
alias /mnt/nas_photos/;
autoindex off; # don't list the folder publicly
expires 30d; # the load-bearing cache header
add_header Cache-Control "public";
access_log off;
}
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Step 4 — Let Cloudflare Cache the Edge
Because the site already sits behind a Cloudflare Tunnel, that Cache-Control: public, max-age=2592000 header is what lets Cloudflare hold each image at its edge. The first request for a photo is a MISS (fetched from the NAS); every request after is a HIT, served from Cloudflare and never touching the NAS again.
# Prove it from anywhere:
curl -I https://yourdomain.com/photos/test.jpg
# first call -> cf-cache-status: MISS
# second call -> cf-cache-status: HIT (now served from the edge)
The practical upshot: the NAS drives spin up once per image per region, then go quiet. A photo-heavy page stays fast and the home hardware stays idle.
Step 5 — Strip the Metadata Before It Ships
This is the step that matters most. A phone photo carries EXIF: camera and exposure data — and GPS coordinates. Serving the originals would publish the photographer's location in every single file.
The whole point of hiding behind Cloudflare is to keep the home network private. Publishing photos with embedded GPS would hand out the home's latitude and longitude in the file metadata — undoing all of it. Strip it.
An import script (Python + Pillow) rebuilds each image from raw pixels only, so no metadata survives into what gets served. Run it wherever the share is writable — the Pi's mount is read-only on purpose, so this runs on the machine that owns the photos (a Mac or PC with the share mounted read-write).
The core strip
# auto-rotate using the EXIF orientation flag FIRST,
# then rebuild from pixel bytes so ALL metadata is dropped
from PIL import Image, ImageOps
img = Image.open(src)
img = ImageOps.exif_transpose(img) # honor rotation
clean = Image.frombytes(img.mode, img.size, img.tobytes())
clean.convert("RGB").save(out, "JPEG", quality=86, optimize=True)
Rebuilding via frombytes() / tobytes() guarantees a clean canvas — EXIF, GPS, and ICC profiles are all gone, because only the raw pixel data is copied to the new image.
Keep the useful data, drop the sensitive data
Before discarding the EXIF, the script reads the harmless parts — camera, focal length, aperture, shutter, ISO, capture date — into a JSON manifest so the gallery can still show those details as captions. The location data is simply never read into it.
# photos.json — one entry per photo
{
"id": "20260712-a1b2c3d4",
"variants": [
{ "width": 400, "src": "gallery/20260712-a1b2c3d4-400.jpg" },
{ "width": 800, "src": "gallery/20260712-a1b2c3d4-800.jpg" },
{ "width": 1600, "src": "gallery/20260712-a1b2c3d4-1600.jpg" }
],
"lqip": "data:image/jpeg;base64,...", # tiny blur-up placeholder
"camera": "Apple iPhone", "focalLength": "5mm",
"aperture": "f/1.8", "shutter": "1/120s", "iso": 200
}
The script also emits responsive widths (400 / 800 / 1600px) and a tiny blurred placeholder (LQIP) so images fade in gracefully, and it keys each photo off a sha256 of the original bytes — so re-running the import is idempotent and resumable: already-imported photos are skipped, a crash halfway through loses nothing.
Step 6 — Verify the Strip Worked
Never trust; verify. Compare the original against the served derivative — the coordinates should simply vanish. On a Mac, mdls reads the Spotlight metadata:
mdls -name kMDItemLatitude -name kMDItemLongitude original.jpeg
# -> kMDItemLatitude = 40.7502... (real location)
# -> kMDItemLongitude = -73.9867...
mdls -name kMDItemLatitude -name kMDItemLongitude gallery/served-1600.jpg
# -> kMDItemLatitude = (null) (nothing shipped)
# -> kMDItemLongitude = (null)
Original reported coordinates; the served file reported (null). Proven, not assumed. (On Linux, exiftool -gps:all does the same job.)
Step 7 — The Gallery Reads the Manifest
The gallery page itself is static. On load it fetches /photos/photos.json, sorts newest-first, and builds each frame client-side — the exact same fetch-a-manifest pattern the rest of the Arcanum uses. The blurred placeholder shows instantly; the full image fades in over it; a click opens a lightbox with the camera details as a caption.
fetch('/photos/photos.json')
.then(r => r.json())
.then(list => {
list.sort((a, b) => (b.takenAt || '').localeCompare(a.takenAt || ''));
list.forEach(buildFrame); # hang each photo in a frame
});
Result
- ✅ Effectively unlimited photo storage on the NAS
- ✅ The Pi's SD card stays lean — it serves, it doesn't store
- ✅ NAS is never exposed to the internet — no public door of its own
- ✅ Cloudflare absorbs the traffic; the NAS mostly sleeps
- ✅ GPS / location metadata never ships — proven with
mdls - ✅ Responsive widths + blur-up loading for a fast, graceful gallery
- ✅ Idempotent imports — safe to re-run, nothing duplicated