Versioning, lifecycle and encryption at rest
Three bucket settings decide whether you can undo a mistake, what the bucket costs in a year, and who can read the bytes on disk. Versioning keeps every overwrite and delete recoverable. Lifecycle rules move and expire data on a schedule. Default encryption picks the key every new object is encrypted with. All three are set once and forgotten - until the day someone runs aws s3 rm --recursive on the wrong prefix.
Need to know: a bucket is unversioned, Enabled or Suspended - once enabled, it can never go back to unversioned. With versioning, a delete adds a delete marker (the data stays, as a noncurrent version) and an overwrite keeps the old version; you restore by removing the marker or copying an old version back. Lifecycle rules transition (STANDARD_IA after 30 days at the earliest) and expire objects - and noncurrent versions, or a versioned bucket grows forever. Every new object is encrypted, SSE-S3 by default; SSE-KMS adds a KMS key whose policy must also allow you.
Versioning
$ cd ~/oncall-lab/labs/aws
$ aws s3 mb s3://try-config-111122223333
make_bucket: try-config-111122223333
$ aws s3api get-bucket-versioning --bucket try-config-111122223333
$ aws s3api put-bucket-versioning --bucket try-config-111122223333 --versioning-configuration Status=Enabled
$ aws s3api get-bucket-versioning --bucket try-config-111122223333
{
"Status": "Enabled"
}
$ aws s3 cp try/config-v1.yaml s3://try-config-111122223333/app/config.yaml
upload: try/config-v1.yaml to s3://try-config-111122223333/app/config.yaml
$ aws s3 cp try/config-v2.yaml s3://try-config-111122223333/app/config.yaml
upload: try/config-v2.yaml to s3://try-config-111122223333/app/config.yaml
$ aws s3api list-object-versions --bucket try-config-111122223333 --prefix app/config.yaml --query 'Versions[].[VersionId,IsLatest,Size,LastModified]' --output text
iXx3aMi2m.r60IMYiCvSbgG.YaC3rxnW True 29 2026-09-22T20:00:04+00:00
Ths0w9hF0n4IjDZW3IzLFnbmJABR7t8o False 28 2026-09-22T20:00:04+00:00
Note the first get-bucket-versioning: an unversioned bucket returns nothing at all (an empty response prints nothing), not "Disabled". Every version has its own VersionId; reading without one gets the latest. Now delete it - the way aws s3 rm always deletes:
$ aws s3 rm s3://try-config-111122223333/app/config.yaml
delete: s3://try-config-111122223333/app/config.yaml
$ aws s3 ls s3://try-config-111122223333/app/
$ aws s3api get-object --bucket try-config-111122223333 --key app/config.yaml /tmp/c.yaml
aws: [ERROR]: An error occurred (NoSuchKey) when calling the GetObject operation: The specified key does not exist.
$ aws s3api list-object-versions --bucket try-config-111122223333 --prefix app/ --query '{versions: Versions[].[VersionId,IsLatest], markers: DeleteMarkers[].[VersionId,IsLatest]}'
{
"versions": [
[
"iXx3aMi2m.r60IMYiCvSbgG.YaC3rxnW",
false
],
[
"Ths0w9hF0n4IjDZW3IzLFnbmJABR7t8o",
false
]
],
"markers": [
[
".zGYGVz.M6tiG01Ey3K1i0QsBjhjB4ei",
true
]
]
}
The object "is gone" - ls shows nothing, GET says NoSuchKey - but the bytes are still there: the delete only added a delete marker as the latest version. Two ways back:
$ MARKER=$(aws s3api list-object-versions --bucket try-config-111122223333 --prefix app/config.yaml --query 'DeleteMarkers[?IsLatest].VersionId' --output text)
$ aws s3api delete-object --bucket try-config-111122223333 --key app/config.yaml --version-id $MARKER
{
"DeleteMarker": true,
"VersionId": ".zGYGVz.M6tiG01Ey3K1i0QsBjhjB4ei"
}
$ aws s3 cp s3://try-config-111122223333/app/config.yaml -
replicas: 3
log_level: debug
$ OLD=$(aws s3api list-object-versions --bucket try-config-111122223333 --prefix app/config.yaml --query 'Versions[?!IsLatest] | [0].VersionId' --output text)
$ aws s3api copy-object --bucket try-config-111122223333 --key app/config.yaml --copy-source "try-config-111122223333/app/config.yaml?versionId=$OLD" --query VersionId --output text
qMx7XMUt6iXennITdw7VrANVJEySPreS
$ aws s3 cp s3://try-config-111122223333/app/config.yaml -
replicas: 2
log_level: info
- Delete the delete marker (a
delete-objectwith its--version-id): the previous version becomes the latest again. - Copy an old version over the current one (
copy-objectfromkey?versionId=...): a new version with the old content - the history is kept, which is what you want for "roll back the config".
A delete-object with a --version-id of real data is the one delete that cannot be undone. That is why the permission is separate (s3:DeleteObjectVersion) and worth denying to everyone but a break-glass role. Suspending versioning stops creating new versions (new writes get the version ID null) but keeps all the old ones. MFA delete can require an MFA code for version deletes and versioning changes (root user only, rarely used); Object Lock makes versions undeletable for a retention period (backups, compliance).
Lifecycle rules
A lifecycle configuration is a list of rules, each with a filter (a prefix, tags, sizes) and actions:
| Action | Does |
|---|---|
Transitions | move current versions to a cheaper class after N days (STANDARD_IA / ONEZONE_IA not before 30 days, GLACIER_IR, GLACIER, DEEP_ARCHIVE) |
Expiration | delete current versions after N days (on a versioned bucket: add a delete marker) |
NoncurrentVersionTransitions / NoncurrentVersionExpiration | the same for old versions, N days after they became noncurrent (NewerNoncurrentVersions keeps the last few) |
AbortIncompleteMultipartUpload | delete the parts of uploads that never finished - invisible, but billed |
ExpiredObjectDeleteMarker | remove delete markers that have no versions left behind them |
$ cat try/lifecycle.json
{
"Rules": [
{
"ID": "logs-tiering",
"Status": "Enabled",
"Filter": {
"Prefix": "logs/"
},
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 90,
"StorageClass": "GLACIER"
}
],
"Expiration": {
"Days": 365
}
},
{
"ID": "old-versions",
"Status": "Enabled",
"Filter": {},
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30,
"NewerNoncurrentVersions": 3
}
},
{
"ID": "abort-uploads",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}
]
}
$ aws s3api put-bucket-lifecycle-configuration --bucket try-config-111122223333 --lifecycle-configuration file://try/lifecycle.json
{
"TransitionDefaultMinimumObjectSize": "all_storage_classes_128K"
}
$ aws s3api get-bucket-lifecycle-configuration --bucket try-config-111122223333 --query 'Rules[].[ID,Status]' --output text
logs-tiering Enabled
old-versions Enabled
abort-uploads Enabled
$ aws s3api put-bucket-lifecycle-configuration --bucket try-config-111122223333 --lifecycle-configuration '{"Rules":[{"ID":"too-soon","Status":"Enabled","Filter":{"Prefix":"logs/"},"Transitions":[{"Days":7,"StorageClass":"STANDARD_IA"}]}]}'
aws: [ERROR]: An error occurred (InvalidArgument) when calling the PutBucketLifecycleConfiguration operation: 'Days' in Transition action must be greater than or equal to 30 for storageClass 'STANDARD_IA'
Lifecycle runs asynchronously (S3 evaluates the rules about once a day), the whole configuration is replaced on every put (read it, edit it, put it back), and objects smaller than 128 KB are not transitioned by default (TransitionDefaultMinimumObjectSize) - moving them would cost more than it saves. The classic bill surprise: versioning on, no noncurrent-version rule, and a job that rewrites the same 10 GB every night.
Encryption at rest
Every object S3 stores is encrypted. You choose with which key:
| Option | Key | Notes |
|---|---|---|
SSE-S3 (AES256) | keys owned and managed by S3 | the default for every new object since January 2023; no extra permissions |
SSE-KMS (aws:kms) | a KMS key: the AWS managed aws/s3, or a customer managed one | access to the key is checked on every read and write; KMS calls are logged in CloudTrail and billed |
DSSE-KMS (aws:kms:dsse) | KMS, two layers of encryption | for rules that demand dual-layer encryption |
| SSE-C | a key you send with every request | S3 never stores it; lose it, lose the data. Buckets can block it (BlockedEncryptionTypes) |
| Client-side | encrypted before upload | S3 sees only ciphertext |
$ aws s3api get-bucket-encryption --bucket try-config-111122223333
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": false
}
]
}
}
$ aws s3api head-object --bucket try-config-111122223333 --key app/config.yaml --query ServerSideEncryption --output text
AES256
$ aws s3api put-bucket-encryption --bucket try-config-111122223333 --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"},"BucketKeyEnabled":true}]}'
$ aws s3 cp try/config-v2.yaml s3://try-config-111122223333/app/new.yaml
upload: try/config-v2.yaml to s3://try-config-111122223333/app/new.yaml
$ aws s3api head-object --bucket try-config-111122223333 --key app/new.yaml --query '[ServerSideEncryption,SSEKMSKeyId,BucketKeyEnabled]' --output text
aws:kms arn:aws:kms:eu-central-1:111122223333:key/29385a4d-39e5-413b-a949-5d8a63badf81 True
$ aws s3api head-object --bucket try-config-111122223333 --key app/config.yaml --query ServerSideEncryption --output text
AES256
$ aws kms list-aliases --query "Aliases[?AliasName=='alias/aws/s3'].TargetKeyId" --output text
29385a4d-39e5-413b-a949-5d8a63badf81
- Changing the default affects new objects only. The existing
config.yamlstayed on SSE-S3; to re-encrypt old objects you copy them onto themselves (copy-objectwith the new encryption, or S3 Batch Operations for millions). - With no key given, SSE-KMS uses the AWS managed key
aws/s3(created on first use). Use a customer managed key when you need to control its key policy - for example to let one specific role decrypt - which is the next lesson. - Bucket keys make S3 derive a short-lived bucket-level key from KMS instead of calling KMS for every object: far fewer KMS requests (and costs), and CloudTrail shows the bucket ARN in the encryption context instead of each object's.
- Encryption at rest protects disks and backups, not access: anyone S3 lets read an SSE-S3 object gets plaintext. SSE-KMS is different - the key's policy is a second, independent door.
In an interview: "Someone deleted objects from a bucket - can you get them back?" - "Only if versioning was on (or there is a backup or replication). With versioning, a normal delete just adds a delete marker: remove the marker, or copy the previous version back. Deletes that name a version ID are permanent, which is why s3:DeleteObjectVersion should be restricted and why Object Lock exists for backups."
You can now: turn versioning on and explain delete markers, recover a deleted or overwritten object two ways, write lifecycle rules for current and noncurrent versions (and know the 30-day and 128 KB rules), and choose between SSE-S3, SSE-KMS, DSSE-KMS and SSE-C - knowing that a new default does not touch old objects.