About SnapEcho
SnapEcho gathers an event's photos, videos and voice notes into one shared gallery. Guests scan a QR code and add what they captured straight from the browser, with no app to install and no account to create.
A project by Justin Monteza · Crafted in Edmonton, Alberta · Hosted in Canada
See the demo galleryWho made it
I'm Justin Monteza, a software engineer in Edmonton. I designed, built and run all of SnapEcho myself: the product, the code, the Azure infrastructure, and the printable QR signs that go on the tables. Claude Code is my pair programmer.
The rest of this page is for fellow software engineers, or anyone curious about how it is put together.
At a glance
- Back end
- .NET 10 and ASP.NET Core MVC, with server-rendered Razor views
- Front end
- TypeScript and Bootstrap, with no front-end framework; every script and style is self-hosted except Cloudflare's Turnstile widget
- Data
- PostgreSQL, through Entity Framework Core and Npgsql
- Media
- ImageSharp for photos, ffmpeg for audio and video, QuestPDF and QRCoder for print
- Cloud
- Microsoft Azure, Canada Central region
- Services
- Resend for email, Cloudflare Turnstile for bot checks, Sentry for error monitoring
- Delivery
- GitHub Actions, xUnit and Dependabot
How an upload travels
-
In the browser
A guest picks a photo, or records a clip right in the page through the camera or microphone. Large photos are resized before they leave the device, which also drops their location and other metadata.
-
At the server
The app checks the file's type, size and length, saves a placeholder record in PostgreSQL, stages the raw file in Blob Storage, puts a job on a Storage Queue, and answers straight away. The guest is back in the gallery in a moment, where their upload shows as processing.
-
In the background
A worker hosted inside the same app works through the queue. A photo is kept as uploaded only when it really is the format it claims and carries no metadata; anything else is re-encoded without it. Videos get an MP4 and a thumbnail: clips browsers already play are copied into MP4 untouched, and the rest are transcoded. Voice notes in WAV, OGG or WebM are converted to AAC so every browser can play them. A job that fails for a passing reason goes back on the queue, and one that keeps failing is marked failed rather than retried forever.
-
Back in the gallery
The page polls a small status endpoint and reloads once the item is ready. Gallery images, players and download links then go straight to Blob Storage through short-lived signed links.
Running on Azure
App Service on Linux
Runs the web app and its background worker together, in one process.
Azure Database for PostgreSQL
Holds accounts, events and media records, on Flexible Server.
Blob Storage
Holds every upload in a private container. Nothing in it is public; browsers are handed signed links that expire within hours.
Storage Queues
Carry the media jobs, so an upload that has been accepted survives a restart before it is processed.
No passwords or keys for Azure. The app signs in to PostgreSQL and Storage as its managed identity, with short-lived Microsoft Entra tokens, and GitHub Actions deploys through OpenID Connect federation. There is no database password or publish profile to leak or rotate.
All of it runs in the Canada Central region, so accounts, event records and uploads are stored in Canada. The services outside Azure, and where they keep data, are listed in the Privacy Policy.
Decisions and trade-offs
One app, not a fleet of services
The website and the media worker ship as one deployable, with a queue between them. The queue already gives the durability and retries a separate service would; splitting them would add a deploy, a network hop and a bill without solving a problem SnapEcho has.
Built to run lean
Heavy work is deliberately kept serial, and ffmpeg runs below web-request priority, so a long video transcode yields to the pages people are waiting on. Event reads go through HybridCache with tag-based invalidation, and responses are Brotli-compressed.
Watermarked sharing, two ways
A shared photo is watermarked on request and cached for a few minutes. Video can't work like that, because burning a badge into every frame takes minutes, so each video's share copy is rendered ahead of time on a second, lower-priority queue, and rebuilt when the event is renamed.
Private by default
Every page is hidden from search engines unless it opts in, so a new page can never put a guest's photos in a search index. Sign-in is required unless a route explicitly opts out. Uploaders' file names are no longer stored.
Failing safely
Bot checks fail closed: if Cloudflare cannot be reached, the form refuses rather than letting everyone through. Error reports have every query-string value scrubbed before they are sent, because that is where password-reset tokens travel.
How changes ship
- Every pull request builds the app, runs more than 3,000 xUnit tests, and gets automated code, security and performance reviews from Claude.
- Merging to the main branch publishes a ReadyToRun build and deploys it to App Service.
- Deploy builds upload their debug symbols to Sentry, so production stack traces show file and line, and every build strips them from the package it produces. The build fails if either step quietly stops happening.
- Database migrations never run on startup. I read each one as idempotent SQL and apply it by hand, timed against the deploy so the running version never meets a schema it does not understand.
Get in touch
Want to talk about how SnapEcho is built, or about working together? Found a bug, or have an idea for the product?