← All posts

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.

Browser DNS query Route 53 DNS · A alias records HTTPS CloudFront CDN · HTTPS · HTTP/2+3 CF Function: URI rewrite OAC: SigV4 signing private S3 private · versioned ACM TLS cert · us-east-1 · free TLS cert request path certificate / auth active component

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

terminal npm run deploy:live you run this astro build → dist/ S3 sync changed files only CF invalidate flush cache Live ~30 sec 0s ~60–90 seconds total ✓ deployed

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.

ServiceCost
Route 53~$0.54/mo
CloudFront~$0.00–1.00/mo
S3~$0.01–0.05/mo
ACMFree
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.