Uplint
uplint dispatch --read multi-bucket-storage-routing
TITLEOne API, Many Buckets: Route Every File Context to Its Own S3validated
DATE2026-07-13
AUTHORUplint Engineering
TYPEengineering
TAGS#S3 #multi-bucket #storage #data-residency #file-contexts #bring-your-own-storage
READ8 min

One API, Many Buckets: Route Every File Context to Its Own S3

Real systems don't put everything in one bucket. Medical claims, avatars, and exports each want their own region, credentials, and lifecycle. Today, Uplint lets you register multiple S3 buckets per tenant and route each file context to the one that fits - with automatic fallback to a default.

Uplint EngineeringJuly 13, 20268 min

When we launched Uplint, we made a deliberate choice: your files live in your S3, under your credentials. Uplint is the trust layer that verifies, classifies, and audits what comes in - but the bytes land in a bucket you own and control. That's non-negotiable for the regulated teams we work with, and it's the difference between a trust layer and yet another place your data ends up.

There was one thing that early design got wrong, though. It assumed you had one bucket.

Real systems almost never do.

One bucket was always a simplification

Think about what actually flows into a production intake pipeline. A health insurer takes in medical claims, prescriptions, and bills - regulated data with a decade-long retention clock. That same company also stores profile avatars, support-ticket attachments, and nightly CSV exports. Treating all of that as one undifferentiated pile in one bucket isn't a storage strategy. It's a liability waiting for an audit.

The moment you have more than one kind of incoming data, one bucket starts fighting you:

Data separation. Regulated uploads and low-sensitivity uploads should not share a blast radius. If a key leaks or a policy is misconfigured, you want the damage contained to one workload, not spread across everything you've ever ingested.

Data residency. "Store EU uploads in the EU" is not a preference - for a lot of teams it's a contractual and legal requirement. A single bucket lives in a single region. The instant you serve two regions, one bucket cannot be the answer.

Independent credentials. Rotating or revoking an IAM key shouldn't be an all-or-nothing event. When every workload shares one key, key rotation becomes a coordinated fire drill instead of a routine, per-workload operation.

Cost and lifecycle. Medical records might need to sit in Glacier for seven years. Temporary uploads should be purged in weeks. S3 lifecycle rules and storage classes are set per bucket - so if everything's in one bucket, everything gets the same lifecycle, which means you're either overpaying to keep junk or under-retaining data you're legally required to hold.

Every one of these is a reason to reach for a second bucket. Until today, Uplint made you choose one and live with it.

What shipped: multiple buckets, routed per context

Starting today, a tenant can register as many S3 buckets as they need, and every file context can be routed to a specific one. If you've used Uplint, you already know contexts - they're the named intake points (medical_claims, avatars, exports) that carry the rules for what's allowed in: extensions, size limits, blank rejection, virus scanning. Now a context carries one more thing: where its accepted files land.

A storage bucket (we also call it a storage configuration) bundles everything Uplint needs to read and write on your behalf:

  • The bucket name and AWS region
  • An access key ID and secret access key - encrypted at rest, and never returned by any endpoint
  • A base path prefix applied to every object written to the bucket
  • A human-friendly name so you can tell your buckets apart in the dashboard and API

Register a few of these, point each context at the bucket that fits, and you keep patient documents, avatars, and exports in genuinely separate buckets - each with its own credentials, region, and lifecycle - while still driving every upload through the same single Uplint API call you use today. Your application code doesn't change. The routing happens underneath it.

How routing actually resolves

The design goal was that this should be impossible to get subtly wrong. Two rules, one for writes and one for reads.

On upload, Uplint resolves the destination bucket in this order:

  1. The context's bound bucket, if it points to an active configuration.
  2. Otherwise - unset, missing, or deactivated - the tenant's default bucket.

So binding a context to a bucket is nothing more than setting its storage_config_id. Unbind it by sending an empty string, and it falls straight back to the default. There is always a default: the first bucket you add becomes it automatically, and exactly one bucket holds that role at any time.

On download, the harder half, Uplint resolves the bucket in this order:

  1. The file's own recorded storage_config_id.
  2. Otherwise, a bucket whose name matches the one stored on the file.
  3. Otherwise, the tenant's default bucket.

The key detail: Uplint records which bucket every file was written to. Routing is resolved per file, not per context. That means re-pointing a context to a new bucket only affects new uploads - every file already written stays readable from exactly where it landed, no matter how you rearrange things afterward. You can add buckets, rename them, deactivate them, or re-route a context, and yesterday's files keep resolving correctly. Nothing gets orphaned.

That per-file guarantee is also why two fields are immutable once a bucket exists: the underlying S3 bucket name and the base path. Changing either would strand every object already stored under them. Everything else - the label, the credentials, the region, whether the bucket is active - you can update freely. To genuinely move data somewhere new, you add a new bucket and route your contexts to it, and the old files stay readable the whole time.

It's already backward compatible

If you're an existing tenant, you don't have to do anything, and nothing about your setup changed. Your single storage configuration simply became your default bucket. Files uploaded before routing existed continue to resolve by their recorded bucket name. You opt into multi-bucket the moment you add a second bucket - and not a second before.

That was a hard requirement for us. A storage change that forces a migration is a storage change people put off for a year. This one costs nothing to adopt.

What it looks like in practice

Say you want to isolate insurance claims in their own bucket, in-region, with their own IAM key.

From the dashboard, open Storage, add a bucket, and give it a name, region, credentials, and bucket name. Then open your insurance_claims context, and in its settings pick that bucket from the new Storage Bucket dropdown. That's it - new claims now land in the dedicated bucket; everything else keeps using whatever it used before.

The same thing over the API:

# 1. Register the dedicated bucket
curl -X POST https://api.uplint.dev/api/v1/tenants/tnt_9f8e7d6c/storage-configs \
  -H "Authorization: Bearer ul_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Insurance Claims",
    "access_key_id": "AKIAIOSFODNN7EXAMPLE",
    "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    "region": "ap-south-1",
    "default_bucket": "acme-insurance-claims",
    "base_path": "file-service/"
  }'
# -> returns a config_id, e.g. cfg_a1b2c3d4e5f6
#    (the access key comes back masked; the secret is never returned)

# 2. Route the context to it
curl -X PATCH https://api.uplint.dev/api/v1/contexts/insurance_claims \
  -H "Authorization: Bearer ul_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "storage_config_id": "cfg_a1b2c3d4e5f6" }'

From here on, uploads into insurance_claims land in acme-insurance-claims, and every other context continues to use whatever bucket it was routed to, or the default. Your upload call itself is unchanged.

A few guardrails worth knowing, all designed to stop you cutting off storage that live traffic depends on. Secret keys are encrypted at rest and never come back from any endpoint - when you read a bucket, the access key returns masked (AKIA...MPLE) so you can recognize which key is in use without exposing it. The default bucket can't be deleted; promote a different one first. And a bucket that's still bound to one or more contexts can't be deleted until you reassign or unbind those contexts. Managing storage requires an API key with the admin scope - deliberately separate from the upload/download scopes your day-to-day file traffic uses.

Why this fits the bigger picture

Uplint exists because storage is content-blind - S3 holds bytes and has no idea whether they form a real claim, a blank PDF, or a renamed executable. Multi-bucket routing is the same idea pushed one layer further. It's not enough for your infrastructure to understand what entered; it should also place that data where its sensitivity, its region, and its retention clock say it belongs - automatically, on every upload, without your application code carrying a map of buckets and credentials.

One API call still answers the only question your code should have to ask: can I trust this data? Now the same call also makes sure the answer is filed in the right place.


Uplint is the trust layer for incoming data. Bring your own S3, route every file context to the bucket that fits, and verify structure, blanks, and threats with a single API call. Start building free →

Found this useful? Share it with your team.