Creating a Global Image CDN with AWS S3 and CloudFront

Pre-requisites: AWS CLI v2 Installed & Configured , 1 or more images for test β
TL;DR (What will we be doing?)
Create and secure an S3 bucket (with versioning, encryption, and public access block).
Upload images using the AWS CLI (
aws s3 sync
).Set up a CloudFront distribution with an Origin Access Identity (OAI) to securely serve images and configure caching policies.
Secure S3 access with a bucket policy that restricts direct access, ensuring images are only served via CloudFront.
Test the setup to verify cache hits/misses and overall performance.
What is a CDN and Why Use It?
A network of globally distributed servers that cache static content (like images, CSS, JavaScript, etc).
Benefits:
Reduced Latency: Users receive content from the nearest edge location.
Improved Performance: Faster load times and reduced load on our origin server (S3).
Enhanced Security: Can help with DDoS protection and secure content delivery.
Step 1: Create an S3 Bucket π¦π
Letβs start by creating two S3 buckets in different regions. Note that when creating a bucket in us-east-1 (the default region), we do not need to specify a location constraint.
i) For us-east-1
:
Command:
aws s3api create-bucket --bucket image-bucket-2025 --region us-east-1
Output:
{
"Location": "/image-bucket-2025"
}
ii) For us-west-2
and other regions:
Command:
aws s3api create-bucket --bucket image-bucket-west-2025 --region us-west-2 --create-bucket-configuration LocationConstraint=us-west-2
Output (From (ii) just above) :
{
"Location": "http://image-bucket-west-2025.s3.amazonaws.com/"
}
Handy Notes:
When creating a bucket in us-east-1, do not include the
--create-bucket-configuration LocationConstraint=us-east-1
parameter because this region is treated as the default and including these resultsInvalidLocationConstraint
error.For other regions (e.g., us-west-2), ensure both --region and --create-bucket-configuration LocationConstraint match.
Step 2: Enable Versioning on the Bucket β³π
Enabling versioning allows us to recover objects from accidental deletions or modifications.
Command:
aws s3api put-bucket-versioning --bucket image-bucket-2025 --versioning-configuration Status=Enabled
Handy Notes:
- If we mistype the bucket name (e.g., using
image-bucket-2024
), we might encounter an "Access Denied" error if we donβt have the proper permissions for that bucket.
Step 3: Enable Default Encryption ππ‘οΈ
Enabling Server-Side Encryption (SSE-S3) to ensure that objects stored in S3 are encrypted using AES256.
Command:
aws s3api put-bucket-encryption --bucket image-bucket-2025 --server-side-encryption-configuration '{ "Rules": [{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } }] }'
Step 4: Block Public Access π«π
For security, configure this bucket to block public access so that only CloudFront (using an OAI) can retrieve objects.
Command:
aws s3api put-public-access-block --bucket image-bucket-2025 --public-access-block-configuration '{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }'
Handy Notes:
- OAI (Origin Access Identity): A special CloudFront user identity that allows CloudFront to securely access S3 buckets without exposing them to the public. It acts as a middleman and allows CloudFront to fetch objects from S3 while blocking direct public access.
Step 5: Upload Files to S3 π€πΌοΈ
Explore how to sync a local folder containing images to our S3 bucket using the CLI.
Commands:
Create a test folder and add an image:
cd ~ mkdir test-image
Manually add an image to this folder for convenience, e.g., put βword-embedding-apple.pngβ inside this folder. (The image is as shown below)
Upload/Sync our local image folder and its content to our created S3 bucket:
aws s3 sync ~/test-image s3://image-bucket-2025/images
Output (From 3 just above):
upload: test-image/word-embedding-apple.png to s3://image-bucket-2025/images/word-embedding-apple.png
Handy Notes:
- Like we uploaded an entire folder/directory, can use
aws s3 cp
for a single file upload. Refer to the official docs here for more: https://docs.aws.amazon.com/cli/latest/reference/s3/cp.html
Step 6: Create a CloudFront Distribution
CloudFront is a Content Delivery Network (CDN) that caches our static assets globally, reducing latency. In this setup, CloudFront will use an Origin Access Identity (OAI) to securely retrieve content from our S3 bucket.
6.1: Create an Origin Access Identity (OAI) π€π
Command:
aws cloudfront create-cloud-front-origin-access-identity --cloud-front-origin-access-identity-config '{
"CallerReference": "aws-cli-example-caller-2025",
"Comment": "OAI for image-bucket-2025"
}'
Output (From command above):
{
"Location": "https://cloudfront.amazonaws.com/2020-05-31/origin-access-identity/cloudfront/ERTF9F3HTHDQ",
"ETag": "E1GY74M2WSZ75",
"CloudFrontOriginAccessIdentity": {
"Id": "ERTF9F3HTHDQ",
"S3CanonicalUserId": "d63a2ec4030735c8b201ccf613941bfbf4944a7cc27cc72e1a6799d29d505e6ab5efed9b418d2eb350450e73b1a89ca8",
"CloudFrontOriginAccessIdentityConfig": {
"CallerReference": "aws-cli-example-caller-2025",
"Comment": "OAI for image-bucket-2025"
}
}
}
Take note of the returned OAIβs ID (e.g., ERTF9F3HTHDQ
). We will need this in next steps.
6.2: Create a CloudFront Distribution πβ‘
Command:
aws cloudfront create-distribution --distribution-config '{
"CallerReference": "aws-cli-example-caller-2025",
"Aliases": {
"Quantity": 0,
"Items": []
},
"DefaultRootObject": "",
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "S3-image-bucket-2025",
"DomainName": "image-bucket-2025.s3.amazonaws.com",
"OriginPath": "",
"CustomHeaders": {
"Quantity": 0
},
"S3OriginConfig": {
"OriginAccessIdentity": "origin-access-identity/cloudfront/ERTF9F3HTHDQ"
}
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3-image-bucket-2025",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
},
"TrustedSigners": {
"Enabled": false,
"Quantity": 0
},
"ForwardedValues": {
"QueryString": false,
"Cookies": {
"Forward": "none"
},
"Headers": {
"Quantity": 0
},
"QueryStringCacheKeys": {
"Quantity": 0
}
},
"MinTTL": 0,
"DefaultTTL": 86400,
"MaxTTL": 31536000,
"Compress": true
},
"Comment": "CloudFront distribution for image hosting",
"Enabled": true,
"PriceClass": "PriceClass_100",
"ViewerCertificate": {
"CloudFrontDefaultCertificate": true
},
"Restrictions": {
"GeoRestriction": {
"RestrictionType": "none",
"Quantity": 0
}
}
}'
Not all the information in above command are important to remember, always refer the official docs for reference, some are defaults. (Please refer to resources mentioned at the end)
This command creates a CloudFront distribution to serve images from our S3 bucket
image-bucket-2025
.It uses an Origin Access Identity (OAI) for secure access to the bucket and enables HTTPS with the default CloudFront certificate.
The caching behavior is configured with Time to Leave (TTL). TTL settings (
MinTTL
,DefaultTTL
,MaxTTL
) are used to control cache expiration times.Compression is also enabled for faster delivery.
This created distribution covers all major regions globally with the most cost-effective PriceClass_100 plan.
Step 7: Secure S3 Access with a Bucket Policy πβ
Update the bucket policy so that only the CloudFront OAI can access our S3 bucket.
Command:
aws s3api put-bucket-policy --bucket image-bucket-2025 --policy '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ERTF9F3HTHDQ"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::image-bucket-2025/*"
}
]
}'
Step 8: List CloudFront Distributions ππ
We can use this command to list distributions and verify our CloudFront domain name.
Command:
aws cloudfront list-distributions --query "DistributionList.Items[].{Id:Id, DomainName:DomainName}" --output table
Output (From command above):
-----------------------------------------------------
| ListDistributions |
+--------------------------------+------------------+
| DomainName | Id |
+--------------------------------+------------------+
| d2z61qbc2yzo2i.cloudfront.net | E2XXAEVCAOEBMR |
+--------------------------------+------------------+
Handy Note:
- This
DomainName
from the above table will be used to compose the image URL for testing in step 9.
Step 9: Test the Image CDN Setup β πΆ
Test our CloudFront distribution by accessing an image via its URL. It should look something like this.
Command:
Run this twice in your terminal and see the difference the first time and second time.
curl -I https://d2z61qbc2yzo2i.cloudfront.net/images/word-embedding-apple.png
Output (First Time) - CACHE MISS
HTTP/2 200
content-type: image/png
content-length: 32369
date: Fri, 07 Feb 2025 07:19:32 GMT
last-modified: Fri, 07 Feb 2025 07:13:57 GMT
etag: "c3688188d41acb74b262270a39438e7a"
x-amz-server-side-encryption: AES256
x-amz-version-id: egHDizTCdO8xADRuUwJDa_XJ2nhtLIjz
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 fb465ef388ebb25e5a872213f9ac3e9c.cloudfront.net (CloudFront)
x-amz-cf-pop: MRS52-C1
x-amz-cf-id: jO8Ti8t4dTrcDq_k2UHtw5K5QAX2x2k7anGXkh3fjtxk-jZ34SFdUw==
If we see
x-cache: Miss from cloudfront
for the x-cache, this means our image is not yet cached, for the first fetch, OAI will fetch the image straight from the origin, which is our S3 bucket.After fetching, CloudFront caches it for future requests. The next request for the same image will then result in a "Hit" status if it is served from the cache.
Output (Second Time) - CACHE HIT
HTTP/2 200
content-type: image/png
content-length: 32369
date: Fri, 07 Feb 2025 07:19:32 GMT
last-modified: Fri, 07 Feb 2025 07:13:57 GMT
etag: "c3688188d41acb74b262270a39438e7a"
x-amz-server-side-encryption: AES256
x-amz-version-id: egHDizTCdO8xADRuUwJDa_XJ2nhtLIjz
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 9ba4efea4d7fc27f92a66f28df5d1152.cloudfront.net (CloudFront)
x-amz-cf-pop: MRS52-C1
x-amz-cf-id: YyBR5UXQ1JLQf4Tqm4N1_V74KqoeGWv1768b8Erkuobuj6nzpzmQUQ==
age: 15
- If we see
x-cache: Hit from cloudfront
for the x-cache, this means our image was already cached, OAI found this image was available in cache from just 15 seconds ago, indicated byage: 15
.
Finally, access the cached (hit) image in our browser using:
https://d2z61qbc2yzo2i.cloudfront.net/images/word-embedding-apple.png
Conclusion
In this way, this approach provides a minimal, secure, scalable image hosting solution using AWS S3 and CloudFront CDN using a collection of AWS CLI v2 commands. Further enhancements like automatic resizing/compression for uploaded images using Lambda@Edge, adding cache invalidation, creating frontend for this, setting up monitoring, connecting with other services and many more are possible on top of this. Similarly, itβs worth considering alternatives like Cloudflare R2 which provides us with similar object storage service, which is already S3-compatible, and the best part / major advantage over AWS S3 is R2βs Zero egress fee (means you don't pay for sending data out of a cloud service). Next article in this series, I will share my learnings with a deep dive into various cache invalidation approaches.
π Resources
https://awscli.amazonaws.com/v2/documentation/api/latest/index.html
https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html (Install AWS CLI v2)
https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-using.html
Subscribe to my newsletter
Read articles from Rahul S. Poudel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
