A super lightweight, simple, self-hosted web app for collecting everyone's photos from a shared event into one box. No accounts, no app. Share a link (or QR code), and people open it on their phone, type their name, and upload. Useful for weddings, festivals, and trips, where the good photos end up scattered across a dozen phones.
Every family has one: a shoebox in the closet where the loose prints pile up. This is that box, for a group, and it fills itself.
A demo box, filled the way a real one fills itself: a few people, everyone's photos, no accounts. (Images above are placeholder gradients, not real photos.)
Important
Shoebox is built for casually sharing event photos, not for sensitive data, and not for durable storage.
- Files are stored unencrypted on the server's filesystem. Anyone with access to the host (or to its backups) can read every uploaded image. Only use a server you trust, and don't upload anything you'd mind others seeing.
- It is not durable. A single instance on a single filesystem, with no replication, versioning, or off-site backup, and boxes can be set to delete themselves.
- It is for casual sharing, not confidential material. Access rests on unguessable links and an optional shared password. There are no accounts and no audit logging.
Treat it as a convenient drop box: gather photos, then have people download what they want to keep. Back up the data directory yourself if a box matters.
For the organizer:
- Create a box, give it a name, and optionally set a password and an auto-delete date.
- You get a share link and QR code to hand out, plus a private admin link to keep.
For everyone else:
- Open the link (or scan the QR code). No sign-up.
- Type your name and drag in photos.
- Browse the gallery and grab everyone else's photos as a ZIP.
- Shareable boxes: one gallery per event, reachable by an 8-character code, a link, or a QR code. Boxes are unlisted: there is no directory and no way to browse other people's events.
- No accounts: uploaders just enter a name. A browser cookie remembers who they are, so their photos get a "you" badge and they can delete their own uploads.
- Optional passwords: a guest enters the password once per device; a signed cookie unlocks the box after that. Photo files live outside the web root and every image and download re-checks the cookie server-side, so a leaked image URL is useless without it.
- Fast gallery: a WebP thumbnail grid plus a full-screen lightbox backed by a downscaled web-safe proxy, so viewing is sharp without sending a full-size original over the wire. Filter by uploader; photos sort by capture time (EXIF).
- HEIC / HEIF from phones: decoded server-side, so iPhone photos get thumbnails and previews in every browser, not just Safari.
- GIFs that move: an animated GIF (or animated WebP) plays in the lightbox and when you hover its tile. The grid itself holds still, so a box full of GIFs doesn't flicker at everyone at once.
- Short videos, minimally: MP4/MOV/MKV/WebM clips can be dropped in alongside the photos. Each gets a poster frame so it has a tile in the grid, and downloads as the original file. Nothing is transcoded and there is no in-browser playback.
- Flexible downloads: a single photo, the whole box as a streamed ZIP, or "download others'": everything except your own uploads.
- Private admin link: the creator can rename the box, change or remove the password, adjust expiry, delete individual photos, or delete the whole box.
- Auto-expiry: a box can be set to delete itself a chosen number of days after the event.
- Deduplication: the same file uploaded twice is stored once (SHA-256).
- Designed to be nice to use: an editorial, print-inspired interface with a light/dark toggle, photos that "develop" in like film as the gallery loads, and layouts and tap targets that work on phones as well as desktops.
- Simple storage: files on disk plus a SQLite database. One directory holds everything.
Share a link, and the box fills itself. The whole flow — from the landing page to a full gallery, in light or dark — looks like this.
The landing page. No sign-up, no directory: start a box, or join one with a code.
The gallery, in dark mode. A WebP thumbnail grid that "develops" in like film as it loads. Photos sort by capture time (EXIF); filter by uploader; like the ones you love.
The lightbox and sharing. Tap a thumbnail for a full-screen, web-safe proxy view with download; hand out the box with a link or a QR code for the table.
| Full-screen lightbox | Share by link or QR |
|---|---|
![]() |
![]() |
Managing a box, and on a phone. The private admin link renames, re-passwords, sets auto-expiry, or deletes the box; guests upload from their phone in a couple of taps.
| Admin panel | On a phone |
|---|---|
![]() |
![]() |
With Docker, which is the easiest way to run it:
docker compose up -d --build
# open http://localhost:8080Or run a prebuilt image (published to GitHub Container Registry by CI):
docker run -d -p 8080:8080 -v shoebox-data:/data ghcr.io/domdom3333/shoebox:latestAll state (photos, database, cookie-signing keys) is kept in a volume mounted at /data.
Requires the .NET 10 SDK.
dotnet run --project src/Shoebox.Web
# open the URL it prints (e.g. http://localhost:5225)Data is written to src/Shoebox.Web/data/ (gitignored).
Set via environment variables (Shoebox__Key) or the Shoebox section of
appsettings.json:
| Setting | Default | Purpose |
|---|---|---|
DataPath |
/data (Docker), data (local) |
Root folder for the database, photos, and keys |
MaxFileSizeMb |
50 |
Per-file upload limit for photos |
MaxVideoFileSizeMb |
200 |
Per-file upload limit for videos |
MaxImagePixels |
100000000 |
Reject images above this many pixels (bomb protection) |
MaxImageDimension |
30000 |
Reject images wider or taller than this many pixels |
MaxAnimationPixels |
40000000 |
Total pixels (all frames) an animation may have and still be re-rendered as an animation |
UnlockAttemptsPerMinute |
10 |
Password-unlock attempts allowed per client IP per box per minute |
ThumbnailSize |
480 |
Longest edge of gallery thumbnails (px) |
DisplaySize |
1600 |
Longest edge of the lightbox proxy (px) |
DefaultExpiryDays |
0 |
Expiry pre-selected on the create form (0 = never) |
CookieLifetimeDays |
90 |
How long unlock, identity, and admin cookies last |
PublicBaseUrl |
(derived from request) | Public URL used in share links and QR codes |
FfmpegPath |
ffmpeg |
ffmpeg executable used for video poster frames (looked up on PATH) |
VideoPosterSeconds |
1 |
How far into a video the poster frame is taken |
Shoebox honours X-Forwarded-Proto and X-Forwarded-For, so HTTPS termination in
Caddy, nginx, or Traefik works out of the box. It is designed to run behind a single trusted
proxy; do not expose the container directly, since the forwarded headers it trusts (used for
the client IP behind rate limiting and for the Secure cookie flag) would then be spoofable.
Two things to set:
Shoebox__PublicBaseUrl: your public address, so QR codes and share links are correct.- Your proxy's request-body limit: at least the larger of
MaxFileSizeMbandMaxVideoFileSizeMb(for exampleclient_max_body_size 200m;in nginx).
Photos are accepted and decoded server-side (Magick.NET) in these formats:
| Format | Extensions |
|---|---|
| JPEG | .jpg, .jpeg |
| PNG | .png |
| GIF | .gif |
| WebP | .webp |
| HEIC / HEIF | .heic, .heif |
Every accepted upload gets a WebP thumbnail and lightbox proxy regardless of source format, so
formats that browsers can't display natively (HEIC/HEIF from phones in particular) still
appear in the gallery everywhere. The original file is always stored unmodified and is what the
Download button returns. Files of the wrong type, over MaxFileSizeMb, or that don't decode as
a real image within the pixel limits are rejected at upload.
The browser checks a file against these limits before sending it, so one that wouldn't be accepted is refused as soon as it's picked; the server checks again regardless. Uploads that fail outright answer with the reason in the body, and the page shows what it was told rather than filling in a cause of its own.
An animated GIF (or animated WebP) keeps its animation: the display proxy is written as an animated WebP, so it plays in the lightbox and while you hover its tile. Thumbnails stay still — the first frame only — so a grid with a dozen GIFs in it isn't a wall of motion. Animated tiles carry a GIF badge, and on touch devices, where there's no hover, tapping through to the lightbox is what plays them.
Every frame has to be decoded, resized and re-encoded, so animations larger than
MaxAnimationPixels counted across all frames (40 MP by default — say 60 frames of 800×600)
are still accepted, but get a still proxy instead. HEIC files are never treated as animations:
the extra images inside one are depth maps and previews, not frames.
GIFs that were uploaded before this existed still have their old still proxy; an admin can
re-render one with POST /api/media/{id}/reprocess.
Video support is deliberately minimal — enough that the clip from the evening ends up in the same box as the photos, and no more:
| Format | Extensions |
|---|---|
| MP4 | .mp4, .m4v |
| QuickTime | .mov |
| WebM | .webm |
| Matroska | .mkv |
A clip is stored untouched, appears in the grid as a still frame with a Video badge, and is
included in ZIP downloads like anything else. There is no in-browser playback and no
transcoding: opening a video shows the poster frame, and the Download button hands over the
original file to play locally. Videos are limited by MaxVideoFileSizeMb rather than
MaxFileSizeMb, and uploads whose bytes aren't really one of the containers above are rejected.
The poster frame is taken with ffmpeg, which the Docker image installs. If you run Shoebox
outside Docker without ffmpeg on PATH, videos still upload and download fine — they just show
a placeholder tile instead of a frame. (Point Shoebox__FfmpegPath at the binary if it lives
somewhere unusual; an admin can re-run the frame grab on a photo or video with
POST /api/media/{id}/reprocess.)
Each upload is decoded once and produces three files, so every context gets a right-sized image:
| Rendition | Size | Format | Used for |
|---|---|---|---|
| Thumbnail | ~480px | WebP | Gallery grid (always a single frame) |
| Display proxy | ~1600px | WebP | Full-screen lightbox (animated for animated sources) |
| Original | untouched | as uploaded | Downloads and ZIPs |
A video goes through the same three slots: ffmpeg pulls one frame out of it, and that frame becomes the thumbnail and the display proxy. Only the original is ever video.
/data
├── shoebox.db # SQLite: boxes + photo metadata
├── keys/ # Data Protection keys (signed cookies)
└── pools/{boxId}/
├── orig/{mediaId}.{ext} # untouched originals (photos and videos alike)
├── thumb/{mediaId}.webp # grid thumbnails
└── display/{mediaId}.webp # lightbox proxies
The database is versioned with EF Core migrations, applied automatically at startup — one directory, one file per change, no manual step when you upgrade the container.
Boxes created before migrations existed were built by EnsureCreated and have no migrations
history, so re-running the first migration against them would fail on tables that are already
there. Startup spots that case and records the baseline as applied instead, then migrates
normally from there. Nothing to do by hand, and nothing to delete.
Shoebox is intentionally lightweight, but the basics are done properly:
- Passwords are hashed with PBKDF2-SHA256 (100k iterations, per-hash salt, constant-time compare). Unlock is rate-limited per client IP per box.
- Access is enforced on every byte. Originals live outside
wwwroot; the thumbnail, display, original, ZIP, and QR endpoints all re-check the signed access cookie, so requesting a URL without it returns 404 rather than the file. - Cookies (access, admin, identity) are HttpOnly and SameSite=Lax; access and admin state is carried in tamper-proof, Data-Protection-signed cookies.
- The admin link carries a one-time capability key that is exchanged for a signed admin cookie and stripped from the URL on first use; POST handlers only accept the cookie, never the key.
- Uploads are limited by size and by pixel dimensions (decompression-bomb protection), restricted to a raster-image and video allowlist (no SVG or active content), and rejected if they don't decode (photos) or don't start with a real container header (videos). Stored filenames are random GUIDs, so there is no path traversal or overwrite.
- Responses set
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none', and a leanReferrer-Policy.
The uploader identity cookie (which powers the "you" badge, delete-your-own, and "download others'") is a convenience, not a security boundary. See the caveats at the top: this is not a tool for confidential material.
| Route | Purpose |
|---|---|
GET / |
Home: join a box by code, or create one |
GET/POST /Create |
Create a box |
GET /p/{code} |
Gallery (redirects to unlock if locked) |
POST /p/{code}/unlock |
Verify password, set access cookie (rate-limited) |
GET /p/{code}/admin |
Admin panel (via key or admin cookie) |
POST /api/p/{code}/media |
Multi-file upload |
GET /api/media/{id}/thumb · /display · /original |
Serve a rendition (access-checked) |
DELETE /api/media/{id} |
Delete an item (own, or as admin) |
GET /api/p/{code}/zip?mode=all|others |
Streamed ZIP download |
GET /api/p/{code}/qr |
QR code PNG for the box link |
src/Shoebox.Web/
├── Program.cs # DI, middleware, EF init, upload limits, rate limiting
├── ShoeboxOptions.cs # configuration
├── Data/ # EF Core context + Pool / Media entities, schema upgrade
├── Migrations/ # EF Core migrations (the only way the schema changes)
├── Services/ # boxes, media, per-kind handlers, rendering, ZIP, access…
├── Api/MediaEndpoints.cs # minimal-API upload/serve/zip/qr endpoints
├── Pages/ # Razor Pages (home, create, gallery, unlock, admin)
└── wwwroot/ # css/js/fonts (no build step, no framework)
.github/workflows/docker.yml # CI: build and publish the container image
Dockerfile · docker-compose.yml
.github/workflows/docker.yml builds the Docker image on every push and pull request, and
on pushes to the default branch (and version tags) publishes it to GitHub Container Registry as
ghcr.io/domdom3333/shoebox. Pull requests build only; they do not publish.
- Not for sensitive images. Files are stored unencrypted on disk. Don't upload anything private to a host you don't fully control. See the note at the top.
- Not long-term storage. There is no redundancy or automatic backup; back up the data directory if a box matters, and don't rely on it as anyone's only copy.
- The links are the credentials. Anyone with the box link (and password, if set) can view and upload; anyone with the admin link can manage. There is no email or account recovery.
- EXIF (including GPS) is preserved on originals, and anyone in the box can download them. Worth mentioning to privacy-conscious guests.
- Single instance, single filesystem. This is deliberately simple software for an event, not a scalable photo platform. It expects one server, one data folder, and a trusted reverse proxy in front.
ASP.NET Core Razor Pages (.NET 10), EF Core + SQLite, Magick.NET for all image rendering including HEIC/HEIF (self-contained native, no system packages required), QRCoder, and a vanilla JS/CSS front end, built as a multi-stage Docker image.
Copyright (C) 2026 Dominik Essenhofer and the Shoebox contributors.
Shoebox is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
The AGPL is the GPL plus one extra clause that matters for software like this: if you run a modified Shoebox where other people can reach it over a network, you have to offer those users the source of your modified version. Running it unmodified for your wedding, or hacking on it privately, needs nothing from you. That is the point of the licence here: the software is free and stays free, including for the guests who only ever see it through a browser.
To satisfy that clause, every page links to the source in the header and the footer. If you
deploy a fork, point those links at your source, not at this repository — see
src/Shoebox.Web/Pages/Shared/_Layout.cshtml.
Shoebox was released under the MIT licence up to and including commit
3e1d71f; that history remains
available under MIT, and third-party contributions made under MIT are included here under the
terms MIT permits. Everything from the relicensing commit onward is AGPL-3.0-or-later.
Bundled third-party components keep their own licences: Instrument Serif is under the SIL Open
Font License (src/Shoebox.Web/wwwroot/fonts/OFL.txt), and the NuGet dependencies listed under
Tech stack are covered by their respective licences.






