How this site is deployed: AWS static hosting from scratch
A complete walkthrough of the S3 + CloudFront + Route 53 stack behind victorhernandez.me — with the commands, the gotchas, and the architecture diagrams.
This site is a static Astro build deployed to S3, served through CloudFront, with Route 53
handling DNS. Total monthly cost: around a dollar. Deploy time from git save to live: under
two minutes. Here’s how it works and how to reproduce it.
The architecture
Three AWS services do the actual work. A fourth — ACM — just issues the certificate and gets out of the way.
Why CloudFront in front of S3? Because S3 static website hosting doesn’t support HTTPS on custom domains. You need CloudFront for that, and once you have it you also get a global CDN, HTTP/2 and HTTP/3, Brotli compression, and a cache you control. The cost at personal site traffic levels is essentially zero.
Why OAC instead of a public bucket? Origin Access Control lets CloudFront authenticate to S3 using SigV4 request signing, so the bucket stays completely private. No public URLs, no accidental direct access. Only your specific CloudFront distribution can read the files.
The deploy flow
The deploy:live npm script chains three things: Astro builds to dist/, the AWS CLI
syncs only changed files to S3 with --delete to remove old ones, then CloudFront
invalidation flushes the cache. Changes are visible in about 30 seconds.
Step by step
1. AWS CLI and named profile
Everything runs through a named profile so credentials never touch the scripts.
aws configure --profile victor
# Access Key ID: <your key>
# Secret Access Key: <your secret>
# Default region: us-east-1
# Output format: json
# Set for the session
export AWS_PROFILE=victor
# Verify
aws sts get-caller-identity
2. S3 bucket
# Create — us-east-1 is special, no LocationConstraint needed
aws s3api create-bucket \
--bucket victorhernandez.me \
--region us-east-1
# Block all public access — CloudFront will be the only reader
aws s3api put-public-access-block \
--bucket victorhernandez.me \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Enable versioning for rollback capability
aws s3api put-bucket-versioning \
--bucket victorhernandez.me \
--versioning-configuration Status=Enabled
3. ACM certificate
The certificate must be in us-east-1 — a hard CloudFront requirement, regardless
of where your bucket or users are.
aws acm request-certificate \
--domain-name victorhernandez.me \
--subject-alternative-names "www.victorhernandez.me" \
--validation-method DNS \
--region us-east-1
ACM gives you DNS CNAME records to add to Route 53. Add them, then wait for status
ISSUED before touching CloudFront — you’ll get an InvalidViewerCertificate error
if you proceed early.
# Watch validation status
watch -n 10 "aws acm describe-certificate \
--certificate-arn <ARN> \
--region us-east-1 \
--query \"Certificate.Status\""
4. Origin Access Control
OAC is the modern replacement for Origin Access Identity. It authenticates CloudFront to S3 using SigV4 signing.
aws cloudfront create-origin-access-control \
--origin-access-control-config '{
"Name": "victorhernandez-oac",
"Description": "OAC for victorhernandez.me S3 origin",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}'
Save the Id from the response.
5. CloudFront distribution
aws cloudfront create-distribution --distribution-config '{
"CallerReference": "victor-site-001",
"Comment": "victorhernandez.me",
"Aliases": {
"Quantity": 2,
"Items": ["victorhernandez.me", "www.victorhernandez.me"]
},
"DefaultRootObject": "index.html",
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "s3-victorhernandez",
"DomainName": "victorhernandez.me.s3.us-east-1.amazonaws.com",
"S3OriginConfig": { "OriginAccessIdentity": "" },
"OriginAccessControlId": "<OAC_ID>"
}]
},
"DefaultCacheBehavior": {
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
},
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"TargetOriginId": "s3-victorhernandez"
},
"CustomErrorResponses": {
"Quantity": 1,
"Items": [{
"ErrorCode": 404,
"ResponsePagePath": "/404.html",
"ResponseCode": "404",
"ErrorCachingMinTTL": 10
}]
},
"ViewerCertificate": {
"ACMCertificateArn": "<CERT_ARN>",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
},
"HttpVersion": "http2and3",
"Enabled": true,
"PriceClass": "PriceClass_100"
}'
CachePolicyId 658327ea... is AWS’s managed CachingOptimized policy. PriceClass_100
covers US and Europe edge nodes — cheapest tier, still fast for a personal site.
Save the Distribution Id and Domain Name from the response.
6. S3 bucket policy
Grant CloudFront read access. The SourceArn condition scopes it to your specific
distribution — no other CloudFront distribution can access your bucket.
aws s3api put-bucket-policy \
--bucket victorhernandez.me \
--policy '{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::victorhernandez.me/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
}
}
}]
}'
7. CloudFront URI rewrite function
Without this, navigating to /about returns a 403. CloudFront looks for a file literally
named about in S3 — but Astro builds it as about/index.html. A small CloudFront
Function rewrites the URI before the request hits S3.
cat > /tmp/rewrite-uri.js << 'EOF'
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith("/")) {
request.uri += "index.html";
} else if (!uri.includes(".")) {
request.uri += "/index.html";
}
return request;
}
EOF
aws cloudfront create-function \
--name rewrite-uri \
--function-config '{"Comment":"Rewrite subdirectory URIs to index.html","Runtime":"cloudfront-js-2.0"}' \
--function-code fileb:///tmp/rewrite-uri.js
One gotcha: the --function-code flag expects fileb:// (binary file), not an inline
string. Another gotcha: when attaching the function to the distribution via
update-distribution, the --distribution-config flag expects only the inner
DistributionConfig object — not the full response from get-distribution-config
which wraps it with an ETag key. Extract it first:
python3 -c "
import json
with open('dist-config.json') as f:
data = json.load(f)
with open('dist-config-only.json', 'w') as f:
json.dump(data['DistributionConfig'], f, indent=2)
"
Then add FunctionAssociations inside DefaultCacheBehavior and run:
aws cloudfront update-distribution \
--id <DISTRIBUTION_ID> \
--if-match <ETag from dist-config.json top level> \
--distribution-config file://dist-config-only.json
8. Route 53 DNS
# Get your hosted zone ID
aws route53 list-hosted-zones \
--query "HostedZones[?Name=='victorhernandez.me.'].Id"
# Point apex and www to CloudFront
aws route53 change-resource-record-sets \
--hosted-zone-id <ZONE_ID> \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "victorhernandez.me",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "<YOUR_CF_DOMAIN>.cloudfront.net",
"EvaluateTargetHealth": false
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.victorhernandez.me",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "<YOUR_CF_DOMAIN>.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}
]
}'
Z2FDTNDATAQYW2 is CloudFront’s fixed hosted zone ID — same for everyone.
What it costs
Route 53 is the only fixed cost at $0.50/month per hosted zone. Everything else scales with traffic and rounds to zero at personal site volume.
| Service | Cost |
|---|---|
| Route 53 | ~$0.54/mo |
| CloudFront | ~$0.00–1.00/mo |
| S3 | ~$0.01–0.05/mo |
| ACM | Free |
| Total | ~$0.55–1.50/mo |
If a post hits Hacker News and you get 50,000 visitors in a day, the bill for that month might reach $5. CloudFront scales automatically; the cost scales proportionally at rates that only matter at serious volume.