Skip to main content
An s3 drain writes your agents’ log records into a bucket you own, in your own region, as gzipped NDJSON under a Hive-style key layout. It is the destination a compliance owner asks for, and it is the only one where Lua is not the last hop: retention, encryption, object lock, and the query engine on top are all yours. All three providers are reached through the S3 API — Cloudflare R2 and Google Cloud Storage over their S3-compatible endpoints — so there is one set of rules for the three of them.
Object-storage drains are switched on for a deployment as a whole. Where the type is not open yet, a create naming it answers 422 DRAIN_TYPE_UNAVAILABLE, and the message names the types that are. It may not be enabled on your deployment yet — ask [email protected].
Before you begin
  • A bucket, and a prefix inside it that only Lua writes to.
  • Either a key pair or — better — a role Lua can assume. The grant is one action: write into that prefix.
  • Azure Blob Storage is out of scope: it has no S3-compatible surface.

What to enter

A dotted bucket name is refused: a dot in a virtual-hosted URL breaks TLS certificate verification, and every provider recommends against it. --role-arn and --credentials-from-env are mutually exclusive — a drain either assumes a role at send time or holds a key pair, never both.
The secret access key is never a flag. A shell history keeps one and so does a CI log. --credentials-from-env names two environment variables and reads them in the process that runs the command; neither value appears in argv. The access key id is stored and shown — SigV4 puts it in the Authorization header of every request in clear — while the secret goes to the encrypted credential store and appears in no response, no audit entry, and no error message.

Which hosts are allowed

The host is checked against the three providers’ own shapes before any DNS lookup happens, so an arbitrary S3-compatible endpoint is refused: Anything else answers 422 DRAIN_ENDPOINT_NOT_ALLOWED. A deployment can widen the list for its own environment, but a widened host is still required to be https: and to resolve entirely to public addresses, re-checked on every send — the same posture as every other destination.

What lands in the bucket

Object keys

Hive-style partitioning, because every query engine discovers key=value directories as partition columns with no DDL of its own. Each object is gzipped NDJSON: one line per record, and every line carries schemaUrl, batchId, the batch’s resource block, and one record. Each line’s record is the same shape the event schema describes and the same encoder the generic HTTPS drain uses in ndjson format, so the two cannot drift. A consumer that splits a file and drops lines still has the tenancy of every line it kept. format is always ndjson for a bucket drain; --format json is not honoured here. Dedup on record.id, never on batchId.
dt= and hour= are the observation hour — when Lua wrote the file — not the record’s own event time. A batch whose records straddle an hour boundary has to have one home, and a partition a query engine can prune has to be derivable from the key.Every record keeps its own timestamp and observedTimestamp, so nothing is lost. But a query that reads dt= as the event day will under-count the boundary: filter on the partition to prune, then filter on record.timestamp to be exact.

Batching

Object storage batches on a window, not on a flush tick: 5 minutes or 50 MB, whichever comes first. The 500-record, 1 MiB ceilings that suit an HTTP intake would cut a five-minute window into twenty half-megabyte objects, and no query engine reads a partition of 43,000 tiny files at a sensible cost. A single flush writes at most 25,000 records, so a very busy drain gets a second object in the window rather than one enormous one. At roughly a kilobyte a record that sits well inside 50 MB, so most windows are one file. A drain holding its window looks like backlog, not failure: lua drains status shows a non-zero backlog for up to five minutes at a time, by design. A test or a verify never waits out the window.

The hourly manifest

Every hour partition gets a manifest beside its objects:
To reconcile an hour: list the partition, read _manifest.json, and for each row confirm the object exists, its length equals bytesCompressed, and the SHA-256 of the downloaded bytes equals sha256. The hash is over the stored, gzipped object, so you never have to decompress anything to verify it.
Four properties worth knowing:
  • The merge is idempotent on the file name. A re-run that writes the same object replaces its row rather than appending a second one, and the totals are recomputed from the rows.
  • Entries are sorted by file name, which is a ULID, which is time order.
  • Past 5,000 entries a manifest keeps the oldest rows and sets "truncated": true. The objects are all still there; only the index is short.
  • The manifest is best effort. A delivery is never retried because a manifest write failed — a retry would mint a fresh batch id and write the same records to a second file. If you suspect a manifest is short, rebuild it by listing the partition: every column but sha256 is derivable from the objects themselves, and sha256 is derivable from their bytes.
Concurrent manifest writes use conditional puts. A provider that ignores them degrades to last-write-wins, which can lose a manifest row — never an object. Rebuild from the listing if a partition’s manifest and its objects disagree; the objects are the record.

Prove you own the bucket

Writing to a bucket proves somebody gave Lua a key. It does not prove the bucket is yours. So before an object-storage drain delivers anything, it writes a challenge object under your prefix and has to see the token come back.
1

Start the challenge

Lua mints a one-time token and writes {prefix}/_lua-verify-<token> into the bucket, with the token as its content.
2

If the credentials can read it back, you are done

Verification finishes on its own and the drain goes to healthy. This is the path when the grant includes s3:GetObject.
3

Otherwise, echo the token yourself

With the write-only grant we recommend, Lua cannot read the object back. The drain stays in pending_verification, and the delivery row says token_not_echoed. List your prefix, open the object, and echo the token:
Output
Lua stores only a hash of the token and returns it from no route — not the read model, not the delivery row, not the audit trail. Finding it in the bucket is the proof: a control plane that could hand the token back would let anyone holding logs:manage confirm a bucket they cannot even read. The challenge expires with the verification token, after 10 minutes. Run lua drains verify again to mint a new one; verification is limited to 5 attempts per drain per hour. The read model carries a challenge.keyPattern telling you where to look, not what to send. There is no route that returns the token.

When verification fails

Errors carry the provider’s own error code, never the SDK’s message. An SDK message quotes the signed request, and a signed request carries a credential.

The bucket policy

The grant is deliberately small: write into one prefix, and nothing else. s3:GetObject is optional — without it the ownership challenge takes the lua drains confirm path and the hourly manifest degrades from read-modify-write to write-only.

Terraform

Object Lock is your setting, not Lua’s. Lua writes each data object exactly once and never deletes or overwrites one, so compliance mode is compatible with everything above except the manifest, which is rewritten as each hour fills. Either keep the manifest outside the locked prefix, or accept that a locked hour’s manifest reflects only its first object — the objects themselves are complete either way.

Query it

The Hive layout means a query engine discovers org, agent, env, dt, and hour as partition columns.
_manifest.json is not NDJSON, so exclude it — either keep manifests in a prefix of their own or add AND "$path" NOT LIKE '%_manifest.json' to the queries that would otherwise read one.A map<string,string> coerces every attribute value to a string, which is what you want for the long tail. Declare the handful you compare numerically (lua.duration_ms, say) as named struct fields instead.

What a read of the drain shows

An s3 drain reads back with its bucket configuration and no credential:
  • credentials.secretAccessKey is write-only. It is accepted on POST and on PATCH, and returned by nothing.
  • credentials.accessKeyId is shown, because SigV4 sends it in clear on every request anyway.
  • credentials.secretLast4 is the last four characters of the stored secret, so you can tell one credential from another after a rotation without ever reading one.
  • challenge.keyPattern is where to look, not what to send. tokenHash is always an empty string on a read: a hash would be no use to you and is one thing less to leak.
  • challenge.method appears once the challenge settles: readback when Lua read the object back itself, confirmed when you echoed the token.
A PATCH replaces the whole bucket block, never merges it. A half-patched destination — a new bucket with the old credentials — is a drain writing somewhere you did not ask for. Omit bucket entirely to keep the current one.

If it isn’t working

Object-storage drains are not open on your deployment yet. The message names the types that are. Nothing about your bucket or your credentials is wrong.
That is the write-only grant working as intended. List your prefix for _lua-verify-* and run lua drains confirm <id> --token <token>. If the object is not there either, the delivery row will say s3:AccessDenied — the grant is missing s3:PutObject on that prefix.
The manifest is best effort and the objects are the record. Rebuild it by listing the partition. A grant without s3:GetObject cannot do read-modify-write, so an hour with several objects may list only the first.
That is the window, not a fault. Object storage batches on 5 minutes or 50 MB. Watch the age of the backlog against the window rather than its depth.
The bucket is in a different region from --region. An AWS endpoint is derived from the bucket and the region, so a wrong region is a wrong host. Recreate the drain with the right one.
dt= is the hour Lua wrote the file, not the hour the events happened. Read one partition on either side and filter on record.timestamp. See the partition caveat.

Next steps

Event schema

Every field on the record inside each line.

Protecting your destination

Why an arbitrary endpoint is refused, and what is re-checked on every send.

Log export

A one-off NDJSON export, for a window that predates the drain.

lua drains

Every verb and flag, including confirm.