S3-Compatible Object Sink
The S3-compatible object sink is a first-party production sink for writing normalized JetStream events to one operator-configured object-storage bucket. It is intended for S3-compatible APIs where an object key can act as the idempotency boundary and where the operator controls bucket policy, encryption, retention, lifecycle, and recovery.
The sink follows the same core rule as the other production sinks:
Commit first. ACK last. Design for redelivery.
The sink itself never ACKs NATS messages. A batch succeeds only after every
selected object write has completed according to the configured duplicate
policy. The core runner ACKs after S3Sink.write_batch(...) returns success.
Install
The base package does not install the AWS SDK dependency used for S3-compatible APIs. Install the optional extra when this sink will be used:
python -m pip install "nats-sinks[s3]"
Expected package metadata includes:
boto3>=1.34,<2
Unit tests for the sink use fake clients and make no network calls. Live integration tests against an object-storage service should remain explicitly environment-gated because endpoint, identity, bucket, and retention policy are operator-owned production concerns.
Minimal Configuration
{
"nats": {
"url": "nats://localhost:4222",
"stream": "EVENTS",
"consumer": "s3-events-sink",
"subject": "events.>"
},
"sink": {
"type": "s3",
"bucket": "nats-sinks-events"
}
}
Validate the tracked example without connecting to object storage:
nats-sink validate examples/s3-basic/config.json
Expected output:
Configuration is valid.
Active sink: s3
ACK policy: commit-then-acknowledge
nats-sink test-sink initializes the real SDK client and may contact the
configured service. Keep that command for reviewed local or live environments
where the S3-compatible endpoint and credentials are intentionally available.
Delivery Boundary
sequenceDiagram
participant Runner as nats-sinks runner
participant S3 as S3 sink
participant Store as S3-compatible bucket
participant NATS as JetStream
Runner->>S3: write_batch(envelopes)
S3->>Store: PutObject with deterministic key
Store-->>S3: object write completed
S3-->>Runner: success
Runner->>NATS: ACK
The durable success boundary is the configured object-storage service
accepting the object write. A timeout or network failure is ambiguous: the
service may have stored the object even if the client did not receive a
response. The default duplicate policy is therefore skip_existing, and the
default key strategy is deterministic.
Complete Configuration Example
{
"sink": {
"type": "s3",
"bucket": "nats-sinks-events",
"prefix": "mission/events",
"endpoint_url": "https://object-store.example.invalid",
"region_name": "eu-amsterdam-1",
"credential_mode": "environment",
"aws_access_key_id_env": "NATS_SINKS_S3_ACCESS_KEY_ID",
"aws_secret_access_key_env": "NATS_SINKS_S3_SECRET_ACCESS_KEY",
"aws_session_token_env": "NATS_SINKS_S3_SESSION_TOKEN",
"key_strategy": "stream_sequence",
"key_prefix": "archive",
"object_suffix": ".json",
"duplicate_policy": "skip_existing",
"object_format": "envelope",
"metadata_mode": "sidecar",
"sidecar_suffix": ".metadata.json",
"payload_mode": "json_or_envelope",
"compression": "none",
"content_type": "application/json",
"object_metadata": {
"purpose": "archive"
},
"server_side_encryption": "AES256",
"max_key_bytes": 1024,
"max_object_bytes": 16777216,
"max_metadata_bytes": 4096,
"request_timeout_seconds": 10,
"max_retries": 2,
"retry_backoff_ms": 250,
"retry_max_backoff_ms": 5000,
"retry_backoff_mode": "exponential",
"retry_backoff_multiplier": 2.0,
"retry_jitter": "full"
}
}
Configuration Reference
| Field | Required | Default | Valid values | Description |
|---|---|---|---|---|
type |
yes | none | s3 |
Selects the S3-compatible object sink. |
bucket |
yes | none | DNS-style bucket name, 3 to 63 characters. | Destination bucket. It must be lowercase and must not look like an IP address. |
prefix |
no | none | Safe object-key path segments. | Optional bucket prefix. Leading slash, trailing slash, empty segments, . and .. are rejected. |
endpoint_url |
no | SDK default | HTTPS URL without path, query, fragment, or credentials. | Optional S3-compatible endpoint. HTTP is accepted only for explicit loopback local testing. |
allow_http_for_local_testing |
no | false |
true or false. |
Allows http://localhost, http://127.0.0.1, or http://[::1] endpoints only for local tests. |
region_name |
no | SDK default | Bounded non-secret region text. | Region passed to the SDK session. |
credential_mode |
no | default_chain |
default_chain, environment, or profile. |
Selects the SDK credential source. |
profile_name |
no | none | Bounded SDK profile name. | Required only with credential_mode="profile". |
aws_access_key_id_env |
no | none | Environment variable name. | Required with credential_mode="environment". The value is resolved at runtime and never printed by config review. |
aws_secret_access_key_env |
no | none | Environment variable name. | Required with credential_mode="environment". |
aws_session_token_env |
no | none | Environment variable name. | Optional session-token variable for temporary credentials. |
key_strategy |
no | idempotency_key |
idempotency_key, stream_sequence, message_id, or payload_sha256. |
Determines the deterministic object key. |
key_prefix |
no | none | Safe bounded text. | Prepended to the generated key namespace. |
object_suffix |
no | .json |
Simple extension such as .json or .json.gz. |
Suffix appended to primary object keys. |
duplicate_policy |
no | skip_existing |
skip_existing, replace, or fail_existing. |
Behavior when a conditional write finds an existing object. |
object_format |
no | envelope |
envelope or payload. |
Writes the full normalized event or only the normalized payload value. |
metadata_mode |
no | object_metadata |
none, object_metadata, or sidecar. |
Controls low-sensitivity object metadata and optional metadata sidecar objects. |
sidecar_suffix |
no | .metadata.json |
Simple extension. | Suffix for metadata sidecar objects. |
payload_mode |
no | json_or_envelope |
Shared payload mode. | Controls JSON, text, and bytes normalization before object generation. |
compression |
no | none |
none or gzip. |
Gzip compression uses Python's standard library. Use a compressed suffix such as .json.gz. |
content_type |
no | application/json |
application/json. |
Current object content type. |
object_metadata |
no | {} |
Up to 32 bounded string pairs. | Static low-cardinality metadata. Secret-bearing names such as Authorization are rejected. |
server_side_encryption |
no | none |
none or AES256. |
Requests provider-managed server-side encryption where the compatible service supports it. |
max_key_bytes |
no | 1024 |
64 to 1024. |
UTF-8 object-key size limit. |
max_object_bytes |
no | 16777216 |
1 to 1073741824. |
Maximum primary or sidecar object body size after compression. |
max_metadata_bytes |
no | 4096 |
128 to 65536. |
Maximum aggregate object metadata size. |
request_timeout_seconds |
no | 10 |
>0 to 300. |
Per-object timeout around the client write call. |
max_retries |
no | 0 |
0 to 10. |
In-sink retries for destination-unavailable failures. |
retry_backoff_ms |
no | 250 |
0 to 60000. |
Initial retry backoff. |
retry_max_backoff_ms |
no | 5000 |
0 to 300000. |
Retry backoff cap. |
retry_backoff_mode |
no | exponential |
fixed, linear, or exponential. |
Shared retry scheduler mode. |
retry_backoff_multiplier |
no | 2.0 |
1.0 to 10.0. |
Exponential multiplier. |
retry_jitter |
no | full |
none, full, or equal. |
Jitter mode for retries. |
Object Key Strategies
| Strategy | Key shape | Required message data | Guidance |
|---|---|---|---|
idempotency_key |
idempotency-key/<safe-envelope-key>.json |
Stable framework idempotency key. | Recommended default. Prefers stream sequence, then message ID, then payload hash when safe. |
stream_sequence |
stream-sequence/<stream>/<sequence>.json |
JetStream stream and sequence. | Recommended for normal durable JetStream consumers. |
message_id |
message-id/<Nats-Msg-Id>.json |
Publisher message ID. | Use only when publishers reliably set unique IDs. |
payload_sha256 |
payload-sha256/<subject>/<digest>.json |
Payload bytes and subject. | Useful for controlled archives where identical payloads should collapse. Avoid as the only identity when payload encryption is enabled. |
The sink normalizes unsafe key characters into deterministic safe components, then enforces the configured key-size limit. Message content never selects a bucket, endpoint, credential source, or arbitrary object prefix.
Duplicate Policies
| Policy | Client behavior | Redelivery effect |
|---|---|---|
skip_existing |
Put with If-None-Match: *; an existing key is treated as prior success. |
Recommended default for at-least-once delivery. |
replace |
Put without the conditional header. | Overwrites existing objects. Use only when replacement is intentional and reviewed. |
fail_existing |
Put with If-None-Match: *; an existing key raises a permanent sink error. |
Useful when duplicates should go to DLQ or operator review. Not allowed with sidecar metadata mode. |
skip_existing is the safest default because redelivery after a crash can
arrive after the object was already stored but before the original ACK reached
JetStream.
Stored Object Shape
With object_format="envelope", the primary object contains the normalized
event:
{
"schema": "nats_sinks.s3.object.v1",
"schema_version": 1,
"subject": "events.created",
"stream": "EVENTS",
"stream_sequence": 42,
"consumer": "s3-events-sink",
"consumer_sequence": 7,
"message_id": "event-42",
"priority": "normal",
"classification": "unclassified",
"labels": "archive;audit",
"labels_list": ["archive", "audit"],
"payload": {
"event_id": "event-42",
"status": "accepted"
},
"payload_info": {
"original_format": "json",
"wrapped": false,
"sha256": "example-redacted",
"size_bytes": 42
},
"mission_metadata": null,
"security_labels": null,
"custody": null,
"metadata": {
"schema": "nats_sinks.metadata.v1"
}
}
With object_format="payload", the object body is only the normalized payload
JSON value. Use that mode only when the downstream process does not need the
standard event metadata inside the object body.
Metadata Modes
| Mode | Behavior |
|---|---|
none |
Sends no object metadata beyond the object body. |
object_metadata |
Adds low-sensitivity static metadata and nats-sinks schema hints to object metadata. Raw subjects, classifications, labels, payloads, table names, and message IDs are not added by default. Message ID is represented only as a SHA-256 hash when present. |
sidecar |
Writes the primary object plus a deterministic sidecar object containing event metadata and payload hashes, without duplicating the payload body. |
Sidecar writes are useful when downstream object readers need a smaller
metadata document next to large payload-focused objects. Sidecar mode is not
allowed with duplicate_policy="fail_existing" because a duplicate primary
object plus missing sidecar is a recovery shape, not a permanent data error.
Least Privilege
Give the sink identity only the minimum bucket permissions needed for the chosen policy:
| Sink setting | Minimum operation shape |
|---|---|
skip_existing |
Put object with conditional create semantics for the configured prefix. |
replace |
Put object for the configured prefix. Review overwrite permission carefully. |
metadata_mode="sidecar" |
Put object for both the primary prefix and sidecar suffix. |
Avoid granting bucket administration, lifecycle management, policy editing, delete, list-all-buckets, or unrelated prefix access to the sink process. When the storage service supports scoped credentials, bind the identity to the specific bucket and prefix used by the configuration.
Privacy And Security Notes
- Treat bucket names, prefixes, object keys, timestamps, stream names, and object counts as operational metadata.
- Do not put secrets, private endpoint details, classification values, labels,
subjects, payload fields, or message IDs into
object_metadata. - Use environment-backed credentials or a reviewed SDK profile. Do not store access keys in JSON configuration.
- Prefer HTTPS endpoints. Cleartext HTTP is accepted only for loopback local
testing after
allow_http_for_local_testing=true. - Provider-managed server-side encryption can protect stored objects at rest, but it does not encrypt subjects, object keys, bucket names, metrics, logs, or issue comments.
- Framework payload encryption still protects only payload bytes before the sink sees them. Metadata remains visible unless separately protected by the destination and operational process.
Fan-Out Example
This example requires Oracle Database before ACK and gives the S3 archive copy a bounded optional window. The optional S3 copy may complete before ACK, but it is not part of the required custody path:
{
"sink": {
"type": "fanout"
},
"sinks": {
"oracle_primary": {
"type": "oracle",
"dsn": "tcps://database.example.invalid/service",
"user": "app_user",
"password_env": "NATS_SINKS_ORACLE_PASSWORD",
"table": "NATS_SINK_EVENTS"
},
"s3_archive": {
"type": "s3",
"bucket": "nats-sinks-events",
"prefix": "archives/events",
"key_strategy": "stream_sequence"
}
},
"routing": {
"enabled": true,
"target_sink_types": {
"oracle_primary": "oracle",
"s3_archive": "s3"
},
"routes": [
{
"name": "archive_events",
"match": {
"subject": "events.>"
},
"targets": [
"oracle_primary",
{
"sink": "s3_archive",
"required": false,
"minimum_wait_ms": 1000,
"timeout_ms": 5000
}
]
}
]
}
}
If minimum_wait_ms or timeout_ms is omitted for an optional S3 target, the
configuration loader applies the S3 defaults: 1000 ms minimum wait and
5000 ms timeout.
Testing
Local unit and CLI checks:
python -m pytest tests/unit/test_s3_sink.py -q
python -m pytest tests/unit/test_cli.py::test_cli_validates_s3_sink_config -q
nats-sink validate examples/s3-basic/config.json
Expected example validation output:
Configuration is valid.
Active sink: s3
ACK policy: commit-then-acknowledge
Live tests should be added only behind explicit environment gates such as
NATS_SINKS_RUN_S3_E2E=1, with fake payloads, temporary prefixes, scoped
credentials, bounded object sizes, cleanup guidance, and sanitized reports.
Do not run live object-storage tests from the default unit suite.
Recovery
For normal outages, leave the JetStream message unacknowledged or NAKed through
the core delivery policy and let redelivery retry the same deterministic key.
For object stores with delayed visibility or partial failures, prefer
skip_existing and review sidecar recovery separately. If a sidecar is missing
after a duplicate primary object, the sink attempts to write the deterministic
sidecar during redelivery.
When operators need to repair an archive manually, use the object key strategy, stream sequence, message ID, payload hash, and stored object schema to reconcile objects. Never paste object contents, credentials, bucket policies, private endpoint names, or sensitive metadata into issue comments or release evidence.