TL;DR — One commit today, and it's a Markdown file. No migration, no action class, no tests. I spent the day writing down the shape of a feature instead of building it, and the thing I wrote down is embarrassingly small: an application that uses object storage should have a foreign key to that storage, not four env vars somebody typed in. Everything else in this post is downstream of that one sentence.
The gap that hides behind "it works"
Here's the setup. A deployment platform can do two things with object storage:
- It can run it — provision a storage server, create buckets, mint access keys.
- It can record somebody else's — an S3-compatible connection to whatever the operator already pays for.
Both work. Both have UIs. Both have tests. And neither one is connected to an application in any way.
So the actual integration procedure is: provision a bucket, open the workload's environment editor, and hand-copy four values across — endpoint, region, key id, secret. It works on the first deploy. Everybody moves on.
What that copy-paste can't do is the whole point:
- It can't be gated. Nothing stops a deployment starting against a bucket that hasn't finished provisioning.
- It can't be rotated. Rotate the access key and the running app breaks, with no way to answer "which apps did I just break?"
- It can't be audited. There is no query that returns "which workloads use this bucket."
- It can't be detached. Nobody knows the link exists, so nobody can cleanly remove it.
A copied string is not a relationship. It's a coincidence that currently holds.
This is the same shape as the bug family I wrote about yesterday, one level up: the system took something it did know — this app uses that bucket — and stored it somewhere it couldn't reason about. Not a wrong fact. A fact with no home.
Copy the shape you already solved
The useful thing about this gap is that it isn't new. Managed databases went through it months ago and came out the other side with a shape: one action, nullable foreign keys on the deployment workload, environment variables written per language preset, and a gate that refuses to deploy against a database that isn't ready.
So the storage plan mirrors it. Deliberately, and almost literally.
That's a decision worth stating out loud, because "just do it the same way" is not automatically right. You inherit whatever's slightly wrong with the original, and you make the two things harder to evolve independently later. I took that trade knowingly. The alternative — a second vocabulary for the same concept, sitting one tab away from the first — is how a platform starts to feel arbitrary to the people operating it. When two features answer the same question ("how do I connect this app to that thing?") they should answer it with the same words.
| Source | Credentials | What the platform creates |
|---|---|---|
| Managed | minted per deployment | bucket + access key + permission |
| External | operator's, already stored encrypted | nothing — it names an existing bucket |
Two sources, one attach. Both end in the same place: env vars on the workload, and a foreign key so the link is a fact rather than a string.
Nullable columns, and where the rule lives
The schema part is dull, which is a good sign:
Schema::table('deployment_workloads', function (Blueprint $table) {
$table->foreignId('storage_bucket_id')->nullable()->constrained();
$table->foreignId('storage_access_key_id')->nullable()->constrained();
$table->foreignId('object_storage_connection_id')->nullable()->constrained();
$table->string('storage_prefix')->nullable();
});
Managed and external are mutually exclusive — you attach a bucket the platform manages, or you name one it doesn't, never both. The interesting question is where that rule lives.
I'm putting it in the action, not in a CHECK constraint:
if ($bucket && $connection) {
throw StorageAttachmentInvalid::bothSourcesGiven();
}
The honest counter-argument first, because it's a real one: a database constraint is the only rule that survives a seeder, a console command, or somebody in tinker at 2am. Application-level invariants are invariants right up until code writes around them.
I still went with the action, for two reasons. The rule is about intent, not data integrity — two populated columns isn't corrupt data, it's an ambiguous request, and it deserves an error message that says so rather than a constraint violation. And the platform targets several database engines, so a CHECK here means either a portability problem or the same rule written five ways. When the constraint would be lying about why it exists, I'd rather it live where the reason is readable.
If you disagree, add the constraint too. Belt and braces is fine. What isn't fine is having the rule in neither place because you assumed the form would enforce it.
The secret you can read exactly once
This one shapes the whole flow, and it's easy to miss until you're halfway through building.
A well-behaved object storage engine hands you the secret key at creation and never again. It doesn't keep the plaintext. There's no "show secret" endpoint to fall back on, because there's nothing to show.
Which means the moment of creation is the only moment you can persist it. Practically:
final class ProvisionDeploymentStorageAction
{
public function __invoke(Deployment $deployment, Workload $workload): array
{
$bucket = ($this->createBucket)($deployment, $this->bucketName($deployment, $workload));
$key = ($this->createAccessKey)($bucket); // ← plaintext exists only here
($this->attachPermission)($bucket, $key, Permission::ReadWrite);
return [
'bucket' => $bucket,
'key' => $key,
'env' => $this->env($bucket, $key), // written into encrypted env now
];
}
}
Two consequences fall straight out of that:
You can't design an "attach later, fetch the credentials then" flow. There is no "then". If your UI implies one, you'll discover it the first time an operator tries to re-attach a bucket whose key was minted last week.
No daemon calls in the action. Rows first; queued jobs make them real. The action's job is to record the intent atomically — provisioning a bucket over the network inside a request is how you get a half-created attachment when the connection blips.
And the bucket name is derived, not free text, through a small identifier value object. Object storage naming is stricter than people expect — lowercase, digits, hyphens, dots; no uppercase, no underscores. Deriving it from the deployment and workload slugs means the name is reproducible, which matters more than it sounds like, for reasons in a moment.
Real values, not template tokens
For services that live inside the deployment, env vars can be {{ tokens }} resolved against a service registry at deploy time — the database host isn't known until the thing is placed.
Object storage isn't like that. It lives outside the deployment. There's no registry entry to resolve against, so the values go in real, at attach time. Same reasoning the database attach already uses.
Per language preset, because the ecosystem conventions differ:
laravel → AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION,
AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT=true
node → S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
python → AWS_* (boto3 reads the same names)
Two traps worth writing down before you hit them:
Path style vs virtual-host style. Self-hosted engines generally want use_path_style = true (https://host/bucket/key). Hosted providers usually want virtual-host style (https://bucket.host/key). Get it wrong and you get a 403 or a DNS failure that reads like a credentials problem. For an external connection this has to be whatever the operator recorded, not whatever your managed default is.
Build the endpoint through a helper, never string concatenation. An IPv6 literal needs brackets in a URL:
// "http://fd00::1:3900" — silently wrong, the port isn't a port
$endpoint = "http://{$host}:{$port}";
// "http://[fd00::1]:3900"
$endpoint = $publishedHost->authority();
That's the kind of bug that only shows up on one operator's network, six weeks after you shipped.
The gate: don't start something that can't work yet
The deployment gets a check beside the existing managed-database one, with the same three answers:
- The row is gone — the bucket was deleted out from under the attachment.
- The row failed — provisioning errored; surface the recorded error, don't just say "not ready".
- The row is pending — it's coming, but it isn't here yet.
public function managedStorageGate(): ?string
{
$bucket = $this->storageBucket;
return match (true) {
$this->storage_bucket_id && ! $bucket => 'The attached bucket no longer exists.',
$bucket?->status === StorageResourceStatus::Failed
=> "Storage provisioning failed: {$bucket->last_error}",
$bucket?->status !== StorageResourceStatus::Ready
=> 'Storage is still being provisioned.',
default => null,
};
}
The value of a gate is entirely about where the failure surfaces. Without it, the deployment starts, the app runs happily for however long, and then the first user upload gets a 403 from an endpoint that doesn't have that bucket. The symptom lands in application logs, hours later, nowhere near the cause. With the gate, the deploy refuses at the point where the missing thing is named.
it('refuses to deploy when the attached bucket is not ready', function () {
$workload = DeploymentWorkload::factory()
->withManagedBucket(StorageResourceStatus::Pending)
->create();
expect($workload->managedStorageGate())->toContain('still being provisioned');
});
it('reports the recorded error when provisioning failed', function () {
$workload = DeploymentWorkload::factory()
->withManagedBucket(StorageResourceStatus::Failed, error: 'no space on device')
->create();
expect($workload->managedStorageGate())->toContain('no space on device');
});
Detach must never delete
The hardest rule in the whole plan, and the shortest:
Data outlives deployments.
Detaching a bucket removes the link and the env vars. It does not touch a single object. Someone tearing down a staging deployment at 6pm on a Friday must not be able to delete a year of uploads by clicking a button labelled Detach.
Which is why the derived bucket name matters: re-attaching finds a live bucket under the name it would have generated and reuses it rather than refusing. Detach, re-attach, and you're back where you were, with your files. If the operator genuinely wants the data gone, that's a separate, louder, differently-worded action.
The same idempotency thinking as treating a vendor 404 as success on delete: a repeated operation has to be able to finish.
Rotation is the part that bites
Key rotation already exists here: mint a successor, keep the old key valid for an overlap window so nothing dies mid-request.
The overlap is the safety mechanism, and it's also the thing that makes the bug easy to miss. Rotate, everything keeps working, ship it. Then the overlap expires and apps start failing — at a moment with no deploy, no code change, and nothing in the timeline to point at.
So completing a rotation has to walk the attachments pointing at the old key and update them. Otherwise the platform is scheduling its own outage, on a delay, for a reason its own logs won't explain.
One more bit of honesty owed to the operator: updating a stored env var doesn't change the environment of a process that's already running. The new value reaches the app on the next deploy. The toast has to say "redeploy to apply" rather than implying it's live — a UI that overstates what just happened is how you get someone confidently debugging the wrong layer.
The bit I got wrong first: knowing what not to model
This plan exists because of a wrong turn.
The new-application wizard briefly offered "uploads on their own storage server" as a component you could compose into an app's blueprint, alongside the runtime and the database. It looked consistent. It was wrong twice over.
The shallow reason: the specific engine I reached for went into maintenance mode upstream, so the platform's own unsupported-components check rejected it. Fair enough — that's a dependency risk, and the validator did exactly its job.
The reason that actually matters: object storage isn't a per-application component at all. It reads its own config file and owns its own cluster layout. A per-app layer can't own that — you'd be asking one application's blueprint to hold configuration that belongs to a service several applications share.
That's a useful test for any composable system:
If a thing owns its own config file and its own cluster identity, it's a peer service you attach to, not a component you compose in.
Getting that wrong doesn't produce an error. It produces an abstraction that works for the first case, then quietly fails the second — the moment two applications want the same bucket, or the storage needs a setting that isn't any single app's business.
The fix wasn't to make the component work harder. It was to delete it and model the relationship instead.
What I left out on purpose
Two things, both tempting:
Migrating existing objects into a new bucket. There's no safe general answer — prefixes differ, sizes differ, and a half-finished copy loses files. A feature that sometimes loses uploads is worse than no feature.
Multiple buckets per workload. One bucket, one key, one prefix. The second bucket arrives with the first real request for one, not on the strength of me imagining it at design time.
The takeaway
A plan is the cheapest place to be wrong. Today's entire output is one Markdown file, and it already caught one bad abstraction, one dependency risk, and one silent-outage path in the rotation flow. Those are much more expensive to find in a migration.
The question I'd take to any integration in your own system: what happens when the credential rotates?
If the answer involves a human remembering which apps to update, you don't have an integration. You have four env vars and no foreign key.
Top comments (0)