The previous lesson ended with a decision still pending. EFS solved the problem of Friday's four
instances sharing /var/www/fotos, but at the wrong price: some thirteen times more expensive than
the alternative, with all the image traffic going through the instances and without taking advantage
of AWS's global scale. That alternative is Amazon S3 (Simple Storage Service), and this lesson
is where MercadoFresco makes the final decision about its product photos.
S3 is probably the most important service in AWS. It was the first one launched, back in 2006, and today it is where the data of almost any architecture ends up: static files, backups, logs, data lakes, deployment artefacts. Understanding it properly — not just "it is where you upload files" — changes the way you design systems, because S3 is not a disk: it is an object store with an API, and that difference has consequences everywhere.
Contents
- Object storage: bucket, object, key and prefix
- Why S3 is not a file system
- Bucket names, regions and the global namespace
- Durability and availability: the eleven nines
- Storage classes and how to choose
- Lifecycle rules for the old catalogue photos
- Versioning and protection against accidental deletion
- Migrating
/var/www/fotoswithaws s3 sync aws s3versusaws s3api- Permissions: Block Public Access, bucket policies and ACLs
- Pre-signed URLs: Sara's reports without credentials
- Encryption at rest and in transit
- Hosting a static site
- S3 events: the thumbnail trigger
- Multipart upload and Transfer Acceleration
- S3 costs and how to avoid surprises
Object storage: bucket, object, key and prefix
The S3 model has only three pieces, and it is worth naming them precisely because that is how all the documentation uses them.
| Concept | What it is | Example in MercadoFresco |
|---|---|---|
| Bucket | The container. It lives in a region and its name is unique worldwide | mercadofresco-catalogo-fotos |
| Object | The stored piece of data: content + metadata + identifier | The photo naranjas-valencia-1kg.jpg |
| Key | The object's full name inside the bucket | productos/frutas/naranjas-valencia-1kg.jpg |
| Prefix | The initial part of the key, used to filter and organise | productos/frutas/ |
| Metadata | Key/value pairs: content type, encryption, tags | Content-Type: image/jpeg |
An object can weigh anything from 0 bytes to 5 TiB. A bucket can hold an unlimited number of objects and grow without anyone provisioning anything.
Why S3 is not a file system
This is the idea you have to internalise, because almost every surprise that catches beginners out follows from it.
There are no folders in S3. The console draws them, aws s3 ls lists them, but they are not
there. All that exists is a flat list of keys, and the slash / is just one more character in the
name.
What you see in the console: What is really there:
productos/ productos/frutas/naranjas.jpg
├── frutas/ productos/frutas/manzanas.jpg
│ ├── naranjas.jpg productos/verduras/tomates.jpg
│ └── manzanas.jpg informes/2026-07-ventas.csv
└── verduras/
└── tomates.jpg
informes/
└── 2026-07-ventas.csvThe practical consequences of this are concrete:
| In a file system | In S3 |
|---|---|
| Renaming a folder is instant | You have to copy and delete every object: with 40,000 photos, 80,000 operations |
| You can modify one byte in the middle of a file | Objects are immutable: the whole object is replaced |
| Empty folders exist | An "empty folder" does not exist (other than a 0-byte object ending in /) |
mv moves |
There is no real mv: it is copy + delete |
An open() returns a descriptor and you read in chunks |
Every read is an HTTPS request (though byte ranges are supported) |
| Latency is measured in microseconds | Tens of milliseconds per request |
And one enormous benefit: because there is no real hierarchical structure, there is no limit to scale. A bucket with a billion objects works just as well as one with ten, and S3 supports at least 3,500 writes and 5,500 reads per second per prefix, with practically unlimited parallelisation across several prefixes.
That is why key design matters. For MercadoFresco:
productos/frutas/naranjas-valencia-1kg.jpg # original productos/frutas/naranjas-valencia-1kg_thumb.jpg # thumbnail (Lambda will generate it, 02-05) informes/2026/07/ventas-julio.csv # Sara's reports, partitioned by date copias/base-datos/2026-08-02-pedidos.dump # backups
Bucket names, regions and the global namespace
A bucket name is unique across the whole of AWS, in every account and every region in the world.
If somebody in Australia has fotos, you cannot have it. It is the only AWS resource with a truly
global namespace.
Naming rules:
- Between 3 and 63 characters.
- Lower case letters, numbers, hyphens and dots only.
- It must start and end with a letter or a number.
- It cannot look like an IP address.
- Avoid dots: they break TLS certificate validation with virtual-hosted-style names and cause errors that are hard to diagnose.
MercadoFresco's convention, which we will apply throughout:
mercadofresco-<componente>-<proposito>[-<entorno>] mercadofresco-catalogo-fotos mercadofresco-informes-analitica mercadofresco-copias-basedatos mercadofresco-tienda-web-desarrollo
Even though the name is global, the data reside in one specific region and never leave it unless
you explicitly ask them to. For MercadoFresco this is not a minor detail: the photos and the reports
stay in eu-west-1, inside the EU, which is what sustains the GDPR argument we went through in
lesson 01-03.
aws s3api create-bucket \
--bucket mercadofresco-catalogo-fotos \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1 \
--profile mercadofresco-dev
# Mandatory project tagging
aws s3api put-bucket-tagging \
--bucket mercadofresco-catalogo-fotos \
--tagging 'TagSet=[
{Key=Proyecto,Value=mercadofresco},
{Key=Entorno,Value=produccion},
{Key=Componente,Value=catalogo},
{Key=Propietario,Value=luis},
{Key=CentroCoste,Value=marketing}]' \
--profile mercadofresco-dev --region eu-west-1The
LocationConstrainttrap. Inus-east-1(and only there) that parameter must not be passed: the command fails if you include it. In any other region it is mandatory. It is a historical legacy of S3 having been born in Virginia.
Durability and availability: the eleven nines
S3 Standard promises 99.999999999 % annual durability: the famous eleven nines. The figure sounds like marketing, but it has a precise operational meaning.
If you store 10 million objects, the statistical expectation is to lose one every 10,000 years. Put another way: it is far more likely that you lose the data through an accidental deletion or a badly written policy than through an S3 failure.
How it is achieved: every object is automatically replicated across at least three Availability Zones in the region, with continuous integrity checking and automatic repair. You configure nothing.
Two different metrics must not be confused:
| Metric | What it measures | S3 Standard | Consequence if it fails |
|---|---|---|---|
| Durability | That the data are not lost | 99.999999999 % | Permanent loss |
| Availability | That you can get to them right now | 99.99 % (≈53 min/year) | Temporary errors; retry |
One nuance that matters a great deal for MercadoFresco: S3's durability does not protect you from
yourself. If Luis runs an aws s3 rm --recursive on the wrong bucket, S3 will delete it with
eleven nines of reliability. The protection against that is versioning, which we cover below.
Storage classes and how to choose
Not all data are accessed in the same way. The photos of seasonal tomatoes are requested a thousand times a day in June and not once in January. S3 offers classes with different trade-offs between storage cost and access cost/latency.
| Class | Cost GB-month (eu-west-1, approx.) |
Retrieval latency | Retrieval cost | AZ | Minimum duration | Use case |
|---|---|---|---|---|---|---|
| Standard | 0.023 USD | Milliseconds | No | ≥3 | None | Active data: current catalogue photos |
| Intelligent-Tiering | 0.023 → 0.0025 USD | Milliseconds | No (small monitoring fee) | ≥3 | None | Unknown or changing access pattern |
| Standard-IA | 0.0125 USD | Milliseconds | Yes, per GB | ≥3 | 30 days | Monthly access, needed fast |
| One Zone-IA | 0.01 USD | Milliseconds | Yes, per GB | 1 | 30 days | Reproducible copies, regenerable thumbnails |
| Glacier Instant Retrieval | 0.004 USD | Milliseconds | Yes, higher | ≥3 | 90 days | Archive consulted a couple of times a year |
| Glacier Flexible Retrieval | 0.0036 USD | Minutes to 12 hours | Yes | ≥3 | 90 days | Compliance backups |
| Glacier Deep Archive | 0.00099 USD | 12 to 48 hours | Yes, the highest | ≥3 | 180 days | 7 or 10 year legal retention |
Between Standard and Deep Archive there is a factor of 23 times in storage cost. That is the prize for classifying your data well.
Three decision criteria:
- If you do not know the access pattern, use Intelligent-Tiering. It moves objects between tiers automatically, with no retrieval cost, charging a small monitoring fee per object. It is the safe default for heterogeneous data.
- Watch out for the minimum duration. Upload an object to Standard-IA, delete it after 10 days and you are charged for 30. For short-lived data this class works out dearer than Standard.
- One Zone-IA only for reproducible data. It lives in a single AZ: if that AZ is lost, the data are lost. It is perfect for MercadoFresco's thumbnails, because they can always be regenerated from the original.
Lifecycle rules for the old catalogue photos
A lifecycle rule applies transitions and expirations automatically according to the age of the objects. It is how the saving described above happens without anyone intervening.
MercadoFresco's analysis: product photos are consulted heavily for the first few months after
publication, then interest drops off, and those of discontinued products are only needed for
accounting reasons. Translated into policy, in ciclo-vida-fotos.json:
{
"Rules": [
{
"ID": "fotos-productos-enfriamiento",
"Filter": {"Prefix": "productos/"},
"Status": "Enabled",
"Transitions": [
{"Days": 90, "StorageClass": "STANDARD_IA"},
{"Days": 365, "StorageClass": "GLACIER_IR"},
{"Days": 1095,"StorageClass": "DEEP_ARCHIVE"}
]
},
{
"ID": "miniaturas-una-sola-az",
"Filter": {"Prefix": "miniaturas/"},
"Status": "Enabled",
"Transitions": [
{"Days": 30, "StorageClass": "ONEZONE_IA"}
]
},
{
"ID": "informes-analitica-caducan-a-2-anos",
"Filter": {"Prefix": "informes/"},
"Status": "Enabled",
"Transitions": [
{"Days": 60, "StorageClass": "STANDARD_IA"}
],
"Expiration": {"Days": 730}
},
{
"ID": "limpiar-cargas-multiparte-incompletas",
"Filter": {},
"Status": "Enabled",
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
},
{
"ID": "versiones-antiguas-caducan-a-90-dias",
"Filter": {},
"Status": "Enabled",
"NoncurrentVersionExpiration": {"NoncurrentDays": 90}
}
]
}aws s3api put-bucket-lifecycle-configuration \
--bucket mercadofresco-catalogo-fotos \
--lifecycle-configuration file://ciclo-vida-fotos.json \
--profile mercadofresco-dev --region eu-west-1Rule by rule:
fotos-productos-enfriamiento: a three-step staircase. After 3 months it drops to IA, after a year to Glacier Instant (which still returns in milliseconds), after 3 years to Deep Archive.miniaturas-una-sola-az: exploits the fact that they are regenerable. Saving with no real risk.informes-analitica-caducan-a-2-anos: as well as moving, it deletes. Sara's reports have no value after two years, and deleting is the ultimate saving.limpiar-cargas-multiparte-incompletas: the most forgotten one and the one that quietly saves the most money. An interrupted large upload leaves parts behind that are billed and do not show up when you list the bucket. This rule should be in absolutely every bucket you own.versiones-antiguas-caducan-a-90-dias: without this one, versioning makes the bucket grow forever.
Estimated saving for the 40,000 photos of 500 KB each (some 20 GB) with a typical distribution of ages:
| Scenario | Monthly cost |
|---|---|
| Everything in Standard | 20 GB × 0.023 = 0.46 USD |
| With lifecycle (5 GB Standard, 8 GB IA, 7 GB Glacier IR) | 0.115 + 0.10 + 0.028 = 0.24 USD |
At 20 GB the difference is small change. At the 20 TB MercadoFresco will have after opening in three more cities (problem 3), it is more than 250 USD a month of difference. The rules go in now, when they cost a minute, not when they hurt.
Versioning and protection against accidental deletion
With versioning enabled, S3 never really overwrites or deletes:
- When you upload an object with an existing key, a new version is created and the previous one is kept.
- When you delete, a delete marker is placed: the object disappears from listings but all its versions are still there.
- To recover it, all you have to do is delete the delete marker.
aws s3api put-bucket-versioning \
--bucket mercadofresco-catalogo-fotos \
--versioning-configuration Status=Enabled \
--profile mercadofresco-dev --region eu-west-1Recovering an accidental deletion, the real-world scenario:
# 1. Luis deletes a photo by mistake
aws s3 rm s3://mercadofresco-catalogo-fotos/productos/frutas/naranjas.jpg \
--profile mercadofresco-dev
# 2. It no longer shows up in the listing... but it still exists
aws s3api list-object-versions \
--bucket mercadofresco-catalogo-fotos \
--prefix productos/frutas/naranjas.jpg \
--query '{Versions:Versions[].[VersionId,LastModified],Markers:DeleteMarkers[].[VersionId]}' \
--profile mercadofresco-dev --region eu-west-1
# 3. Delete the DELETE MARKER (not the version) and the photo comes back
aws s3api delete-object \
--bucket mercadofresco-catalogo-fotos \
--key productos/frutas/naranjas.jpg \
--version-id "3HL4kqCxf3vjVBH40Nrjfkd" \
--profile mercadofresco-dev --region eu-west-1Once enabled, versioning cannot be turned off, only suspended (the versions already created are
kept). And mind the cost: every version is billed separately, hence the
NoncurrentVersionExpiration lifecycle rule in the previous section.
Two additional protections, mentioned briefly because they are for specific cases:
- MFA Delete: requires a multi-factor authentication code from the root user in order to delete versions or change the versioning state. Very strong, but it can only be enabled with root credentials and cannot be managed from the console, which makes it awkward day to day.
- Object Lock (WORM: write once, read many): prevents an object being deleted or modified for a fixed period, even by the account administrator. It has two modes, Governance (a role with a special permission can bypass it) and Compliance (nobody can, not even root). It is for legal retention. It can only be enabled when the bucket is created. For MercadoFresco's database backups it would make sense; for the photos, it would not.
Migrating /var/www/fotos with aws s3 sync
The moment has come. The 40,000 photos on the office server are going to S3.
# Dry run: shows what it WOULD do without doing anything. Always do this first.
aws s3 sync /var/www/fotos s3://mercadofresco-catalogo-fotos/productos/ \
--dryrun \
--profile mercadofresco-dev
# The real migration
aws s3 sync /var/www/fotos s3://mercadofresco-catalogo-fotos/productos/ \
--storage-class STANDARD \
--exclude "*" \
--include "*.jpg" --include "*.jpeg" --include "*.png" --include "*.webp" \
--metadata-directive REPLACE \
--cache-control "public, max-age=86400" \
--profile mercadofresco-devWhat each option does:
syncis incremental: it compares size and modification date, and only uploads what has changed. It can be interrupted and relaunched without duplicating work, which is exactly what you need to migrate 40,000 files over an office connection.--dryrun: essential before anysyncto a new destination.--exclude "*"followed by several--include: order matters. First everything is excluded and then the wanted extensions are let back in. That way no.DS_Store,Thumbs.dbor.bakcopies sneak through.--cache-control: it is stored as metadata and will be honoured by browsers and, later on, by CloudFront (03-04). One day of cache for photos that hardly ever change.
Verification after the migration:
# Number of files at source
find /var/www/fotos -type f \( -name "*.jpg" -o -name "*.png" -o -name "*.webp" \) | wc -l
# Number of objects and total size at destination
aws s3 ls s3://mercadofresco-catalogo-fotos/productos/ --recursive --summarize \
--human-readable --profile mercadofresco-dev | tail -3For very large volumes there are specific options worth knowing about even if we do not use them:
AWS DataSync for massive, recurring transfers over the network, and the AWS Snowball family
for when moving the data over the internet would take longer than shipping them physically on a
device. For MercadoFresco's 20 GB, sync is more than enough.
aws s3 versus aws s3api
The CLI offers two different interfaces for S3, and confusing them causes a lot of frustration.
aws s3 |
aws s3api |
|
|---|---|---|
| What it is | High-level, Unix-style commands | 1:1 correspondence with the API operations |
| Commands | ls, cp, mv, rm, sync, mb, rb, presign |
put-object, get-object, list-object-versions, put-bucket-policy… |
| Automatic multipart | Yes, for large files | No, you have to orchestrate it by hand |
| Recursion | --recursive |
You have to paginate |
| Fine control | Limited | Total: any API parameter |
| When to use it | Day to day: copying, syncing, listing | Configuring the bucket, versions, policies, ACLs |
A practical rule: move data with aws s3, configure with aws s3api.
# High level: convenient and sufficient 90 % of the time
aws s3 cp informe.csv s3://mercadofresco-informes-analitica/informes/2026/07/ \
--profile mercadofresco-dev
aws s3 ls s3://mercadofresco-catalogo-fotos/productos/frutas/ --profile mercadofresco-dev
# Low level: when you need a parameter the high level does not expose
aws s3api put-object \
--bucket mercadofresco-informes-analitica \
--key informes/2026/07/ventas.csv \
--body informe.csv \
--server-side-encryption aws:kms \
--content-type text/csv \
--tagging "Proyecto=mercadofresco&Componente=analitica&Propietario=sara" \
--profile mercadofresco-dev --region eu-west-1Permissions: Block Public Access, bucket policies and ACLs
Data leaks caused by badly configured S3 buckets have been front-page news many times. AWS responded by changing the defaults: today every new bucket is born completely private and with Block Public Access enabled at both account and bucket level.
# Check the state (all four options should come back true)
aws s3api get-public-access-block \
--bucket mercadofresco-catalogo-fotos \
--profile mercadofresco-dev --region eu-west-1{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}The four switches, which a lot of people turn on or off without knowing what each one does:
| Option | What it prevents |
|---|---|
BlockPublicAcls |
New public ACLs from being created |
IgnorePublicAcls |
Existing public ACLs from taking effect |
BlockPublicPolicy |
A bucket policy granting public access from being added |
RestrictPublicBuckets |
An already existing public policy from working for anonymous users |
MercadoFresco's stance is to leave Block Public Access enabled on every bucket, without exception. The photos will be served publicly through CloudFront with an origin access control, not by opening the bucket. That is set up in lesson 03-04.
Bucket policies
A bucket policy is an IAM JSON document attached to the bucket saying who can do what. A real MercadoFresco example: allow only the storefront instances' role to read the photos, and force all traffic to use HTTPS.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PermitirLecturaAlRolDeLaTienda",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/rol-mercadofresco-tienda"
},
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::mercadofresco-catalogo-fotos",
"arn:aws:s3:::mercadofresco-catalogo-fotos/*"
]
},
{
"Sid": "DenegarTodoLoQueNoSeaHTTPS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::mercadofresco-catalogo-fotos",
"arn:aws:s3:::mercadofresco-catalogo-fotos/*"
],
"Condition": {
"Bool": {"aws:SecureTransport": "false"}
}
}
]
}aws s3api put-bucket-policy \
--bucket mercadofresco-catalogo-fotos \
--policy file://politica-bucket-fotos.json \
--profile mercadofresco-dev --region eu-west-1Two fundamental details:
s3:ListBucketis granted on the bucket (arn:aws:s3:::bucket), whereass3:GetObjectis granted on the objects (arn:aws:s3:::bucket/*). They are different ARNs and confusing them is the most frequent policy mistake of all.- An explicit
Denyalways wins over anyAllow, wherever it comes from. That is why the second statement is a solid defence: nobody, not even the administrator, can get in over unencrypted HTTP. The full permission evaluation logic is lesson 04-01.
ACLs: legacy
ACLs (Access Control Lists) are the original mechanism from 2006, predating IAM. They grant permissions object by object to predefined accounts or groups.
AWS recommends not using them. Since 2023, new buckets are born with Object Ownership set to
"Bucket owner enforced", which disables them entirely. You only need to know about them for two
things: recognising them when you inherit an old account, and knowing that if a tutorial tells you
--acl public-read, that tutorial is out of date. Use bucket policies.
Pre-signed URLs: Sara's reports without credentials
Sara, the business analyst, needs to download the monthly sales report. She has no AWS user, she should not have one, and the bucket is private. The solution is a pre-signed URL: a temporary link that carries a cryptographic signature and expires.
"""
Generates a temporary download URL for a MercadoFresco report.
Run it with the mercadofresco-dev profile configured (lesson 01-05).
"""
import boto3
from botocore.exceptions import ClientError
BUCKET = "mercadofresco-informes-analitica"
REGION = "eu-west-1"
def download_url(key: str, seconds: int = 3600) -> str | None:
"""Returns a pre-signed download URL, or None if something goes wrong.
:param key: object key, e.g. 'informes/2026/07/ventas-julio.csv'
:param seconds: how long the link stays valid. Maximum 7 days with user credentials.
"""
# Signature v4 requires the client to know the bucket's region.
s3 = boto3.session.Session(profile_name="mercadofresco-dev").client(
"s3", region_name=REGION
)
try:
return s3.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": BUCKET, "Key": key},
ExpiresIn=seconds,
)
except ClientError as e:
print(f"Could not generate the URL: {e}")
return None
def upload_url(key: str, seconds: int = 900) -> dict | None:
"""Pre-signed UPLOAD URL, with conditions limiting what can be uploaded.
Used so an external supplier can upload product photos without holding
AWS credentials or access to the rest of the bucket.
"""
s3 = boto3.session.Session(profile_name="mercadofresco-dev").client(
"s3", region_name=REGION
)
try:
return s3.generate_presigned_post(
Bucket="mercadofresco-catalogo-fotos",
Key=key,
Fields={"Content-Type": "image/jpeg"},
Conditions=[
{"Content-Type": "image/jpeg"}, # JPEG only
["content-length-range", 1024, 5242880], # between 1 KB and 5 MB
],
ExpiresIn=seconds,
)
except ClientError as e:
print(f"Could not generate the upload URL: {e}")
return None
if __name__ == "__main__":
link = download_url("informes/2026/07/ventas-julio.csv", seconds=3600)
print(f"Link for Sara (valid for 1 hour):\n{link}")The essentials of this mechanism:
- Whoever holds the link can use it. There is no additional authentication: the link is the credential. That is why the windows must be short.
- The URL inherits the permissions of whoever signs it. If the signing profile cannot read the object, neither will the URL.
- Maximum expiry: 7 days with IAM user credentials; 1 hour if it is signed from a role with temporary credentials, as would be the case inside a Lambda.
generate_presigned_postwithConditionsis the correct way to accept third-party uploads: it limits type and size before the file arrives.
Encryption at rest and in transit
Every new object in S3 has been encrypted at rest automatically since January 2023. There is nothing to do to have encryption; the only decision is who manages the key.
| Mode | Who manages the key | Extra cost | Auditing of key usage | When |
|---|---|---|---|---|
SSE-S3 (AES256) |
AWS, transparently | None | No | The default; enough almost always |
SSE-KMS (aws:kms) |
You, in AWS KMS | Yes, per key and per request | Yes, in CloudTrail | Regulated data, separation of duties |
| SSE-C | You, sending the key with every request | None on the AWS side | No | Very specific requirements |
| DSSE-KMS | Double encryption with KMS | Higher | Yes | Government requirements |
MercadoFresco's decision: SSE-S3 for the photos (in practice they are public data, so KMS's per-request cost is not worth it) and SSE-KMS for the sales reports and the database backups, which hold business data and where a record of who decrypts is valuable.
# Force SSE-KMS by default on the reports bucket
aws s3api put-bucket-encryption \
--bucket mercadofresco-informes-analitica \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/mercadofresco-datos"
},
"BucketKeyEnabled": true
}]
}' \
--profile mercadofresco-dev --region eu-west-1BucketKeyEnabled: true cuts calls to KMS, and their associated cost, by up to 99 %; turn it on
whenever you use SSE-KMS. Key management is covered in depth in lesson 04-02.
In transit, all traffic with S3 goes over HTTPS. To guarantee that nobody uses plain HTTP there
is the DenegarTodoLoQueNoSeaHTTPS statement in the bucket policy above.
Hosting a static site
S3 can serve a static website directly: HTML, CSS, JavaScript and images, without any server whatsoever.
aws s3 website s3://mercadofresco-tienda-web-desarrollo/ \
--index-document index.html \
--error-document error.html \
--profile mercadofresco-devThe result is a URL of the form
http://mercadofresco-tienda-web-desarrollo.s3-website-eu-west-1.amazonaws.com.
Limitations you need to know before getting excited:
- HTTP only, no HTTPS. The static website endpoint does not support TLS.
- No custom domain with a certificate.
- It requires a public bucket, which clashes with the Block Public Access policy.
That is why it is not used this way in production. The correct architecture is a private bucket + CloudFront in front with Origin Access Control: it gives you HTTPS, a custom domain, caching in more than 400 edge locations and protection. That is exactly lesson 03-04. S3's website mode is for Luis's quick tests.
S3 events: the thumbnail trigger
S3 can notify when something happens in a bucket. It is the gateway to event-driven architectures, and for MercadoFresco it solves a concrete problem: the original photos are too heavy for the product listing and thumbnails are needed.
Available events and possible destinations:
| Event type | When it fires |
|---|---|
s3:ObjectCreated:* |
Any creation (Put, Post, Copy, end of multipart) |
s3:ObjectRemoved:* |
Deletion or delete marker |
s3:ObjectRestore:* |
Restore from Glacier completed |
s3:LifecycleTransition |
An object has changed class because of a lifecycle rule |
s3:ReducedRedundancyLostObject |
Loss of an object (obsolete class) |
| Destination | When to choose it |
|---|---|
| AWS Lambda | Process the object with your own code: our case |
| Amazon SQS | Queue for batch processing or with retries (module 7) |
| Amazon SNS | Notify several subscribers at once (module 7) |
| EventBridge | Advanced routing with rules and filters (module 7) |
The flow we will build:
flowchart LR
A["Luis uploads<br/>naranjas.jpg"] --> B["Bucket<br/>mercadofresco-catalogo-fotos<br/>prefix productos/"]
B -->|"s3:ObjectCreated:*"| C["Event notification"]
C --> D["Lambda function<br/>mercadofresco-generar-miniaturas"]
D --> E["Writes to<br/>prefix miniaturas/<br/>200x200 px"]
E -.->|"lifecycle rule"| F["ONEZONE_IA after 30 days"]
Configuring the notification (the function is written in 02-05):
aws s3api put-bucket-notification-configuration \
--bucket mercadofresco-catalogo-fotos \
--notification-configuration '{
"LambdaFunctionConfigurations": [{
"Id": "generar-miniaturas-al-subir",
"LambdaFunctionArn": "arn:aws:lambda:eu-west-1:111122223333:function:mercadofresco-generar-miniaturas",
"Events": ["s3:ObjectCreated:*"],
"Filter": {"Key": {"FilterRules": [
{"Name": "prefix", "Value": "productos/"},
{"Name": "suffix", "Value": ".jpg"}
]}}
}]
}' \
--profile mercadofresco-dev --region eu-west-1The filter by prefix and suffix is essential. Without it, the Lambda would also fire when writing the thumbnails into the same bucket, which would in turn generate another event: an infinite recursive loop that is one of the fastest and most expensive ways to go wrong in AWS.
Two warnings about the semantics of these events: delivery is at least once (the function may receive the same event twice, so it must be idempotent) and ordering is not guaranteed. Here we only set up the trigger; the code is written in lesson 02-05.
Multipart upload and Transfer Acceleration
Multipart upload slices a large object into pieces and uploads the parts in parallel. It is mandatory above 5 GB and advisable from 100 MB upwards.
Advantages: better throughput through parallelism, retry of a single part if the network fails, and
the ability to pause and resume. aws s3 cp and aws s3 sync do it automatically; you only
need s3api if you program the process by hand.
# Tune the CLI thresholds for a fast connection
aws configure set default.s3.multipart_threshold 64MB
aws configure set default.s3.multipart_chunksize 16MB
aws configure set default.s3.max_concurrent_requests 20The risk: if a multipart upload is interrupted, the parts already uploaded stay behind and are
billed, and they do not show up in a normal aws s3 ls. That is why we included the
AbortIncompleteMultipartUpload rule in the lifecycle. To see them:
aws s3api list-multipart-uploads --bucket mercadofresco-catalogo-fotos \
--profile mercadofresco-dev --region eu-west-1Transfer Acceleration routes uploads through the CloudFront edge location nearest the client, and from there over the AWS backbone to the bucket. It speeds things up significantly when whoever is uploading is far from the region. It has an extra cost (≈0.04 USD/GB) and for MercadoFresco, which uploads from Spain to Ireland, it is not worth it. It would be useful if a supplier in South America were uploading photo catalogues.
# Check whether it would pay off, with the AWS comparison tool:
# https://s3-accelerate-speedtest.s3-accelerate.amazonaws.com/en/accelerate-speed-comparsion.html
aws s3api put-bucket-accelerate-configuration \
--bucket mercadofresco-catalogo-fotos --accelerate-configuration Status=Enabled \
--profile mercadofresco-dev --region eu-west-1S3 costs and how to avoid surprises
The S3 bill has four components, and almost everybody only looks at the first one.
| Component | Indicative price (eu-west-1) |
Comment |
|---|---|---|
| Storage | 0.023 USD/GB-month (Standard) | The most visible and often the smallest |
| Requests | PUT: 0.0054 USD per 1,000 GET: 0.00043 USD per 1,000 |
Dominates the cost with many small objects |
| Outbound transfer | 0.09 USD/GB to the internet | Usually the largest. Inbound free; out to another region also chargeable |
| Retrieval and management | Varies by class | Retrieving from Glacier, Intelligent-Tiering monitoring, inventory |
A realistic calculation for MercadoFresco: 20 GB of photos, 500,000 visits a month with 10 photos viewed per visit (5,000,000 GET requests), average photo 200 KB:
Storage: 20 GB × 0.023 = 0.46 USD
GET requests: 5,000,000 / 1,000 × 0.00043 = 2.15 USD
Transfer: 5,000,000 × 200 KB = 1,000 GB
1,000 GB × 0.09 = 90.00 USD
------------------------------------------------------------------
Total ≈ 92.61 USD/month97 % of the cost is outbound transfer. And that figure alone justifies lesson 03-04: by putting CloudFront in front, most requests are served from the edge cache, transfer from S3 to CloudFront is free, and the price per GB to the internet is lower. The bill can drop to less than a third, and the site will be faster into the bargain.
Tools for keeping it under control:
# Size and number of objects per bucket
aws s3 ls s3://mercadofresco-catalogo-fotos --recursive --summarize --human-readable \
--profile mercadofresco-dev | tail -3- S3 Storage Lens offers a free dashboard with usage metrics and saving recommendations.
- S3 Inventory generates a periodic report in CSV or Parquet with every object, its class and its size: the right way to audit buckets with millions of objects without listing them one by one.
- The
presupuesto-mensual-mercadofrescobudget from lesson 01-02 is still the safety net that catches anything the other two miss.
Cleaning up after the lesson. If you have created test buckets, delete them. A bucket with versioning also requires deleting the versions and the markers:
aws s3 rm s3://mi-bucket-de-pruebas --recursive --profile mercadofresco-dev aws s3api delete-objects --bucket mi-bucket-de-pruebas \\ --delete "$(aws s3api list-object-versions --bucket mi-bucket-de-pruebas \\ --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' --output json)" \\ --profile mercadofresco-dev aws s3 rb s3://mi-bucket-de-pruebas --profile mercadofresco-dev
Common Mistakes and Tips
- Treating S3 like a disk. Renaming a prefix with 40,000 objects means 80,000 operations and minutes of waiting. Design your keys well from the outset.
- Making a bucket public "to get it working quickly". It is the origin of most published leaks. Leave Block Public Access enabled and use CloudFront with OAC.
- Forgetting
LocationConstraintoutsideus-east-1, or including it insideus-east-1. Both fail, with unhelpful messages. - Not configuring
AbortIncompleteMultipartUpload. An invisible cost that grows forever. - Enabling versioning and not expiring the old versions. The bucket swells without limit and nobody understands why the bill goes up when "nothing new is being uploaded".
- Confusing the bucket ARN with the object ARN in policies:
ListBucketgoes on the bucket,GetObjectonbucket/*. - Putting a Lambda to listen to events from the same bucket it writes to. Infinite loop. Always filter by prefix, or use different buckets.
- Using
--acl public-readbecause an old tutorial says so. ACLs are disabled by default on new buckets and AWS advises against using them. - Uploading short-lived data to Standard-IA. The 30-day billing minimum makes it work out dearer than Standard.
- Looking only at the storage cost. Outbound transfer is usually 90 % of what you end up paying for S3.
- Tip: run
syncwith--dryrunevery first time. A mistyped destination can copy 40,000 files to the wrong prefix, and undoing it is more work than doing it.
Exercises
Exercise 1: designing the lifecycle for the database backups
MercadoFresco will keep the daily dumps of the orders database in mercadofresco-copias-basedatos,
under the prefix copias/base-datos/. Each dump takes up 2 GB. The requirements are:
- The last 30 days must be restorable in minutes.
- Between 30 days and 1 year, a restore may take hours.
- Between 1 and 7 years (legal tax retention) they are kept at the lowest possible cost.
- After 7 years they are deleted automatically.
Write the lifecycle rule in JSON and work out the monthly cost in steady state, comparing it with leaving everything in Standard. Use: Standard 0.023; Glacier Flexible 0.0036; Deep Archive 0.00099 USD/GB-month.
Exercise 2: the bucket policy for the reports
Sara must be able to read the reports under informes/, but not delete them or touch other
prefixes. The role rol-mercadofresco-analitica must be able to write in informes/. Nobody
should get in without HTTPS or upload objects that are not encrypted with KMS.
Write the complete bucket policy and explain why the last condition needs a Deny and not an
Allow.
Exercise 3: deciding where each piece of data goes
For each MercadoFresco piece of data, state the bucket, prefix, initial storage class, whether you enable versioning and which lifecycle rule you would apply:
- A) The 40,000 original catalogue photos, in high resolution.
- B) The 200×200 thumbnails generated automatically from the ones above.
- C) The web server's access logs, about 3 GB a day, only consulted if there is an incident and mandatory for 90 days.
- D) The monthly sales report Sara downloads through a pre-signed URL.
Solutions
Solution 1.
{
"Rules": [
{
"ID": "copias-base-datos-retencion-fiscal",
"Filter": {"Prefix": "copias/base-datos/"},
"Status": "Enabled",
"Transitions": [
{"Days": 30, "StorageClass": "GLACIER"},
{"Days": 365, "StorageClass": "DEEP_ARCHIVE"}
],
"Expiration": {"Days": 2555}
},
{
"ID": "limpiar-multipart",
"Filter": {},
"Status": "Enabled",
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
}
]
}Volume in steady state (2 GB/day):
Standard (0-30 days): 30 × 2 GB = 60 GB Glacier Flexible (30-365): 335 × 2 GB = 670 GB Deep Archive (365-2555): 2190 × 2 GB = 4,380 GB Total = 5,110 GB
| Configuration | Calculation | Monthly cost |
|---|---|---|
| With lifecycle | 60×0.023 + 670×0.0036 + 4,380×0.00099 | 8.13 USD |
| Everything in Standard | 5,110 × 0.023 | 117.53 USD |
| Saving | 109.40 USD/month (93 %) |
Design notes: 2,555 days is 7 years. Deep Archive has a 180-day minimum duration, no problem here because the objects arrive with 365 days of life and stay for more than six years. And the requirement "restore the last 30 days in minutes" is what stops you dropping to Glacier earlier: Glacier Flexible takes from minutes to 12 hours depending on the retrieval mode.
Solution 2.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SaraLeeInformes",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::111122223333:user/sara"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mercadofresco-informes-analitica/informes/*"
},
{
"Sid": "SaraListaSoloElPrefijoInformes",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::111122223333:user/sara"},
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::mercadofresco-informes-analitica",
"Condition": {"StringLike": {"s3:prefix": "informes/*"}}
},
{
"Sid": "RolAnaliticaEscribeInformes",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::111122223333:role/rol-mercadofresco-analitica"},
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::mercadofresco-informes-analitica/informes/*"
},
{
"Sid": "DenegarSinHTTPS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::mercadofresco-informes-analitica",
"arn:aws:s3:::mercadofresco-informes-analitica/*"
],
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
},
{
"Sid": "DenegarSubidasSinCifradoKMS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::mercadofresco-informes-analitica/*",
"Condition": {
"StringNotEquals": {"s3:x-amz-server-side-encryption": "aws:kms"}
}
}
]
}Why the last two need Deny and not Allow. An Allow only grants: if you simply "allowed
HTTPS", any other policy — an IAM identity policy attached to the user, for instance — could still
grant access over HTTP, because permissions are added together. An explicit Deny, by contrast,
takes absolute precedence: no policy at any layer can override it. For security requirements
that must hold always and without exception, the correct formulation is to forbid the unwanted
condition, not to allow the wanted one.
One more detail: without the s3:prefix condition on ListBucket, Sara could list the whole bucket
and see the object names under other prefixes, even though she could not download them. Names that
sometimes reveal more than they appear to.
Solution 3.
| Data | Bucket and prefix | Initial class | Versioning | Lifecycle |
|---|---|---|---|---|
| A) Original photos | mercadofresco-catalogo-fotos / productos/ |
Standard | Yes: they are the original and cannot be regenerated; protects against an accidental rm |
IA at 90 d, Glacier IR at 365 d, Deep Archive at 3 years; old versions expire at 90 d |
| B) Thumbnails | mercadofresco-catalogo-fotos / miniaturas/ |
Standard, and One Zone-IA after 30 d | No: they are regenerable with the Lambda; versioning would only add cost | To One Zone-IA after 30 d. Different prefix from the source so as not to create an event loop |
| C) Web server logs | mercadofresco-registros-web / nginx/YYYY/MM/DD/ |
Standard | No | IA at 30 d, expiry at exactly 90 d (the legal requirement). Partitioning by date in the key makes them easier to query and delete |
| D) Monthly sales report | mercadofresco-informes-analitica / informes/YYYY/MM/ |
Standard with SSE-KMS | Yes: it is business data that must not be lost through an overwrite | IA at 60 d, expiry at 2 years. Access only through a pre-signed URL, private bucket |
A cross-cutting observation: the thumbnail prefix must sit outside the prefix that triggers the
event (productos/), or the notification filter must exclude them. It is the protection against the
recursive loop mentioned earlier.
Conclusion
MercadoFresco now has its photos where they belong. You have understood that S3 is an object store and not a file system: there are no folders, only flat keys where the slash is one more character; objects are immutable; renaming a prefix means copying and deleting. In exchange for those constraints you get practically unlimited scale, eleven nines of durability through automatic replication across three or more Availability Zones, and a cost per GB some thirteen times lower than EFS.
You know how to build valid bucket names in a global namespace, LocationConstraint trap
included, and you have a convention of your own (mercadofresco-<componente>-<proposito>). You have
mastered the seven storage classes and their trade-offs — latency, retrieval cost, minimum
duration, number of AZs — and you have written lifecycle rules that cool the catalogue photos
automatically, move the thumbnails to One Zone-IA because they are regenerable, expire the reports
after two years, delete the old versions and — the rule everybody forgets — abort the incomplete
multipart uploads that are billed in silence.
You have enabled versioning and practised the real recovery of an accidental deletion by
removing the delete marker, and you know about MFA Delete and Object Lock for the cases that
demand inviolable retention. You have migrated /var/www/fotos with aws s3 sync, with its
mandatory --dryrun and its inclusion filters, and you are clear about the division of labour
between aws s3 for moving data and aws s3api for configuring.
On security, you have seen why buckets are born closed today: Block Public Access with its four
switches, bucket policies in JSON with the critical distinction between the bucket ARN and the
object ARN, the absolute precedence of an explicit Deny, and ACLs as a legacy you should not
use. You have generated pre-signed URLs with boto3 so that Sara can download her reports without
having AWS credentials, and also to accept third-party uploads with type and size limits. You know
that everything is encrypted at rest by default and when it pays to move from SSE-S3 to
SSE-KMS with BucketKeyEnabled.
You have seen static hosting and why it is not used that way in production, you have left configured the event notification that will trigger thumbnail generation — with the prefix filter that avoids the infinite loop — you know about multipart upload and Transfer Acceleration, and you have done the calculation that governs everything else: in MercadoFresco's S3 bill, 97 % is outbound transfer, not storage.
The photos are settled and the heart of the business is still missing. MercadoFresco's orders are still in the database on the office server, with its unreliable backup and its inability to grow. In lesson 02-04, "Amazon RDS", we will migrate that database to a managed service: we will see what it brings over installing PostgreSQL on an EC2, we will set up Multi-AZ with a synchronous standby replica, we will create read replicas so that Sara's reports do not punish production, and we will finally close problem 2 with automated backups and point-in-time recovery.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
