Declare a bucket in catalog-info.yaml (catalog 0.3.0+). The builder provisions a MinIO
bucket with a hard quota, and credentials are injected into your pod by Vault — dynamic,
short-lived, scoped to your bucket. Use the @insureco/storage SDK at ^0.2.0.
metadata:
annotations:
insureco.io/catalog-version: "0.3.0"
spec:
storage:
- name: uploads # → S3_UPLOADS_BUCKET (+ S3_BUCKET alias if single-bucket)
tier: s3-md # s3-sm | s3-md | s3-lg | s3-xl
import { createStorage } from '@insureco/storage' // ^0.2.0 — see credentials section
const storage = createStorage() // single bucket
await storage.upload('report.pdf', buffer)
The package is
@insureco/storage. A@tawa/storageexists in the registry but is abandoned at0.1.0and reads credentials fromprocess.env, which is broken in a deployed pod (see below). Do not use it.
| Tier | Capacity | Gas / month |
|---|---|---|
s3-sm | 1 GB | 200 |
s3-md | 5 GB | 800 |
s3-lg | 25 GB | 3000 |
s3-xl | 100 GB | 10000 |
Credentials are dynamic MinIO users minted by a custom Vault secrets engine. Pods never hold a static key:
minio/creds/{role} → the plugin creates an ephemeral MinIO
user scoped to your bucket, with a lease (default_ttl: 1h, max_ttl: 24h)./vault/secrets/storage, which the sidecar keeps current.@insureco/storage ≥ 0.2.0 reads that file — not process.env — re-reading it before
each operation, so a rolled lease is picked up with no restart.S3_* out of process.envRequires
"@insureco/storage": "^0.2.0". A^0.1.xpin cannot resolve it and will readprocess.env, which is broken. Check yours before assuming storage works.
The pod's S3_* environment variables are a snapshot taken at boot and they go stale
within about an hour. There are two Vault Agent renders, not one:
exec()s your process — so your env holds that credential, forever;Nothing renews the init container's credential. When its Vault token expires (~1h) the
credential it created is revoked, and every S3_* value in your environment is dead — while
the file on disk is still valid. Measured across production: every Vault-using pod is in this
state, and pods older than about an hour fail InvalidAccessKeyId on the env credential.
So:
import { createStorage, hasVaultFile } from '@insureco/storage'
const storage = createStorage() // ✅ reads the Vault file when deployed
const storage2 = createStorage('uploads') // ✅ named bucket, same behaviour
// ❌ never do this — these values are a dead boot snapshot in a deployed pod
new S3Client({ credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, ... } })
// ❌ and never gate on them either — they are present whether or not they work
if (process.env.S3_ACCESS_KEY_ID) { /* proves nothing */ }
if (hasVaultFile()) { /* ✅ deployed with Vault-managed creds */ }
hasVaultFile() is the correct "am I deployed?" discriminator: file when deployed, env when
local. Tracked in iec-builder #31; see also iec-docman #7 for what it looks like when it bites.
Historically broken, now fixed. The builder used to ask the Vault Agent to
rm -f /vault/signals/alive on rotation and probe cat on that file. It never worked for any
service: the command runs in the vault-agent sidecar, which never had the vault-signals
volume mounted, so it removed nothing — and rm -f on a missing path exits 0, so it reported
success while doing nothing.
There is also no longer a Vault init container (agent-pre-populate: false). Two agent
processes meant two independent reads, hence two credentials — the app captured one nobody
renewed. One render now means the credential in your environment is the one being kept alive.
Rotation is detected inside your own container instead: the entrypoint fingerprints the
database credentials it sourced, and the liveness probe re-fingerprints the live files.
A mismatch means Vault issued credentials the running process cannot pick up (its environment
is fixed after exec), so Kubernetes restarts it. The probe fails open — missing
fingerprint, missing files, or no md5sum all pass, so it can never cause a restart loop.
Storage is deliberately excluded from that fingerprint: @insureco/storage re-reads the file
per operation, so a storage roll needs no restart and must not cause one.
The roll above only fires on Vault's lease clock. An out-of-band MinIO IAM reset — a
reinstall, a .minio.sys wipe, or a volume loss — is invisible to Vault: MinIO loses every
Vault-created user, but Vault still holds the leases and believes they're valid, so no roll
fires. Every storage pod keeps serving cached creds that now throw InvalidAccessKeyId,
silently, until max_ttl.
You cannot fix this inside the roll: the roll is time-driven and the reset has no signal into Vault. The builder runs a storage reconciler that supplies that signal from outside.
Every STORAGE_RECONCILE_INTERVAL_MS (default 2 min) the builder:
InvalidAccessKeyId / SignatureDoesNotMatch) while admin still works, MinIO has
forgotten an account Vault issued — an IAM reset.STORAGE_CANARY_FAILURE_THRESHOLD (default 3) consecutive failures
before acting, so a transient blip never restarts the fleet.On a confirmed reset (and outside STORAGE_HEAL_COOLDOWN_MS), it heals every storage service:
re-asserts the Vault role, recreates buckets if also wiped, revokes the stale Vault leases, and
kubectl rollout restarts the deployment. On restart the Vault Agent re-reads creds, the plugin
re-creates the MinIO user, and the pod recovers. A Septor storage.iam_reset_healed event and an
alert email are emitted. The number of services healed per run is capped by
STORAGE_HEAL_MAX_SERVICES (default 50).
Non-destructive: the heal only touches the identity layer (Vault leases, MinIO users, pod lifecycle). Object data is never deleted. If a reset also wiped object data, buckets are recreated empty — the reconciler restores access, not lost objects. Object durability is MinIO's job (distributed/erasure-coded mode + backups).
When ops knowingly reset MinIO, freshly-minted canary creds would mask the drift, so detection is bypassed — call the platform-admin endpoint to re-provision the whole fleet immediately:
POST /storage/reconcile { "reason": "minio reinstall 2026-06-24" } # platform-admin
GET /storage/health # last reconciler result
| Env var | Default | Purpose |
|---|---|---|
STORAGE_SELF_HEAL_ENABLED | true | Master switch for the reconciler loop |
STORAGE_RECONCILE_INTERVAL_MS | 120000 | Time between detection passes |
STORAGE_CANARY_FAILURE_THRESHOLD | 3 | Consecutive canary failures before healing |
STORAGE_HEAL_COOLDOWN_MS | 900000 | Minimum gap between auto-heals (anti-thrash) |
STORAGE_HEAL_MAX_SERVICES | 50 | Per-run restart cap |
STORAGE_CANARY_BUCKET | iec-storage-canary | Canary probe bucket |
PLATFORM_ALERT_EMAIL | (unset) | Recipient for heal alert emails (no email if unset) |
The durable long-term fix is to federate storage identity (MinIO OIDC backed by Bio-ID) so there is no stored MinIO user to lose at all — the same model AWS IRSA / GCP Workload Identity use. The reconciler remains the right safety net even after that lands.
POST /storage/reconcile).spec.storage below catalog 0.3.0 — the field is ignored.Last updated: October 6, 2026