A Helm chart for deploying the S3 Encryption Gateway - a transparent proxy that provides client-side encryption for S3-compatible storage services.
The S3 Encryption Gateway sits between S3 clients and backend storage providers, encrypting/decrypting data transparently while maintaining full S3 API compatibility. This Helm chart simplifies deployment to Kubernetes clusters.
Bucket creation is disabled by default. Set config.allowBucketCreation.value
to "true" only with matching credential scope and an explicit create grant.
rw does not infer management access. Delete is independently granted with
delete; backend IAM must permit the operation. Authorized CreateBucket
requests preserve LocationConstraint bytes and backend responses.
This chart is available at: https://cloud37.github.io/s3-encryption-gateway
Signed OCI releases are published at ghcr.io/cloud37/s3-encryption-gateway.
Install a specific chart version with:
helm install my-gateway oci://ghcr.io/cloud37/s3-encryption-gateway \
--version 0.11.10
For strongest artifact pinning, install by the immutable manifest digest:
helm install my-gateway \
oci://ghcr.io/cloud37/s3-encryption-gateway@sha256:<manifest-digest>
Verify the digest-qualified chart with the GitHub Actions keyless identity
before installation (replace <manifest-digest> with the release digest):
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/cloud37/s3-encryption-gateway/\.github/workflows/helm\.yml@refs/heads/(main|master)$' \
ghcr.io/cloud37/s3-encryption-gateway@sha256:<manifest-digest>
helm repo add s3-encryption-gateway https://cloud37.github.io/s3-encryption-gateway
helm repo update
helm install my-gateway s3-encryption-gateway/s3-encryption-gateway \
--set config.backend.accessKey.valueFrom.secretKeyRef.name=my-secrets \
--set config.backend.accessKey.valueFrom.secretKeyRef.key=access-key \
--set config.backend.secretKey.valueFrom.secretKeyRef.name=my-secrets \
--set config.backend.secretKey.valueFrom.secretKeyRef.key=secret-key \
--set config.encryption.password.valueFrom.secretKeyRef.name=my-secrets \
--set config.encryption.password.valueFrom.secretKeyRef.key=encryption-password
All configuration options support two methods:
values.yaml or via --setMost sensitive values should be stored in Kubernetes Secrets and referenced:
config:
backend:
accessKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-access-key
secretKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-secret-key
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
Non-sensitive configuration can be stored in ConfigMaps:
config:
backend:
endpoint:
valueFrom:
configMapKeyRef:
name: s3-encryption-gateway-config
key: backend-endpoint
region:
valueFrom:
configMapKeyRef:
name: s3-encryption-gateway-config
key: backend-region
| Parameter | Description | Default |
|---|---|---|
replicaCount |
Number of replicas | 1 |
track |
Blue/green or canary track label ("", "blue", "green", "stable", "canary"). Empty = no label (backward-compatible). When set, a shared external Valkey address is required and valkey.enabled must be false. |
"" |
config.listenAddr |
Listen address | ":8080" |
config.logLevel |
Log level (debug, info, warn, error) |
"info" |
config.proxiedBucket |
Single bucket proxy mode — restricts gateway to one backend bucket (optional) | "" |
config.policies |
Glob path to per-bucket policy YAML files mounted in the container (optional) | "" |
| Parameter | Description | Default |
|———–|————-|———|
| config.backend.endpoint | S3 backend endpoint URL | "https://s3.amazonaws.com" |
| config.backend.region | S3 backend region | "us-east-1" |
| config.backend.accessKey | Backend access key (use valueFrom) | "" |
| config.backend.secretKey | Backend secret key (use valueFrom) | "" |
| config.backend.provider | Provider hint string (optional) | "" |
| config.backend.useSSL | Selects HTTPS for scheme-less backend endpoints; explicit http:// or https:// endpoint schemes take precedence | "true" |
| config.backend.usePathStyle | Use path-style bucket addressing | "false" |
Gateway credentials are validated at the gateway and backend credentials are never forwarded from clients. Configure config.auth.credentials[].buckets with exact names or trailing-prefix scopes such as tenant-*; omit it or use ["*"] for unrestricted access, or use [] for deny-all. A bare * is broad authority, particularly with create, delete, or manage. permissions is ro or rw, while bucketPermissions independently grants create, delete, and manage (bucket configuration administration). Root ListBuckets is allowed for both ro and rw (with omitted permissions defaulting to rw): an absent buckets scope returns backend-visible buckets, while an explicit empty buckets: [] scope returns a successful empty inventory.
Existing credentials remain unrestricted and read-write when buckets and
permissions are omitted. To migrate safely, first add explicit bucket scopes,
then use ro for readers and grant create or delete only where required.
Exact bucket names, the explicit unrestricted *, and non-empty trailing-prefix
patterns such as tenant-* are supported; other wildcard forms are rejected.
Credential policy files and the main configuration file can be reloaded with
SIGHUP when the deployment is configured to watch them. A failed reload keeps
the previous complete policy snapshot. Helm-provided environment variables are
process environment, so changing a Secret or Helm value requires a pod restart;
it is not a live environment reload. Roll out credential changes atomically and
verify the rendered environment before removing old access.
| Parameter | Description | Default |
|---|---|---|
config.encryption.password |
Master encryption password (use valueFrom for production) | "" |
config.encryption.keyFile |
Path to an encryption key file (optional) | "" |
config.encryption.preferredAlgorithm |
Preferred AEAD algorithm (AES256-GCM, ChaCha20-Poly1305) |
"AES256-GCM" |
config.encryption.supportedAlgorithms |
Comma-separated list of algorithms accepted for decryption | "AES256-GCM,ChaCha20-Poly1305" |
config.encryption.keyManager.enabled |
Enable envelope encryption key-manager mode | "false" |
config.encryption.keyManager.provider |
KEK provider: self_contained (default, no external KMS), cosmian, memory |
"self_contained" |
config.encryption.keyManager.dualReadWindow |
Number of previous key versions tried during rotation | "1" |
config.encryption.keyManager.selfContained.type |
Sub-type: rsa (recommended) or aes |
"rsa" |
config.encryption.keyManager.selfContained.rsa.privateKeySource |
RSA PEM key source — prefix env:VAR, base64:DATA, or file:PATH |
"" |
config.encryption.keyManager.selfContained.rsa.keyVersion |
RSA key version (integer ≥ 1; increment to rotate) | "1" |
config.encryption.keyManager.selfContained.aes.activeVersion |
Active AES key version for wrapping new DEKs | "1" |
config.encryption.keyManager.selfContained.aes.keys |
Comma-separated AES-256 key entries ("1=env:VAR,2=base64:DATA") |
"" |
config.encryption.keyManager.cosmian.endpoint |
Cosmian KMIP endpoint (JSON/HTTP: http://host:9998/kmip/2_1; binary: host:5696) |
"" |
config.encryption.keyManager.cosmian.timeout |
KMS operation timeout | "10s" |
config.encryption.keyManager.cosmian.keys |
Comma-separated wrapping keys ("key1:v1,key2:v2") |
"" |
config.encryption.keyManager.cosmian.caCert |
CA certificate for TLS (use valueFrom) | "" |
config.encryption.keyManager.cosmian.clientCert |
Client certificate for mTLS (use valueFrom) | "" |
config.encryption.keyManager.cosmian.clientKey |
Client private key for mTLS (use valueFrom) | "" |
config.encryption.keyManager.cosmian.insecureSkipVerify |
Skip TLS verification (testing only) | "false" |
| Parameter | Description | Default |
|---|---|---|
config.encryption.kdf.algorithm |
KDF algorithm: "pbkdf2-sha256" (default, FIPS-compatible) or "argon2id" |
"pbkdf2-sha256" |
config.encryption.kdf.pbkdf2.iterations |
PBKDF2-SHA256 iteration count (≥ 600000 recommended per NIST SP 800-132) | "600000" |
config.encryption.kdf.argon2id.time |
Argon2id passes (range: 1–10) | "2" |
config.encryption.kdf.argon2id.memory |
Argon2id memory in KiB (range: 8192–1048576) | "19456" |
config.encryption.kdf.argon2id.threads |
Argon2id parallelism/threads (range: 1–256) | "1" |
FIPS note: When the binary is compiled with
-tags=fips, Argon2id is rejected at startup.
Key Manager Configuration: When config.encryption.keyManager.enabled is set to "true", the gateway performs envelope encryption — each object gets a unique DEK that is wrapped by a Key Encryption Key (KEK). Three providers are available:
self_contained (default, recommended): No external KMS required. The gateway wraps DEKs with a local RSA or AES key. The key material is loaded at startup from an environment variable, a mounted secret file, or an inline base64 value. No additional components to deploy or operate.cosmian: Wraps DEKs with Cosmian KMS over the KMIP protocol.memory: Ephemeral in-memory key (testing only — encrypted objects are unreadable after a restart).See the KMS Compatibility Guide for details.
Example — self-contained RSA envelope encryption (recommended):
# Create the RSA private key Secret:
# openssl genrsa 2048 | kubectl create secret generic self-contained-kek \
# --from-file=private-key.pem=/dev/stdin
config:
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
keyManager:
enabled:
value: "true"
provider:
value: "self_contained"
selfContained:
type:
value: "rsa"
rsa:
privateKeySource:
# "env:VAR" tells the gateway to read the PEM from the RSA_KEK_PRIVATE_KEY env var.
# The valueFrom below injects the Secret data into that env var.
value: "env:RSA_KEK_PRIVATE_KEY"
keyVersion:
value: "1"
# Inject the key material as an env var via extraEnv:
extraEnv:
- name: RSA_KEK_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: self-contained-kek
key: private-key.pem
Example — self-contained AES envelope encryption (multi-version rotation):
# Store base64-encoded 32-byte keys in a Secret:
# KEY1=$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 -w0)
# KEY2=$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 -w0)
# kubectl create secret generic aes-kek --from-literal=key1="$KEY1" --from-literal=key2="$KEY2"
config:
encryption:
keyManager:
enabled:
value: "true"
provider:
value: "self_contained"
selfContained:
type:
value: "aes"
aes:
activeVersion:
value: "2" # wraps new objects with key v2
keys:
# "1=env:AES_KEK_V1,2=env:AES_KEK_V2" — both versions available for unwrap
value: "1=env:AES_KEK_V1,2=env:AES_KEK_V2"
extraEnv:
- name: AES_KEK_V1
valueFrom:
secretKeyRef:
name: aes-kek
key: key1
- name: AES_KEK_V2
valueFrom:
secretKeyRef:
name: aes-kek
key: key2
Example — Cosmian KMIP provider:
config:
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
keyManager:
enabled:
value: "true"
provider:
value: "cosmian"
dualReadWindow:
value: "1"
cosmian:
endpoint:
value: "https://cosmian-kms:9998/kmip/2_1"
insecureAllowPlaintextTransport:
value: "false"
timeout:
value: "10s"
keys:
value: "wrapping-key-1:1"
caCert:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: ca-cert
clientCert:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: client-cert
clientKey:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: client-key
insecureSkipVerify:
value: "false"
Cosmian Protocol Selection:
https://host:9998/kmip/2_1insecureAllowPlaintextTransport.value: "true" explicitly. HTTP transmits plaintext DEKs and is rejected by default.host:5696 — requires caCert, clientCert, clientKey (mutual TLS)Valkey (or any Redis-protocol-compatible store) is the gateway’s shared state backend. As of v1.0 a single Valkey instance and one connection pool serve two features:
UploadState blobs
(mpu:<id>, 7-day TTL). Required (fail-closed) when any policy sets
encrypt_multipart_uploads: true.plainsize:<bucket> so ListObjects[i].Size == HeadObject(key).Content-Length
without per-object HEAD calls. Strongly recommended; fail-soft to
ciphertext sizes if Valkey is unavailable.Use the built-in Valkey subchart for development or point at an external cluster for production.
Valkey subchart (development / single-release deployments):
| Parameter | Description | Default |
|---|---|---|
valkey.enabled |
Deploy the Valkey subchart in-cluster. Set to false when using an external Valkey or in blue/green topologies. |
false |
valkey.architecture |
Valkey architecture (standalone, replication) |
standalone |
valkey.auth.enabled |
Enable Valkey authentication | false |
Blue/green and canary deployments:
valkey.enabledmust befalse. Both tracks must share a single external Valkey cluster. Set the address viaconfig.multipartState.valkey.addr.
External Valkey connection (config.multipartState.valkey.*):
| Parameter | Description | Default |
|---|---|---|
config.multipartState.valkey.addr |
External Valkey address (host:port). Auto-wired when valkey.enabled: true. |
"" |
config.multipartState.valkey.tls.enabled |
Enable TLS for Valkey connection | "" |
config.multipartState.valkey.tls.caFile |
CA certificate file for Valkey TLS | "" |
config.multipartState.valkey.tls.certFile |
Client certificate file for Valkey mTLS | "" |
config.multipartState.valkey.tls.keyFile |
Client key file for Valkey mTLS | "" |
config.multipartState.valkey.insecureAllowPlaintext |
Allow plaintext Valkey (development only) | "" |
config.multipartState.valkey.ttlSeconds |
TTL for in-flight MPU state records in Valkey (default: 604800 = 7 days) |
"" |
config.multipartState.valkey.stateV2Writer.enabled.value |
Enable the fixed state-v2 encrypted-MPU writer capability required after the v0.12 rollout |
"" |
config.multipartState.valkey.encryptionPassword |
Dedicated password for Valkey at-rest encryption (V1.0-CRYPTO-2). Provide via .value (plaintext) or .valueFrom (secret ref). When set, VALKEY_ENCRYPT_STATE is automatically "true" |
"" |
CRYPTO-2 note: When
encryptionPasswordis set (either.valueor.valueFrom), the gateway automatically enables at-rest encryption (VALKEY_ENCRYPT_STATE=true). If unset, the gateway falls back to the mainENCRYPTION_PASSWORDwith a distinct HKDF salt.
v0.12 encrypted-MPU rollout: Enable
config.multipartState.valkey.stateV2Writer.enabled.value: "true"on the second Helm upgrade. Helm rendersVALKEY_MPU_STATE_V2_WRITER=truefor every replica; the gateway derives the fixed internal protocol capability. Before that upgrade, complete a separate scale-down to one old replica withhelm upgrade RELEASE CHART --reuse-values --set replicaCount=1, then wait forkubectl rollout status deployment/DEPLOYMENT. Do not combine this scale-down with the image upgrade. Drain or abort all in-flight encrypted MPUs before the second upgrade. The chart usesmaxSurge: 0andmaxUnavailable: 1, so this single old writer is terminated before its v0.12 replacement starts. That first state-v2 writer atomically initializes Valkeympu:writer-version; later replicas verify the same value. Do not use this upgrade path with multiple old replicas, because legacy writers cannot be detected by the new release.
ListObjects size cache (config.listSizeTranslate.*) — V1.0-S3-3. Reuses the
Valkey instance above; no separate deployment. All fields use the
configValue shape (.value / .valueFrom).
| Parameter | Description | Default |
|---|---|---|
config.listSizeTranslate.enabled |
Enable size-cache lookup on ListObjects. Automatically false when Valkey is not configured. | true (when Valkey configured) |
config.listSizeTranslate.fallbackHeadEnabled |
Issue bounded concurrent HeadObject calls for cache misses. Disabled by default to avoid per-API-call billing amplification on Wasabi / R2 / B2. |
false |
config.listSizeTranslate.fallbackHeadConcurrency |
Max concurrent HeadObject goroutines per listing page (1–100). |
10 |
config.listSizeTranslate.fallbackHeadTimeout |
Per-page deadline for the HEAD batch (100ms–60s). | 5s |
Operational note: The size cache has no TTL — entries persist until evicted by
DeleteObject/DeleteObjects. Objects uploaded before enabling the feature are not auto-warmed; temporarily setfallbackHeadEnabled: trueto warm them via normal listing traffic. Seedocs/RUNBOOK.md.
| Parameter | Description | Default | |———–|————-|———|
| Parameter | Description | Default |
|---|---|---|
config.server.readTimeout |
HTTP read timeout (0 = disabled; use readHeaderTimeout for slow-loris protection) | "0s" |
config.server.writeTimeout |
HTTP write timeout (0 = disabled; required for large object streaming) | "0s" |
config.server.idleTimeout |
HTTP idle connection timeout | "60s" |
config.server.readHeaderTimeout |
HTTP read header timeout | "10s" |
config.server.maxHeaderBytes |
Maximum request header size (bytes) | "1048576" |
| Parameter | Description | Default |
|---|---|---|
config.tls.enabled |
Enable TLS on the gateway listener | "false" |
config.tls.useCertManager |
Provision certificates automatically via cert-manager | "false" |
config.tls.certFile |
TLS certificate file path (when not using cert-manager) | "" |
config.tls.keyFile |
TLS private key file path (when not using cert-manager) | "" |
Note: When config.tls.enabled is "true" and config.tls.useCertManager is "false", you must provide certFile and keyFile (validated by the values schema). When TLS is enabled the Service automatically uses port 443 with port name https.
| Parameter | Description | Default |
|---|---|---|
certManager.issuer.name |
Issuer resource name (defaults to chart fullname) | "" |
certManager.issuer.namespace |
Issuer namespace (defaults to release namespace) | "" |
certManager.issuer.selfSigned |
Self-signed issuer spec (set to {} to use self-signed) |
{} |
certManager.issuer.clusterIssuer |
Name of a pre-existing ClusterIssuer (alternative to selfSigned) | "" |
certManager.certificate.extraDNSNames |
Additional DNS SANs for the certificate | [] |
certManager.certificate.duration |
Certificate validity period | "2160h" |
certManager.certificate.renewBefore |
Renew this long before expiry | "720h" |
cert-manager Integration: When config.tls.useCertManager is enabled, the chart automatically creates Issuer and Certificate resources. The TLS certificate and key are automatically mounted into the pod.
| Parameter | Description | Default |
|---|---|---|
config.rateLimit.enabled |
Enable per-connection rate limiting | "false" |
config.rateLimit.limit |
Maximum requests per window | "100" |
config.rateLimit.window |
Rate limit time window | "60s" |
| Parameter | Description | Default |
|---|---|---|
config.cache.enabled |
Enable response caching | "false" |
config.cache.maxSize |
Maximum cache size (bytes) | "104857600" |
config.cache.maxItems |
Maximum number of cached items | "1000" |
config.cache.defaultTTL |
Default cache entry TTL | "5m" |
| Parameter | Description | Default |
|---|---|---|
config.audit.enabled |
Enable audit logging | "false" |
config.audit.maxEvents |
Maximum in-memory audit events | "10000" |
config.audit.redactMetadataKeys |
Comma-separated metadata key names to redact from audit log | "" |
config.audit.sink.type |
Sink type (stdout, file, http) |
"stdout" |
config.audit.sink.endpoint |
HTTP sink endpoint URL (for type=http) |
"" |
config.audit.sink.filePath |
Log file path (for type=file) |
"" |
config.audit.sink.batchSize |
Maximum events per write batch | "100" |
config.audit.sink.flushInterval |
Maximum time between flushes | "5s" |
config.audit.sink.retryCount |
Retries on failed writes | "3" |
config.audit.sink.retryBackoff |
Initial retry backoff duration | "1s" |
| Parameter | Description | Default |
|---|---|---|
ingress.enabled |
Create a standard networking.k8s.io/v1 Ingress |
false |
ingress.className |
Ingress class name | "" |
ingress.annotations |
Additional ingress annotations | {} |
ingress.hosts |
List of ingress hosts and paths | [] |
ingress.tls |
TLS configuration for the Ingress | [] |
Traefik CRD Ingress (mutually exclusive with ingress.enabled):
| Parameter | Description | Default |
|---|---|---|
ingress.traefik.enabled |
Render a Traefik IngressRoute CRD (requires Traefik ≥ v3.0) |
false |
ingress.traefik.entryPoints |
Traefik entrypoint names | ["websecure"] |
ingress.traefik.host |
Host() matcher — the S3 hostname clients will use |
"" |
ingress.traefik.tls |
TLS stanza (secretName, certResolver) |
{} |
ingress.traefik.middlewares |
List of Traefik Middleware references (name, namespace) |
[] |
ingress.traefik.weighted.enabled |
Render a kind: Weighted TraefikService for canary traffic splitting |
false |
ingress.traefik.weighted.services |
List of backend services with weights summing to 100 (name, port, weight) |
[] |
ingress.traefik.weighted.sticky |
Sticky session cookie config (cookie.name, httpOnly, secure, sameSite) |
{} |
Common Ingress Annotations:
kubernetes.io/ingress.class: nginxcert-manager.io/cluster-issuer: letsencrypt-prodnginx.ingress.kubernetes.io/ssl-redirect: "true"nginx.ingress.kubernetes.io/proxy-body-size: "0"nginx.ingress.kubernetes.io/proxy-read-timeout: "600"| Parameter | Description | Default |
|---|---|---|
image.repository |
Container image repository | cloud37io/s3-encryption-gateway |
image.tag |
Container image tag | "0.11.10" |
image.pullPolicy |
Image pull policy | IfNotPresent |
imagePullSecrets |
Image pull secrets | [] |
nameOverride |
Override the chart name portion of resource names | "" |
fullnameOverride |
Override the full resource name | "" |
podAnnotations |
Extra annotations added to every pod | {} |
podSecurityContext |
Pod-level security context (runAsNonRoot, runAsUser, fsGroup) |
See values.yaml |
securityContext |
Container-level security context (readOnlyRootFilesystem, allowPrivilegeEscalation, seccompProfile, etc.) |
See values.yaml |
terminationGracePeriodSeconds |
Pod termination grace period. Increase for blue/green drain (≥ p99 request duration + 10 s). | 30 |
lifecycle |
Container lifecycle hooks. Use a preStop sleep equal to the kube-proxy propagation tail for zero-downtime traffic flips. |
{} |
resources |
CPU/memory resource requests and limits | See values.yaml |
nodeSelector |
Node selector labels | {} |
tolerations |
Pod tolerations | [] |
affinity |
Pod affinity/anti-affinity rules | {} |
topologySpreadConstraints |
Pod topology spread constraints | [] |
| Parameter | Description | Default |
|---|---|---|
livenessProbe.httpGet.path |
Liveness probe path | /live |
livenessProbe.initialDelaySeconds |
Seconds before first liveness check | 10 |
livenessProbe.periodSeconds |
Liveness probe period | 30 |
livenessProbe.timeoutSeconds |
Liveness probe timeout | 3 |
livenessProbe.failureThreshold |
Failures before pod is restarted | 3 |
readinessProbe.httpGet.path |
Readiness probe path | /ready |
readinessProbe.initialDelaySeconds |
Seconds before first readiness check | 5 |
readinessProbe.periodSeconds |
Readiness probe period | 10 |
readinessProbe.timeoutSeconds |
Readiness probe timeout | 3 |
readinessProbe.failureThreshold |
Failures before pod is removed from endpoints | 3 |
The /ready endpoint performs dependency health checks (KMS, Valkey) and returns 503 with a JSON checks map if any configured dependency is unhealthy. The aliases /readyz, /healthz, and /livez follow Kubernetes conventions.
| Parameter | Description | Default |
|---|---|---|
serviceAccount.create |
Create a ServiceAccount for the gateway |
true |
serviceAccount.annotations |
Annotations to add to the ServiceAccount (e.g. IRSA / Workload Identity) | {} |
serviceAccount.name |
Override the generated ServiceAccount name | "" |
| Parameter | Description | Default |
|---|---|---|
service.enabled |
Create a Kubernetes Service |
true |
service.type |
Service type | ClusterIP |
service.port |
Service port (overridden to 443 when TLS is enabled) |
80 |
service.targetPort |
Container target port | 8080 |
Note: When service.enabled is false, ServiceMonitor is also disabled automatically. Keep the Service enabled for stable DNS-based service discovery.
| Parameter | Description | Default |
|---|---|---|
autoscaling.enabled |
Enable Horizontal Pod Autoscaler | false |
autoscaling.minReplicas |
Minimum number of replicas | 2 |
autoscaling.maxReplicas |
Maximum number of replicas | 10 |
autoscaling.targetCPUUtilizationPercentage |
CPU utilization target (%) | 70 |
autoscaling.targetMemoryUtilizationPercentage |
Memory utilization target (%) | 80 |
autoscaling.behavior.scaleDown.stabilizationWindowSeconds |
Scale-down stabilisation window | 300 |
autoscaling.behavior.scaleUp.stabilizationWindowSeconds |
Scale-up stabilisation window | 0 |
| Parameter | Description | Default |
|---|---|---|
podDisruptionBudget.enabled |
Create a PodDisruptionBudget |
false |
podDisruptionBudget.minAvailable |
Minimum available pods during disruption (integer or percentage) | "" |
podDisruptionBudget.maxUnavailable |
Maximum unavailable pods during disruption (integer or percentage) | "" |
| Parameter | Description | Default |
|---|---|---|
serviceMonitor.enabled |
Create a Prometheus Operator ServiceMonitor |
false |
serviceMonitor.interval |
Scrape interval | 30s |
serviceMonitor.scrapeTimeout |
Scrape timeout | 10s |
serviceMonitor.labels |
Extra labels for ServiceMonitor (e.g. prometheus: kube-prometheus) |
{} |
podMonitor.enabled |
Create a Prometheus Operator PodMonitor (alternative to ServiceMonitor) |
false |
podMonitor.interval |
Scrape interval | 30s |
podMonitor.scrapeTimeout |
Scrape timeout | 10s |
podMonitor.labels |
Extra labels for PodMonitor | {} |
metrics.port |
Dedicated unauthenticated /metrics listener port; 0 uses the admin or S3 port fallback |
0 |
metrics.enableBucketLabel |
Preserve bucket names on bucket-labelled S3 metrics; disabled mode uses bucket="*" |
false |
ServiceMonitor vs PodMonitor: ServiceMonitor targets the Service (recommended). PodMonitor targets pods directly — useful when the Service is disabled or for fine-grained pod-level metrics. Both emit a track relabel rule when track is set, enabling per-track PromQL queries in blue/green topologies.
The gateway exposes the following Prometheus metrics for S3 client traffic:
s3_client_requests_total{operation,bucket,status_code} records each completed S3 route once.s3_client_bytes_total{bucket,direction} records actual plaintext application-body bytes at the client boundary. direction is in for uploads and out for downloads.Input bytes are counted after AWS streaming framing is decoded; output bytes are counted only when successfully written. Headers, TLS framing, backend retries, and internal copy traffic are excluded.
Set metrics.enableBucketLabel: true to preserve bucket names. With the default false, all bucket labels are collapsed to * to bound cardinality. Labels never contain object keys, upload IDs, credentials, paths, or error text.
| Parameter | Description | Default |
|---|---|---|
networkPolicy.enabled |
Create a NetworkPolicy |
false |
networkPolicy.policyTypes |
Policy types to enforce | [Ingress, Egress] |
networkPolicy.namespaceIsolation |
Restrict ingress to pods in the same namespace only | true |
networkPolicy.namespaceLabel.key |
Namespace label key used for isolation matching | "kubernetes.io/metadata.name" |
networkPolicy.egress.awsS3 |
Allow egress to AWS S3 (adjust CIDRs per region) | false |
networkPolicy.egress.minioInternal |
Allow egress to an in-cluster MinIO service | false |
networkPolicy.egress.monitoring |
Allow egress to monitoring/logging services | false |
networkPolicy.ingress.ingressControllers |
Allow ingress from ingress controller pods | false |
networkPolicy.ingress.ingressNamespace |
Namespace of the ingress controller | "ingress-nginx" |
networkPolicy.ingress.monitoring |
Allow ingress from Prometheus scrape pods | false |
networkPolicy.ingress.monitoringNamespace |
Namespace of the Prometheus stack | "monitoring" |
Namespace Isolation: When namespaceIsolation is enabled (default), only pods in the same namespace can access the gateway. Most Kubernetes distributions auto-label namespaces with kubernetes.io/metadata.name. If yours does not, either label it or set a custom namespaceLabel.key.
The Helm chart supports several extension points to customize the deployment for advanced use cases.
Add custom environment variables to the main container:
extraEnv:
- name: MY_CUSTOM_VAR
value: "my-value"
- name: MY_SECRET_VAR
valueFrom:
secretKeyRef:
name: my-secret
key: my-key
Mount additional volumes into the main container:
extraVolumes:
- name: my-config
configMap:
name: my-configmap
- name: my-secret-volume
secret:
secretName: my-secret
extraVolumeMounts:
- name: my-config
mountPath: /etc/my-config
readOnly: true
- name: my-secret-volume
mountPath: /etc/my-secrets
readOnly: true
Run initialization containers before the main gateway starts:
initContainers:
- name: init-myservice
image: busybox:1.35
command: ['sh', '-c', 'echo "Initializing..." && sleep 5']
volumeMounts:
- name: shared-data
mountPath: /data
Run sidecar containers alongside the main gateway:
sidecars:
- name: sidecar-logger
image: fluent/fluent-bit:2.0
ports:
- containerPort: 2020
volumeMounts:
- name: varlogcontainers
mountPath: /var/log/containers
readOnly: true
# Create secrets first
kubectl create secret generic s3-encryption-gateway-secrets \
--from-literal=backend-access-key='YOUR_ACCESS_KEY' \
--from-literal=backend-secret-key='YOUR_SECRET_KEY' \
--from-literal=encryption-password='YOUR_ENCRYPTION_PASSWORD'
# Install with secrets
helm install my-gateway s3-encryption-gateway/s3-encryption-gateway \
--namespace default
Deploy with self-contained KEK — no external KMS service needed. Ideal for environments where you want envelope encryption without additional infrastructure.
# Generate a 2048-bit RSA key and store it as a Kubernetes Secret:
openssl genrsa 2048 | kubectl create secret generic self-contained-kek \
--from-file=private-key.pem=/dev/stdin
kubectl create secret generic s3-encryption-gateway-secrets \
--from-literal=backend-access-key='YOUR_ACCESS_KEY' \
--from-literal=backend-secret-key='YOUR_SECRET_KEY' \
--from-literal=encryption-password='fallback-password-123456'
config:
backend:
endpoint:
value: "https://s3.amazonaws.com"
region:
value: "us-east-1"
accessKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-access-key
secretKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-secret-key
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
keyManager:
enabled:
value: "true"
provider:
value: "self_contained"
selfContained:
type:
value: "rsa"
rsa:
privateKeySource:
value: "env:RSA_KEK_PRIVATE_KEY"
keyVersion:
value: "1"
extraEnv:
- name: RSA_KEK_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: self-contained-kek
key: private-key.pem
Notes:
encryption.password remains required as a fallback for password-encrypted objectskeyVersion and add the new key material to trigger key rotation; old objects remain readable via dualReadWindowDeploy the gateway with external KMS (Cosmian KMIP) for envelope encryption and key rotation:
kubectl create secret generic s3-encryption-gateway-secrets \
--from-literal=backend-access-key='YOUR_ACCESS_KEY' \
--from-literal=backend-secret-key='YOUR_SECRET_KEY' \
--from-literal=encryption-password='fallback-password-123456'
# If using TLS, also create certificate secrets
kubectl create secret generic cosmian-kms-certs \
--from-file=ca-cert=/path/to/ca.pem \
--from-file=client-cert=/path/to/client.crt \
--from-file=client-key=/path/to/client.key
config:
backend:
endpoint:
value: "https://s3.amazonaws.com"
region:
value: "us-east-1"
accessKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-access-key
secretKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-secret-key
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
keyManager:
enabled:
value: "true"
provider:
value: "cosmian"
dualReadWindow:
value: "1"
cosmian:
endpoint:
# JSON/HTTPS (recommended): full URL or base URL with auto-appended /kmip/2_1
value: "https://cosmian-kms:9998/kmip/2_1"
insecureAllowPlaintextTransport:
value: "false"
timeout:
value: "10s"
keys:
value: "wrapping-key-1:1"
caCert:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: ca-cert
clientCert:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: client-cert
clientKey:
valueFrom:
secretKeyRef:
name: cosmian-kms-certs
key: client-key
insecureSkipVerify:
value: "false"
Notes:
encryption.password is still required as a fallback for objects encrypted before KMS was enabledkeys format: "key1:version1,key2:version2" (comma-separated for rotation)/readyDefine bucket policies inline in values.yaml using the top-level policies
list. The chart renders a ConfigMap, mounts it, and sets POLICIES
automatically — no manual extraVolumes / extraVolumeMounts required. The same
Valkey instance also powers the ListObjects plaintext-size cache (V1.0-S3-3),
which is enabled by default whenever Valkey is configured.
config:
auth:
# Deprecated SigV2 is disabled by default. Set true only during migration.
allowLegacySignatureV2:
value: "false"
valkey:
enabled: true # auto-wires VALKEY_ADDR → <release>-valkey:6379
architecture: standalone
auth:
enabled: false # enable + use existingSecret in production
policies:
- id: encrypted-uploads
buckets:
- "my-important-bucket"
- "logs-*"
encrypt_multipart_uploads: true
require_encryption: true
- id: public-assets
buckets:
- "public-*"
require_encryption: false
Each entry maps 1:1 to PolicyConfig (internal/config/policy.go). All
fields are supported: encrypt_multipart_uploads, require_encryption,
disallow_lock_bypass, and per-bucket encryption /
rate_limit overrides.
Disable the subchart and point at your shared Valkey cluster. The policies
list works identically:
valkey:
enabled: false
config:
multipartState:
valkey:
addr:
value: "valkey-shared.mpu-state.svc.cluster.local:6379"
tls:
enabled:
value: "true"
caFile:
value: "/etc/valkey-tls/ca.crt"
insecureAllowPlaintext:
value: "false"
policies:
- id: encrypted-uploads
buckets:
- "my-important-bucket"
encrypt_multipart_uploads: true
require_encryption: true
The ListObjects size cache is enabled by default with the Valkey config above.
Override the fallback HEAD batch (e.g. to warm legacy objects) under
config.listSizeTranslate:
config:
listSizeTranslate:
enabled:
value: "true"
fallbackHeadEnabled:
value: "true" # opt-in; watch billing on per-API-call backends
fallbackHeadConcurrency:
value: "10"
fallbackHeadTimeout:
value: "5s"
Note:
policies(top-level list) andconfig.policies.value/config.policies.valueFromare mutually exclusive. Using both causes a render-time error (Guard 6). Useconfig.policiesonly when you need to reference policy files that are mounted from an external source outside the chart (e.g. from a corporate Secret store).
Restrict the gateway to a single backend bucket, minimising IAM policy requirements:
config:
proxiedBucket:
value: "my-secure-bucket"
backend:
endpoint:
value: "https://s3.amazonaws.com"
region:
value: "us-east-1"
accessKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-access-key
secretKey:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: backend-secret-key
encryption:
password:
valueFrom:
secretKeyRef:
name: s3-encryption-gateway-secrets
key: encryption-password
The gateway authenticates to the backend S3 provider using its own credentials
(config.backend.accessKey / config.backend.secretKey). Caller credentials
are validated at the gateway and are never forwarded to the backend (ADR-0012).
Each auth.credentials entry controls client access independently of the
backend identity.
For blue/green and canary deployments, configure a preStop hook to drain in-flight connections before the pod receives a SIGTERM:
terminationGracePeriodSeconds: 60
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
config:
backend:
endpoint:
valueFrom:
configMapKeyRef:
name: s3-gateway-config
key: backend-endpoint
region:
valueFrom:
configMapKeyRef:
name: s3-gateway-config
key: backend-region
rateLimit:
enabled:
value: "true"
limit:
value: "200"
window:
value: "60s"
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
labels:
prometheus: kube-prometheus
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: s3-gateway.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: s3-gateway-tls
hosts:
- s3-gateway.example.com
ingress:
traefik:
enabled: true
entryPoints: ["websecure"]
host: "s3-gateway.example.com"
tls:
certResolver: "letsencrypt"
middlewares:
- name: my-auth-middleware
namespace: default
podDisruptionBudget:
enabled: true
minAvailable: 1
# Alternative: maxUnavailable: "50%"
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: s3-encryption-gateway
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: s3-encryption-gateway
config:
tls:
enabled:
value: "true"
useCertManager:
value: "true"
certManager:
issuer:
name: s3-gateway-issuer
selfSigned: {}
# Or use Let's Encrypt:
# clusterIssuer: letsencrypt-prod
certificate:
extraDNSNames:
- s3-gateway.internal.example.com
duration: "2160h"
renewBefore: "720h"
service:
enabled: true # Keep enabled for stable DNS resolution
networkPolicy:
enabled: true
namespaceIsolation: true
namespaceLabel:
key: "kubernetes.io/metadata.name"
egress:
awsS3: false # set true if backend is AWS S3
minioInternal: true
ingress:
ingressControllers: true
ingressNamespace: "ingress-nginx"
monitoring: true
monitoringNamespace: "monitoring"
Annotate the ServiceAccount for AWS IRSA or GKE Workload Identity:
serviceAccount:
create: true
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/s3-gateway-role"
The chart ships production-safe blue/green and canary deployment recipes for zero-downtime upgrades. See docs/OPS_DEPLOYMENT.md for the complete runbook.
track value defaults to "" (no label).
Existing single-release deployments are byte-for-byte unchanged.track without a shared external Valkey
address, or enabling both ingress.enabled and ingress.traefik.enabled,
causes helm template to fail with a clear, actionable message.ingress.traefik.enabled and
ingress.traefik.weighted.enabled render Traefik IngressRoute and
TraefikService CRDs (requires Traefik ≥ v3.0).valkey.enabled: false and
configure config.multipartState.valkey.addr on all releases.# Blue side:
helm install gw-blue . \
--values examples/values-blue.yaml \
--set config.multipartState.valkey.addr.value=valkey-shared.mpu-state.svc.cluster.local:6379
# Green side (new version):
helm install gw-green . \
--set image.tag=v0.11.10 \
--values examples/values-green.yaml \
--set config.multipartState.valkey.addr.value=valkey-shared.mpu-state.svc.cluster.local:6379
# Cutover:
docs/examples/bluegreen/cutover.sh green
# Rollback:
docs/examples/bluegreen/cutover.sh blue
This chart ships a values.schema.json (JSON Schema draft-07) that validates all values client-side before the chart reaches the cluster.
Helm enforces the schema during helm lint, helm install, helm upgrade, and helm template. Errors appear with JSON-path-prefixed messages like:
at '/replicaCount': got string, want integer
at '/config/logLevel/value': Must be one of: debug, info, warn, error
at '': 'not' failed (both ingress.enabled and ingress.traefik.enabled are true)
What the schema catches early (before template rendering):
| Rule | Description |
|---|---|
| Type mismatches | replicaCount: "2" (string) is rejected; must be an integer |
| Enum violations | logLevel: verbose rejected; must be one of debug/info/warn/error |
| I1 — track + Valkey | Setting track: blue with valkey.enabled: true is rejected |
| I2 — ingress mutex | ingress.enabled: true + ingress.traefik.enabled: true is rejected |
| I3 — weighted requires Traefik | weighted.enabled: true without traefik.enabled: true is rejected |
| I5 — KeyManager provider | keyManager.enabled=true with an unknown provider is rejected |
| I7 — TLS cert required | tls.enabled=true + useCertManager=false without certFile/keyFile is rejected |
The schema is intentionally permissive at the root (
additionalProperties: true) so that overlays likevalues.fips.yamlcan add arbitrary pod annotations and extraEnv entries without triggering false positives. StrictadditionalProperties: falseis applied only at well-structured sub-trees (config.backend.*,config.encryption.*,ingress.traefik.weighted.*).
If helm lint fails with a JSON-path error, consult the description in
values.schema.json at that path — the description contains the fix.
To bypass schema validation in an emergency (template guards still fire):
helm install ... --disable-openapi-validation
helm/s3-encryption-gateway/values.schema.json — hand-written, ~1 400 lines with $defs reusehelm/s3-encryption-gateway/tests/schema/ — positive and negative test caseshelm/s3-encryption-gateway/tests/schema/run-negative.sh — local harness.github/workflows/helm-test.yml jobs: lint-overlays, schema-negative, schema-drift, render-overlaysSee docs/plans/V0.6-OPS-2-plan.md for the full design document.
helm repo update
helm upgrade my-gateway s3-encryption-gateway/s3-encryption-gateway
helm uninstall my-gateway
Set config.backend.tls.caFile.value to the path of a mounted PEM private CA
to augment system roots. Use valueFrom for secret/config-map references.
config.backend.tls.insecureSkipVerify.value defaults to "false"; enabling
it is unsafe and intended only for local diagnostics. Mount the CA with the
chart’s extraVolumes and extraVolumeMounts, then restart the gateway after
changes.
valueFrom.secretKeyRef for:
RBAC: The chart creates a ServiceAccount. Annotate it for IRSA / Workload Identity where applicable.
networkPolicy:
enabled: true
namespaceIsolation: true
When namespaceIsolation is enabled, only pods in the same namespace can access the gateway.
Single Bucket Proxy: Use proxiedBucket to restrict access to a single bucket, minimising IAM policy requirements.
TLS: Enable TLS on the gateway listener (config.tls.enabled) and use cert-manager for automatic certificate rotation.
image.tag: 0.11.10-fips and the values.fips.yaml overlay for FIPS-140-compliant deployments (AES-256-GCM only; ChaCha20-Poly1305 excluded).kubectl logs -l app.kubernetes.io/name=s3-encryption-gateway
kubectl get pods -l app.kubernetes.io/name=s3-encryption-gateway
# Via Service:
kubectl port-forward svc/s3-encryption-gateway 8080:80
curl http://localhost:8080/readyz
# Directly to pod (when Service is disabled):
kubectl port-forward <pod-name> 8080:8080
curl http://localhost:8080/readyz
The /readyz endpoint returns 200 OK when all dependencies (KMS, Valkey) are healthy, or 503 with a JSON checks map identifying the failing dependency. Aliases: /healthz, /livez, /ready, /live.
For issues, feature requests, or questions:
MIT License — see LICENSE for details.