This is the complete solution for the End-to-End Static Site Deployment exercise. I’ve included every command with its expected output so you can compare as you go.
Why This Works
- This workflow turns four separate services into one deployment path: S3 stores the files, CloudFront serves them, ACM terminates TLS, and Route 53 points the domain at the edge.
- The bucket stays private because CloudFront is the only public entry point that needs direct read access.
- The final sync-plus-invalidation loop is the practical deployment cycle you will repeat long after the exercise is over.
If you want the AWS version of the end-to-end workflow open while you work, keep the S3 static website tutorial, the CloudFront OAC guide, and the Route 53 DNS configuration guide nearby.
Create the S3 Bucket
Create the bucket:
aws s3 mb s3://my-frontend-app-assets \
--region us-east-1Expected output:
make_bucket: my-frontend-app-assetsEnable Block Public Access:
aws s3api put-public-access-block \
--bucket my-frontend-app-assets \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
--region us-east-1No output on success.
Verify:
aws s3api get-public-access-block \
--bucket my-frontend-app-assets \
--region us-east-1 \
--output jsonExpected:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}Upload Your Site
Create a minimal site if you don’t have one:
mkdir -p build
echo '<!DOCTYPE html><html><head><title>My Site</title></head><body><h1>It works.</h1></body></html>' > build/index.htmlUpload:
aws s3 sync ./build s3://my-frontend-app-assets \
--region us-east-1 \
--output jsonExpected output:
upload: build/index.html to s3://my-frontend-app-assets/index.htmlVerify:
aws s3 ls s3://my-frontend-app-assets \
--region us-east-1Expected:
2026-03-18 12:00:00 102 index.htmlConfirm Domain and DNS Control
Before requesting the certificate, make sure Route 53 already has the hosted zone:
aws route53 list-hosted-zones-by-name \
--dns-name example.com \
--output json \
--query "HostedZones[0].{Id:Id,Name:Name}"Expected:
{
"Id": "/hostedzone/Z1234567890ABC",
"Name": "example.com."
}If that query returns nothing useful, go back and finish the domain and DNS setup first. ACM validation needs a place to publish the CNAME records.
Request an ACM Certificate
Request the certificate:
aws acm request-certificate \
--domain-name example.com \
--subject-alternative-names www.example.com \
--validation-method DNS \
--region us-east-1 \
--output jsonExpected:
{
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}Save the certificate ARN.
Get the DNS validation records:
aws acm describe-certificate \
--certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
--region us-east-1 \
--output json \
--query "Certificate.DomainValidationOptions"Expected (abridged):
[
{
"DomainName": "example.com",
"ValidationDomain": "example.com",
"ValidationStatus": "PENDING_VALIDATION",
"ResourceRecord": {
"Name": "_abc123.example.com.",
"Type": "CNAME",
"Value": "_def456.acm-validations.aws."
},
"ValidationMethod": "DNS"
},
{
"DomainName": "www.example.com",
"ValidationDomain": "www.example.com",
"ValidationStatus": "PENDING_VALIDATION",
"ResourceRecord": {
"Name": "_ghi789.www.example.com.",
"Type": "CNAME",
"Value": "_jkl012.acm-validations.aws."
},
"ValidationMethod": "DNS"
}
]Create the validation records in Route 53. First, get your hosted zone ID:
aws route53 list-hosted-zones-by-name \
--dns-name example.com \
--region us-east-1 \
--output json \
--query "HostedZones[0].Id"Expected:
"/hostedzone/Z1234567890ABC"Create the validation records (replace the Name and Value fields with the actual values from the describe-certificate output):
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "_abc123.example.com.",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{"Value": "_def456.acm-validations.aws."}
]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "_ghi789.www.example.com.",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{"Value": "_jkl012.acm-validations.aws."}
]
}
}
]
}' \
--region us-east-1 \
--output jsonWait for the certificate to be issued (this can take several minutes):
aws acm wait certificate-validated \
--certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
--region us-east-1No output on success. Verify:
aws acm describe-certificate \
--certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
--region us-east-1 \
--output json \
--query "Certificate.Status"Expected:
"ISSUED"Create an Origin Access Control
aws cloudfront create-origin-access-control \
--origin-access-control-config '{
"Name": "my-frontend-app-oac",
"Description": "OAC for my-frontend-app-assets S3 bucket",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}' \
--region us-east-1 \
--output jsonExpected:
{
"Location": "https://cloudfront.amazonaws.com/2020-05-31/origin-access-control/E1OAC2EXAMPLE",
"ETag": "E1ETAG1EXAMPLE",
"OriginAccessControl": {
"Id": "E1OAC2EXAMPLE",
"OriginAccessControlConfig": {
"Name": "my-frontend-app-oac",
"Description": "OAC for my-frontend-app-assets S3 bucket",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}
}
}Save the Id value (E1OAC2EXAMPLE).
Create the CloudFront Distribution
Save the following as distribution-config.json:
{
"CallerReference": "my-frontend-app-2026-03-18",
"Comment": "CloudFront distribution for my-frontend-app-assets",
"Enabled": true,
"DefaultRootObject": "index.html",
"PriceClass": "PriceClass_100",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true,
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "S3-my-frontend-app-assets",
"DomainName": "my-frontend-app-assets.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "E1OAC2EXAMPLE",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3-my-frontend-app-assets",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
}
},
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
}
]
},
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
},
"Aliases": {
"Quantity": 2,
"Items": ["example.com", "www.example.com"]
},
"Restrictions": {
"GeoRestriction": {
"RestrictionType": "none",
"Quantity": 0
}
}
}Create the distribution:
aws cloudfront create-distribution \
--distribution-config file://distribution-config.json \
--region us-east-1 \
--output jsonExpected (abridged):
{
"Distribution": {
"Id": "E1A2B3C4D5E6F7",
"ARN": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7",
"DomainName": "d1234abcdef.cloudfront.net",
"Status": "InProgress"
}
}Save the distribution ID and domain name.
Wait for deployment:
aws cloudfront wait distribution-deployed \
--id E1A2B3C4D5E6F7 \
--region us-east-1No output on success. Verify:
curl -I https://d1234abcdef.cloudfront.net/index.htmlExpected:
HTTP/2 200
content-type: text/htmlUpdate the S3 Bucket Policy
Save the following as bucket-policy-oac.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-frontend-app-assets/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
}
}
}
]
}Apply the policy:
aws s3api put-bucket-policy \
--bucket my-frontend-app-assets \
--policy file://bucket-policy-oac.json \
--region us-east-1No output on success.
Verify CloudFront still works:
curl -I https://d1234abcdef.cloudfront.net/index.htmlExpected: 200 OK.
Verify direct S3 access is blocked:
curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.htmlExpected: 403 Forbidden.
Create Route 53 DNS Records
Get your hosted zone ID:
aws route53 list-hosted-zones-by-name \
--dns-name example.com \
--region us-east-1 \
--output json \
--query "HostedZones[0].Id"Expected:
"/hostedzone/Z1234567890ABC"Create A alias records for both the apex domain and www:
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d1234abcdef.cloudfront.net",
"EvaluateTargetHealth": false
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d1234abcdef.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}
]
}' \
--region us-east-1 \
--output jsonThe HostedZoneId value Z2FDTNDATAQYW2 is the fixed hosted zone ID for all CloudFront distributions. This isn’t your domain’s hosted zone ID—it’s a constant defined by AWS. You use your domain’s hosted zone ID in the --hosted-zone-id parameter, and CloudFront’s fixed ID in the AliasTarget.
Expected:
{
"ChangeInfo": {
"Id": "/change/C1EXAMPLE",
"Status": "PENDING",
"SubmittedAt": "2026-03-18T12:00:00Z"
}
}Wait for the DNS change to propagate:
aws route53 wait resource-record-sets-changed \
--id /change/C1EXAMPLE \
--region us-east-1Verify the Complete Pipeline
# DNS resolves
dig example.com +shortExpected: One or more CloudFront IP addresses.
# HTTPS works with your certificate
curl -vI https://example.com 2>&1 | grep "subject:"Expected (the subject should contain your domain):
* subject: CN=example.com# CloudFront serves your content
curl -I https://example.comExpected:
HTTP/2 200
content-type: text/html# SPA routing works
curl -I https://example.com/any/spa/routeExpected:
HTTP/2 200
content-type: text/html# Direct S3 access is blocked
curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.htmlExpected:
HTTP/1.1 403 Forbidden# www subdomain works
curl -I https://www.example.comExpected:
HTTP/2 200
content-type: text/htmlTest a Deployment
Update the site content:
echo '<!DOCTYPE html><html><head><title>My Site</title></head><body><h1>Updated content.</h1></body></html>' > build/index.htmlSync to S3:
aws s3 sync ./build s3://my-frontend-app-assets \
--region us-east-1 \
--delete \
--output jsonExpected:
upload: build/index.html to s3://my-frontend-app-assets/index.htmlInvalidate the cache:
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--paths "/*" \
--region us-east-1 \
--output jsonExpected:
{
"Invalidation": {
"Id": "I1EXAMPLE",
"Status": "InProgress",
"CreateTime": "2026-03-18T12:05:00Z",
"InvalidationBatch": {
"Paths": {
"Quantity": 1,
"Items": ["/*"]
},
"CallerReference": "cli-1710763500"
}
}
}Wait a minute, then verify:
curl -s https://example.com | grep "Updated content"Expected:
<h1>Updated content.</h1>Summary of Resources Created
| Resource | Identifier |
|---|---|
| S3 Bucket | my-frontend-app-assets |
| ACM Certificate | arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
| Origin Access Control | E1OAC2EXAMPLE |
| CloudFront Distribution | E1A2B3C4D5E6F7 |
| CloudFront Domain | d1234abcdef.cloudfront.net |
| Route 53 A Record (apex) | example.com -> CloudFront |
| Route 53 A Record (www) | www.example.com -> CloudFront |
| S3 Bucket Policy | CloudFront-only access via OAC |
Your static site is live at https://example.com, served globally through CloudFront, secured with an ACM certificate, stored in a private S3 bucket, and resolved by Route 53. The deployment cycle (sync + invalidate) updates the site in under a minute.