S3 & S3-compatible¶
The S3 backend works for Amazon S3 and for any S3-compatible object store — MinIO, RustFS, Ceph RGW, Wasabi, Backblaze B2's S3 API, Cloudflare R2, SeaweedFS, and friends. It is the most common choice and the backend used by the canonical first example.
Reach for S3 when your storage is an object store with an S3 API. For Azure Blob, Google Cloud Storage, or native Backblaze B2 there are dedicated backends with nicer ergonomics (Azure, GCS, B2); for a NAS use filesystem or SFTP.
Provider prerequisites¶
- A bucket — Kopiur does not create it. Make it in the provider console / CLI first.
- An access key (an
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYpair) with read / write / list / delete on that bucket. Prefer a key scoped to the one bucket over a root/admin key. - For AWS: the bucket's region. For a compatible store: its endpoint host
(a bare
host[:port], nohttps://scheme).
Minimal AWS IAM policy for the key
kopia needs to list, read, write, and delete objects under the repository prefix. A bucket-scoped policy that grants exactly that:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": "arn:aws:s3:::my-backups"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-backups/*"
}
]
}
s3:DeleteObject is not optional — retention and
maintenance delete expired blobs. A read-write-but-no-delete
key makes snapshots succeed and maintenance fail later, which is confusing to
debug. MinIO and most compatible stores accept the same policy document.
The Secret shape¶
The mover loads this Secret with envFrom, so every key below must be a valid
environment-variable name and reaches kopia as an env var.
| Secret key | Required | What it is |
|---|---|---|
AWS_ACCESS_KEY_ID |
yes | Access-key ID for the bucket. |
AWS_SECRET_ACCESS_KEY |
yes | Secret access key paired with the ID. |
AWS_SESSION_TOKEN |
no | Only for temporary STS credentials. Omit for a static key. |
KOPIA_PASSWORD |
yes | The repository encryption password (every backend needs it). |
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
Lose the password, lose the backups
KOPIA_PASSWORD encrypts the repository; kopia cannot decrypt without it and
there is no recovery. Use a long random value, store it outside the cluster,
and back up the Secret itself. See Encryption and repository creation.
The Repository¶
---
apiVersion: v1
kind: Secret
metadata:
name: s3-repo-creds
namespace: backups
type: Opaque
stringData:
# Read by well-known names. AWS_SESSION_TOKEN is optional (STS only).
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
# The kopia repository encryption password. Lose it and the backups are
# unrecoverable — store it outside the cluster too.
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: s3-primary
namespace: backups
spec:
backend:
s3:
bucket: my-backups # the bucket holding the kopia repository
prefix: clusters/prod/ # optional; lets several repos share one bucket
region: us-east-1 # required by AWS; some compatible stores want it too
# endpoint: OMIT for AWS. For MinIO/RustFS/Ceph set the host, e.g.
# endpoint: minio.storage.svc.cluster.local:9000
# tls:
# disableTls: true # in-cluster plain HTTP (kopia assumes HTTPS otherwise)
# # or, for self-signed HTTPS, prefer a CA bundle over skipping verify:
# # caBundleRef: { configMapName: minio-ca, key: ca.crt }
# # insecureSkipVerify: true # last resort
auth:
secretRef:
name: s3-repo-creds
encryption:
passwordSecretRef:
name: s3-repo-creds
key: KOPIA_PASSWORD
create:
enabled: true # initialize the repo if it does not exist yet (default: false)
Fields reference (backend.s3)¶
| Field | Required | Default | Example | What it controls |
|---|---|---|---|---|
bucket |
yes | — | my-backups |
The bucket holding the kopia repository. Just the name — no s3://, no path. |
prefix |
no | bucket root | clusters/prod/ |
Key prefix so several repositories can share one bucket. End it with / or the prefix concatenates into object names. |
endpoint |
no | AWS | minio.storage.svc:9000 |
S3 endpoint host for a compatible store. Omit for AWS. Bare host[:port], no scheme, no trailing slash. |
region |
no | — | us-east-1 |
Region. Required by AWS and some compatible stores; any string for MinIO. |
auth.secretRef |
no¹ | — | { name: s3-repo-creds } |
Names the credential Secret above. Same namespace as the Repository; a ClusterRepository adds namespace:. Mutually exclusive with workloadIdentity. |
auth.workloadIdentity.serviceAccountName |
no¹ | — | backup-mover |
Run the mover Jobs as this (user-created, IAM-federated) ServiceAccount instead of using static keys — see Workload identity. |
tls.disableTls |
no | false |
true |
Talk plain HTTP instead of HTTPS (kopia's --disable-tls). For in-cluster MinIO on a plaintext port. |
tls.insecureSkipVerify |
no | false |
true |
Skip TLS cert verification (kopia's --disable-tls-verification). Last resort — prefer caBundleRef. |
tls.caBundleRef |
no | — | { configMapName: ca, key: ca.crt } |
A ConfigMap key holding a CA bundle (PEM) to trust a self-signed endpoint. |
¹ Set exactly one of auth.secretRef or auth.workloadIdentity
(webhook-enforced). auth itself may be omitted when the AWS_* keys ride the
encryption-password Secret.
Endpoint & region by provider¶
The same four identifiers cover every S3-compatible store; what changes is which
ones you set. ENDPOINT is always a bare host — strip the https:// the
provider's console shows you.
| Provider | endpoint |
region |
Notes |
|---|---|---|---|
| Amazon S3 | omit | the bucket's region | kopia derives the endpoint from the region. |
| MinIO / RustFS (in-cluster) | minio.storage.svc.cluster.local:9000 |
any string (main) |
Plain HTTP needs tls.disableTls: true — see below. |
| Cloudflare R2 | <accountid>.r2.cloudflarestorage.com |
auto |
The account ID is in the R2 dashboard; use an R2 API token's S3 credentials. |
| Wasabi | s3.<region>.wasabisys.com |
matches the endpoint (us-east-1) |
Region must agree with the endpoint host. |
| Backblaze B2 (S3 API) | s3.<region>.backblazeb2.com |
matches the endpoint (us-west-004) |
Or use the native B2 backend — same storage, application-key auth. |
| Ceph RGW | rgw.ceph.svc:7480 (your RGW service) |
your zonegroup (often default) |
Self-signed HTTPS → tls.caBundleRef. |
A filled-in stanza for, say, Cloudflare R2 differs from the AWS example only in these identifiers:
backend:
s3:
bucket: my-backups
endpoint: 0123456789abcdef.r2.cloudflarestorage.com
region: auto
auth:
secretRef:
name: s3-repo-creds # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from the R2 API token
Customization — the values you actually change¶
bucket/prefix— where snapshots land. Multiple repos can share a bucket via distinct prefixes.endpoint+tls— set these for any non-AWS store. See the MinIO variant below.region— set for AWS; any value for MinIO/RustFS.create.enabled—trueinitializes the repository if it doesn't exist;false(default) requires it to already exist.create.encryption/splitter/hashare fixed forever at creation — see repository creation.moverDefaults.cache— sizing for the mover's kopia cache PVC (movers).
In-cluster MinIO / RustFS over plain HTTP¶
kopia's S3 path assumes HTTPS. For a plain-HTTP in-cluster endpoint set
tls.disableTls: true and give a bare host (no scheme). For a self-signed HTTPS
endpoint, prefer pointing tls.caBundleRef at a ConfigMap holding the CA over
tls.insecureSkipVerify: true.
# Backend: S3 — in-cluster MinIO / RustFS over plain HTTP
#
# A variant of the S3 backend for an S3-compatible store reached over plain HTTP
# (e.g. a MinIO/RustFS Service inside the cluster). kopia's S3 path assumes HTTPS,
# so you must opt into plain HTTP with `tls.disableTls: true`. The endpoint is a
# bare host[:port] — NO scheme, NO trailing slash.
#
# Field shapes verified against crates/api: externally-tagged backend
# (`backend.s3`); `tls.disableTls` maps to kopia's `--disable-tls`.
---
apiVersion: v1
kind: Secret
metadata:
name: minio-repo-creds
namespace: backups
type: Opaque
stringData:
# MinIO/RustFS access key pair (the "root" user, or a scoped service account).
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: minio-primary
namespace: backups
spec:
backend:
s3:
bucket: kopia # the bucket holding the repository (create it in MinIO first)
prefix: clusters/prod/ # optional; lets several repos share one bucket
# Bare host[:port] of the in-cluster Service — no http:// scheme.
endpoint: minio.storage.svc.cluster.local:9000
region: us-east-1 # MinIO ignores it but kopia wants a value; any string works
tls:
disableTls: true # talk plain HTTP (kopia otherwise assumes HTTPS)
auth:
secretRef:
name: minio-repo-creds
encryption:
passwordSecretRef:
name: minio-repo-creds
key: KOPIA_PASSWORD
create:
enabled: true # initialize the repo if it does not exist yet
Workload identity (IRSA / EKS Pod Identity)¶
On EKS (or any cluster with AWS IAM federation), you can drop the static
AWS_* keys entirely: set auth.workloadIdentity.serviceAccountName and every
mover Job for this repository runs as that ServiceAccount, with kopia
resolving credentials through the ambient AWS chain — the IRSA web-identity
token, an EKS Pod Identity association, or instance metadata. The only secret
left in the cluster is KOPIA_PASSWORD.
What you provide:
- An IAM role with the bucket policy above.
- A ServiceAccount federated to it — IRSA's
eks.amazonaws.com/role-arnannotation, or an EKS Pod Identity association. It must exist in every namespace mover Jobs run in (the repository's own namespace, plus each consumer namespace for aClusterRepository). auth.workloadIdentity.serviceAccountNameon the backend, instead ofauth.secretRef.
What Kopiur does: it checks the ServiceAccount exists before every run (a
missing SA surfaces as CredentialsAvailable=False naming it, instead of a Job
that never schedules), binds the kopiur-mover role to it (the mover patches
its own CR status at runtime), and — since kopia's CLI insists on its
access-key flags — invokes kopia with explicitly empty --access-key= /
--secret-access-key= so the AWS credential chain takes over.
Kopiur never touches your ServiceAccount
The SA's annotations are your federation contract with AWS; Kopiur only gets
it and adds a RoleBinding (kopiur-mover-wi-<sa>). Create and manage the SA
yourself (or via your GitOps tooling).
# Backend: S3 with workload identity — IRSA / EKS Pod Identity
#
# No static AWS keys anywhere: the mover Jobs run as a ServiceAccount federated
# to an IAM role, and kopia resolves credentials through the ambient AWS chain
# (IRSA web-identity token or EKS Pod Identity). The only secret left in the
# cluster is the kopia repository encryption password.
#
# Prerequisites (AWS side):
# - an IAM role with the bucket policy (see the S3 docs page), trust-federated
# to this ServiceAccount via your cluster's OIDC provider (IRSA), OR an EKS
# Pod Identity association pointing at it.
#
# Field shapes verified against crates/api: externally-tagged backend
# (`backend.s3`); `auth.workloadIdentity` is mutually exclusive with
# `auth.secretRef` (webhook-enforced).
---
# You own this ServiceAccount — Kopiur preflights it and binds the mover role to
# it, but never creates or modifies it (the annotation is your contract with the
# cloud's identity webhook). It must exist in every namespace mover Jobs run in.
apiVersion: v1
kind: ServiceAccount
metadata:
name: backup-mover
namespace: backups
annotations:
# IRSA: the IAM role this SA may assume. For EKS Pod Identity, omit the
# annotation and create a pod-identity association instead.
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/kopia-backups
---
apiVersion: v1
kind: Secret
metadata:
name: s3-wi-repo-creds
namespace: backups
type: Opaque
stringData:
# Workload identity replaces the AWS_* keys — but the repository encryption
# password is kopia's, not the cloud's, and is still required.
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: s3-wi
namespace: backups
spec:
backend:
s3:
bucket: my-backups
region: us-east-1 # the bucket's region (kopia derives the AWS endpoint)
auth:
# Instead of secretRef: the mover Jobs run AS this ServiceAccount and
# kopia authenticates via the ambient AWS credential chain.
workloadIdentity:
serviceAccountName: backup-mover
encryption:
passwordSecretRef:
name: s3-wi-repo-creds
key: KOPIA_PASSWORD
create:
enabled: true
Replication: don't mix static and workload-identity S3 pairs
A RepositoryReplication between two S3 backends must use the same auth
style on both sides (or both workloadIdentity with the same
ServiceAccount). A mixed pair is rejected at admission: the replication pod's
environment carries the static side's AWS_* keys, and the workload-identity
side's credential chain would silently pick them up — authenticating as the
wrong identity. Cross-cloud pairs (e.g. S3 → GCS) are unaffected.
As a ClusterRepository¶
The same backend.s3 stanza works on a cluster-scoped ClusterRepository,
with two differences: every Secret reference must carry an explicit
namespace: (webhook-enforced), and the credential Secret must exist in the
namespaces the movers run in — see Movers → where the credential Secret lives.
# Backend: S3 as a shared ClusterRepository
#
# The SAME S3 backend stanza, but on a cluster-scoped ClusterRepository owned by a
# platform team and referenced by allow-listed tenant namespaces. Two things differ
# from a namespaced Repository:
# 1. EVERY Secret reference must carry an explicit `namespace:` (webhook-enforced
# — a cluster-scoped CR has no namespace of its own to default to).
# 2. `allowedNamespaces` gates which namespaces may reference it, and
# `identityDefaults` derives each consumer's kopia identity from CEL
# expressions evaluated at admission.
#
# REQUIRES the operator installed with installScope=cluster.
#
# Field shapes verified against crates/api: externally-tagged backend
# (`backend.s3`) and `allowedNamespaces` (list | selector | all).
---
apiVersion: v1
kind: Secret
metadata:
name: kopia-platform-creds
namespace: kopiur-system # lives in the platform namespace
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: ClusterRepository
metadata:
name: shared-s3 # cluster-scoped: no namespace on the CR itself
spec:
backend:
s3:
bucket: org-kopia-repo
prefix: "" # bucket root; per-tenant separation comes from identity, not prefix
region: us-east-1
auth:
secretRef:
name: kopia-platform-creds
namespace: kopiur-system # explicit ns REQUIRED for cluster scope
encryption:
passwordSecretRef:
name: kopia-platform-creds
namespace: kopiur-system # explicit ns REQUIRED for cluster scope
key: KOPIA_PASSWORD
# Tenancy gate (externally-tagged: list | selector | all).
allowedNamespaces:
list:
- billing
- payments
# CEL expressions evaluated at admission, pinned to status. The
# CEL environment is `namespace`, `policyName`, `labels`, `annotations`.
identityDefaults:
hostnameExpr: "namespace"
usernameExpr: "namespace + '-' + policyName"
# Maintenance is default-managed. Maintenance is namespaced but a
# ClusterRepository is not, so `namespace` picks where the managed Maintenance
# CR lands (defaults to the operator's namespace when omitted).
maintenance:
namespace: kopiur-system
Try it end-to-end¶
Prove this backend really takes a backup. The same example file carries a tiny
smoke-test (a throwaway PVC + a SnapshotPolicy + a Snapshot) that targets the
s3-primary repository above, so you can go from "applied" to "a snapshot in my
bucket" in one arc.
Fill in the credentials first
The smoke backup only goes green once the REPLACE_ME values in the Secret are
real S3 keys. With placeholders the Repository stalls at Failed (kopia
can't reach the bucket) and the Snapshot stays Pending.
1. Apply the bundle (namespace backups, Secret, Repository, and the
smoke-test objects):
2. Wait for the repository to be Ready — the gate everything else waits on:
$ kubectl -n backups wait --for=condition=Ready repository/s3-primary --timeout=2m
repository.kopiur.home-operations.com/s3-primary condition met
3. Take the smoke backup. The Snapshot uses generateName, so create it
(the namespace, Secret, Repository, PVC, and policy already exist and report
unchanged — the Snapshot is the one new object):
$ kubectl create -f deploy/examples/backends/s3.yaml
snapshot.kopiur.home-operations.com/smoke-now-abc12 created
4. Watch it succeed:
$ kubectl -n backups get snapshots -w
NAME PHASE ORIGIN SNAPSHOT AGE
smoke-now-abc12 Pending manual 2s
smoke-now-abc12 Running manual 7s
smoke-now-abc12 Succeeded manual k1f1ec0a8 38s
(Output illustrative.) The Snapshot has no fixed Succeeded condition; to
wait on it in a script, key on the phase:
$ kubectl -n backups wait --for=jsonpath='{.status.phase}'=Succeeded \
snapshot/smoke-now-abc12 --timeout=5m
5. Deep proof — the data really moved. status.stats shows non-zero
bytesNew/filesNew, and status.snapshot.kopiaSnapshotID is the kopia
snapshot ID in your bucket:
$ kubectl -n backups get snapshot smoke-now-abc12 -o jsonpath='{.status.stats}'
{"sizeBytes":4096,"bytesNew":1280,"filesNew":2,"filesUnchanged":0}
$ kubectl -n backups get snapshot smoke-now-abc12 -o jsonpath='{.status.snapshot.kopiaSnapshotID}'
k1f1ec0a8
(Both outputs illustrative — sizes and the ID vary.) Non-zero bytesNew is the
proof the backup uploaded real content to S3.
6. Clean up the smoke-test when you're done:
$ kubectl -n backups delete snapshot --all # finalizer also deletes the kopia snapshot
$ kubectl -n backups delete snapshotpolicy smoke
$ kubectl -n backups delete pvc smoke-data
Deleting a Snapshot deletes its snapshot
A produced Snapshot defaults to deletionPolicy: Delete, so removing the CR
runs kopia snapshot delete via a finalizer. Use Retain (or Orphan) to keep
the data — see Backups → deletionPolicy.
From here the full lifecycle is backend-independent — only the Repository
differs. Put it on a cron with a SnapshotSchedule
(Backups & schedules,
Example 01) and restore by
picking a Snapshot (Restores,
Example 03).
Troubleshooting¶
Endpoint scheme / HTTPS assumption
The endpoint is a bare host[:port] — no http:///https:// and no trailing
slash. kopia assumes HTTPS; for an HTTP-only store set tls.disableTls: true,
or you'll see TLS handshake errors against a plaintext port.
Self-signed certificates
Prefer tls.caBundleRef (a ConfigMap with the CA PEM) over
tls.insecureSkipVerify: true. Skipping verification disables a real protection;
trusting the CA keeps it.
- 403 / access denied — the key lacks list/write/delete on the bucket, or the bucket/region/endpoint don't match. Confirm the key is scoped to this bucket.
repository not initialized— setcreate.enabled: true, or the prefix points at an empty location. Watch theRepositoryPhase and Events.- Workload identity: the bootstrap/mover Job hits its deadline — the cloud webhook never injected the credential env (the SA is missing its federation annotation, the IRSA/Pod Identity association doesn't exist, or the identity webhook isn't running), so kopia's credential chain falls back to the EC2 metadata service — which hangs on non-EC2 nodes until the Job deadline. The mover pod log carries an explicit warning naming the missing env hints; fix the ServiceAccount's federation and the next run picks it up.
See also¶
- Repositories & backends — concepts: scope, encryption, creation.
- Movers, RBAC & credentials — where the credential Secret must live.
- Sibling backends: Azure · GCS · B2 · rclone.