Shipping ItLesson 37 of 74

Contents

Shipping It

Lesson 37 of 74. Sign in to keep your place.

Module 4 · Load, cost and the traffic that is not customers

Files people send you

9 min read

The local disk is not storage

The first version of file upload always writes to a folder next to the code. It works on your laptop and it fails in production for four separate reasons: a second instance cannot see the file, a container’s filesystem disappears on restart, the disk fills and takes the whole machine down with it, and nothing about it is backed up.

The replacement is object storage: a service that holds blobs by key over HTTP. Amazon S3 defined the interface, and almost everything else speaks the same API: Cloudflare R2, Backblaze B2, DigitalOcean Spaces, Hetzner, Wasabi, and MinIO if you want to run it yourself for free. That compatibility is worth insisting on, because it means moving provider is a configuration change rather than a rewrite.

The price shape is the thing to learn: storage is cheap (roughly 1.5 to 2.5 US cents per gigabyte-month), requests are almost free, and egress is where the difference is: the large clouds meter it at several cents per gigabyte, while R2 and B2 charge nothing or almost nothing to serve it out. For a site serving images, that single difference can be the whole hosting bill.

Do not route the bytes through your app

The obvious design has the browser POST the file to your server, which then forwards it to the bucket. It doubles the bandwidth, ties up a worker for the length of a mobile upload, and puts a 200 MB video through a process sized for 2 KB JSON requests.

The standard fix is a presigned URL. Your server authorises the upload and hands back a short-lived URL; the browser sends the bytes straight to the bucket.

Python
url = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": "uploads", "Key": key,
            "ContentType": "image/jpeg"},
    ExpiresIn=300)      # five minutes is plenty

Three things to constrain when you sign: the key (so a user cannot overwrite somebody else’s object), the content type, and the maximum size: with presigned POST policies you can enforce a content-length range, which is the only way to stop somebody uploading 5 GB to a form meant for avatars.

After the upload, the browser tells your server it finished. Never trust that message alone: check the object exists and its size before you record it, because the client controls what it claims.

What arrives is not what was promised

Everything about an uploaded file is attacker-controlled, including the parts that look like metadata.

  • The filename. ../../etc/passwd and a 2,000-character Unicode name are both things you will receive. Never use the supplied name as a path. Generate your own key (a UUID, or a hash of the content) and keep the original name as a display label only.
  • The content type. The browser sends whatever it likes. Check the actual bytes: file --mime-type, or a magic-number library. A file named .jpg can be an HTML page, and if you serve it back from your own domain with the type the client claimed, you have a stored cross-site scripting hole.
  • The size. Enforce it at the edge, at the bucket policy, and in your own record. A limit implemented only in JavaScript is decoration.
  • The contents. Images can carry EXIF data including the exact GPS coordinates where a photo was taken. If your users are posting personal photographs, strip metadata on ingest: this is a privacy obligation, not an optimisation. exiftool, ImageMagick and libvips all do it, all free.

Serve user content from a different domain from your application, or at minimum with Content-Disposition: attachment and a strict content type. Same-origin user files are how one uploaded HTML page becomes a session-stealing page on your own domain.

Public buckets, the classic accident

Misconfigured buckets are one of the most reliable sources of data leaks in the industry, and the mistake is almost always the same: making the bucket public because a private URL was inconvenient.

Keep the bucket private and serve reads through presigned GET URLs, or through a CDN configured with a signing key. Both are a few lines. If content is public (a logo, a course image) a public bucket is fine; the rule is that publicness is a decision per object, not a shortcut taken once at three in the morning.

Derivatives, and the work you did not plan

An uploaded 8 MB phone photo should not be what a visitor downloads. You need thumbnails, and there are three honest options: resize on upload in a background job (predictable, storage-heavy), resize on first request and cache (lazier, needs a cache), or use an image CDN that transforms on the fly by URL parameter. libvips is the fast free library, sharp wraps it for Node, and Thumbor and imgproxy are free self-hosted services that do the whole job.

Do the resizing in a queue, not in the request. A large image processed inline is exactly the kind of slow, memory-hungry work that the queues lesson exists to move.

Storage grows and never shrinks by itself

Deleted rows leave orphaned objects. Failed uploads leave partial ones. Set a lifecycle rule on the bucket to expire incomplete multipart uploads after a day and to move old derivatives to a cheaper class, and write a small job that reconciles objects against the database occasionally. Nobody does this until the bill arrives, and the fix is a configuration setting that takes two minutes.

Every lesson here is open. Your progress is recorded once you pass the Agents and Automation exam.

Check your understanding

An app stores uploads in a private bucket, generates its own UUID keys, and serves files back from app.example.com/files/<uuid> using the content type the browser reported at upload. Where is the hole?

Pick the one you would defend. Your answer stays private.

Next lesson · 9 minWhy your email went to spam

Stuck on something?Ask Tri, your AI tutor

How to cite: Addaly (2026) ‘Files people send you’, in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/uploads-and-object-storage (Accessed: 13 October 2026).