Guests upload photos and videos from any phone, and the files stay on the client's own server

Wedding guests scan a QR code, open one secret address and upload photos and videos straight from the phone: no app, no account, no login. Files of up to 5 GB land on the client's own disk, and the couple watches the gallery fill up during the party. One evening, no second chance, and phones from six years back.

  • Files of up to 5 GB, resumable
  • Browsers from six years back, tested on real engines
  • Everything on the client's own server, set up with one Ansible command
A phone with a QR code, a file split into chunks with the last one orange, three parallel streams to a server with its own disk, and a gallery grid filling up with one new orange tile

A private client: a wedding party with guests aged 20 to 80 and whatever phone they brought

Challenge

Five assumptions shaped the project: up to 5 GB per file, weak Wi-Fi on phones, guests of every age with browsers several years old, data that stays with the client, and one night without a second chance.

Each of them had a technical consequence: chunked uploads with resume, streaming to disk instead of buffering, a version that works without JavaScript, the client's own server set up as code, and tests on real old browsers rather than in emulation.

Solution

Three containers behind nginx: a Next.js front end in TypeScript, an upload engine in plain TypeScript separated from the view, and a FastAPI back end that streams chunks to disk, keeps the metadata and builds the gallery with ffmpeg. The secret token from the QR code is part of every address and authorises every request; there are no accounts, sessions or cookies.

The server is prepared, deployed and serviced with Ansible: one command, checks before the change, secrets in an encrypted vault, and the deployment only counts as done when the back end, nginx and the page respond.

Results

Per file
Up to 5 GB, in 8 MB chunks with resume after a dropped connection
Parallel streams per upload
3, with retries at a growing interval
Browser versions
6 years back, verified on real engines with a checksum of the uploaded file
External services
0: the files stay on the client's 14 TB disk
Server set-up and updates
1 Ansible command, with checks before and after
Without the secret link
404 for the start page and every wrong address

How we worked

  1. Assumptions first

    Up to 5 GB per file, weak Wi-Fi, guests from 20 to 80 with any phone, data at the client, one evening. Every assumption got its technical consequence before the first line of code.

  2. Three containers, clear roles

    nginx as the only open port and the streaming front door, the back end as the only writer to the data disk, the front end never touching it.

  3. An upload engine of its own

    Plain TypeScript without a framework: chunking, parallelism, retries, resume. The API is plain HTTP, so the same upload works from the command line, and diagnostics need no browser.

  4. Server as code

    Ansible prepares the server, deploys with checks before the change and verifies the result. Deleting test material requires an explicit confirmation and checks the disk and the directory.

  5. Tests on real engines

    Archived browser versions driven directly, each through the full path: open the page, upload a real file, compare the checksum, open the gallery.

  6. A safety net without JavaScript

    If the application does not start, the guest still gets a plain HTML form, and the server stores the file exactly as it does on the normal path.

What the assumptions meant technically

Assumption Technical consequence
Up to 5 GB per file Chunked upload with resume, streaming to disk instead of buffering
Phones, often on weak Wi-Fi Tolerance for dropped connections, retries, resume after a page refresh
Guests aged 20 to 80, any phone Support for browsers several years old and a version that works without JavaScript
Data stays with the client Own server with a 14 TB disk, deployment with Ansible, no external services
One night, no second chance Tests on real old browsers, not only in emulation

Challenges we overcame

Every point below cost between a few hours and two days, and none of them could be predicted from the documentation.

The iPhone quietly degrades what it uploads

A video recorded in 4K arrived as a short 480 by 360 file, and photos came in smaller than the originals. Nothing in the application did that. The WebKit sources make it clear: when a file is picked from the photo library, Safari on iOS always re-encodes videos, remembers the size chosen in the picker on any other site, and hands over a Live Photo as its short low-resolution clip. A web page cannot switch that off. So the application tells the truth instead: after the choice it shows the real resolution and length, warns when it sees signs of downscaling, and offers the way through the Files app, which delivers the original. The dimensions of every file are stored on the server, so the question why a video is poor is settled by a record, not by guesswork.

Stuck at 96 percent: a response that arrives but is never seen

Uploads from iPhones reached 96 to 99 percent and stopped. The server logs showed everything received and written, with a response time of a second and a half. The phone waited forever. Two causes, both in Apple's networking layer: the browser lost its way when the request body was read straight from a file on disk, and with several uploads at once a request received a complete HTTP 200 but never reported that it had finished. Chunks are now read into memory first and sent as a finished buffer; if a complete, valid response is visible, the chunk counts as delivered and the zombie request is dropped; an idle timer of 30 seconds triggers a retry with a growing interval. The phone also sends a short event log at the end, stored with the file's metadata, so the behaviour of a stranger's phone can be read later from the server.

A modern build, an old phone, a white page

On a phone a few years old with an outdated browser the page appeared without any styles and the upload did not work. The framework builds for browsers of the last two years, the styling library wraps everything in a modern CSS construct that an older engine skips together with the whole stylesheet, and a few new units and syntax shortcuts stop the whole script. Two traps on top: the CSS minifier removed the older of two fallback values as a duplicate, and the framework's official mechanism for loading a script first loaded it after its own bundles. The fix: an explicit list of supported browsers set back several years, the modern construct flattened at build time, fallback units, polyfills, and a few lines of script written directly into the document head.

A big file against the server's defaults

At 5 GB per file most defaults work against you. The reverse proxy buffers the whole request before passing it on, writing gigabytes to its own disk only to hand them over, and size limits and timeouts are short. The upload now streams through every layer without intermediate buffering, every chunk lands in place in a sparse file, completion is atomic, and a repeated completion after a dropped connection does not create a duplicate. The standard form library in Python decoded file names in an old western encoding, so Polish characters could break the whole request; a short reader of the header with the correct encoding replaced it.

Testing a browser from six years ago

Modern test tools can no longer drive browsers from that period. Instead of assuming it should work, archived engine versions were downloaded and driven directly over a lower-level protocol, each through the full path: open the page, upload a real file, compare its checksum with the original, open the gallery and the sub-pages.

A phone and a progress bar stuck at its last, orange segment; the server is marked with an orange cross, and an orange loop under the bar stands for the retry

What we take from it

  • The most expensive mistakes are not in the code but in other people's defaults. A framework building for the newest browsers, a minifier removing a compatibility fallback, a proxy buffering gigabytes, a library reading file names in the wrong encoding: each decision was made for us, and none announced itself as an error.
  • Works on my machine is not a test result. Half of the problems only appeared on real hardware: a specific phone, a specific browser, a specific network. That is why we test on real versions and let the application collect evidence from devices we cannot reach.
  • What cannot be fixed must be shown. Some limits no web page can get around. Then the application's job is to tell the user the truth and offer the way around.
A new phone, an old keypad phone and a laptop reach the same server on three even lanes, marked with three ticks; the new phone and the laptop go through the application, the old phone through a plain form drawn in orange

Related situations

  • Does your front end depend on jQuery 1.x or 2.x and on Smarty templates that nobody likes to touch? We renew it one area at a time alongside the running system and test after every step. The jQuery project no longer supports the 1.x and 2.x branches (As of 2026-10).

  • We move your Lucee, ColdFusion or PHP application from classic hosting into containers without rewriting it. That lets you rebuild, update and relocate the environment. Security updates for PHP 8.2 end on 31 December 2026 (As of 2026-10).

  • Is your data being copied by hand between two systems today, or does a partner require an API? We connect existing applications via REST or SOAP, synchronise data between them and send transactional emails and notifications from your workflows.

More projects

  • A score scale with a dashed threshold: the phishing envelope moves in an orange arc across the threshold

    A spam filter that ran but did not protect

    Services up, configuration valid, no errors in the system status. Yet phishing landed in the inboxes while partners' newsletters went to spam. Our engineers measured which detection mechanisms actually fired, repaired the causes, and three months later found and fixed a regression that only showed up hour by hour.

  • Four envelopes pass the gates SPF, DKIM and DMARC, each ticked in orange, on the way into the inbox

    Email deliverability for four company domains

    A mail server for four company domains signed nothing, published no DMARC policy and was rejected by Microsoft. Our engineers audited it, piloted the change on one domain and rolled it out in batches. The result: all three checks pass at Gmail, the Microsoft block was lifted, and the users noticed nothing.

In 20 minutes we work out which first step fits your system

You tell us the goal and the current state, and we say openly whether and how we can help. Reply within one working day, no obligation.

Phone (German, Polish, English) +48 501 250 220
Email Email

Reachable: Monday to Friday, 9:00 to 17:00 Central European Time; support and emergencies around the clock (24/7)