Mike J / Hosting a Static Site on AWS, End to End (S3 + CloudFront + OAC + Route 53 + ACM)

Created Sun, 07 Jun 2026 08:00:00 -0400 Modified Mon, 21 Sep 2026 20:08:17 -0400

I moved my blog (the one you’re reading now) onto Amazon Web Services . I was only paying about $10–$15 per month to host on GoDaddy, but it still felt too expensive. After all, my little blog doesn’t get much traffic these days 😭. It’s also important to me that I treat every development and deployment opportunity as a learning experience. A turnkey hosting service robs you of the chance to learn about the different components and layers in the website deployment process. This post recounts my setup process so that you, the reader, can follow this and deploy your own site on the cheap.

Note: Do not try this unless you want to become your own devops team + system administrator

There is also a long-term benefit I will reap in addition to putting knowledge in my noggin. A managed host is a fine on-ramp, but you’re renting someone else’s opinions about caching, headers, redirects, and TLS. Running it myself on AWS means I own every one of those knobs — and the bill scales with actual traffic, which for a blog rounds to nothing. But there’s also a longer game: the same account that serves this site can grow into whatever I build next, without a migration. I’m planning to deploy some SPAs for fun and/or profit in the mid-future. Consider this blog deployment the prototype for future web apps.

This post gives the whole setup, start to finish, using only the AWS CLI . I originally created everything by console clicking. If you follow this to the end you’ll have a private S3 bucket served over HTTPS through CloudFront , a real certificate from AWS Certificate Manager , DNS in Route 53 , and a deploy that’s a single aws s3 sync.

The architecture

                 Route 53 (apex + www, A/AAAA alias records)
                                  │
                                  ▼
        CloudFront distribution  ──  ACM cert (us-east-1, *.example.com)
          • redirect-to-HTTPS         CloudFront Function (viewer-request):
          • HTTP/2 + HTTP/3             /dir/ → /dir/index.html
          • caches at the edge        CloudFront Function (viewer-response):
                                       security headers
                                  │
                          Origin Access Control (SigV4)
                                  │  (signed, HTTPS only)
                                  ▼
                 Private S3 bucket  ──  bucket policy allows ONLY
                 (Block Public Access     this distribution's CloudFront
                  fully ON)               service principal

Notes:

  • The bucket is private. Nobody reaches S3 directly — CloudFront authenticates to it with Origin Access Control (OAC) , and a bucket policy trusts only this one distribution. Visitors only ever touch the CDN edge.
  • An alias record is a special Route 53 entity that maps a DNS name to an AWS resource (CloudFront distribution) instead of an IP address.

Prerequisites

  • An AWS account and the CLI installed and authenticated (aws sts get-caller-identity should work).
  • A registered domain. I’ll use example.com throughout — substitute yours.
  • A built static site. I use Hugo ; any generator that outputs a folder of files works.

Set a few shell variables so the commands below are copy-pasteable:

1
2
3
4
DOMAIN=example.com
BUCKET=example-site          # NOTE: no dots — this matters, see the gotchas
REGION=us-east-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

A quick word on Hugo

Hugo is my site generator of choice. I won’t belabor it — but you can go info diving if you’re interested . The only thing that matters for hosting is that something builds your entire site into a public/ directory of plain files:

1
hugo --gc --minify        # output lands in ./public

That folder — containing root index.html, per-page index.html files, images, CSS — is the artifact we ship to S3. Hold that thought; the very last step is a one-liner that copies it up.

Step 1 — Create the private S3 bucket

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# In us-east-1 you do NOT pass a LocationConstraint; every other region requires it.
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION"

# Block public access — all four switches, at the bucket level.
aws s3api put-public-access-block --bucket "$BUCKET" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Default server-side encryption.
aws s3api put-bucket-encryption --bucket "$BUCKET" \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

New buckets already default to BucketOwnerEnforced (ACLs disabled), which is what you want — access is governed entirely by the bucket policy we’ll add later, not by per-object ACLs.

While you’re here, you can lock the whole account down so a future bucket can’t accidentally go public:

1
2
3
aws s3control put-public-access-block --account-id "$ACCOUNT_ID" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Step 2 — Request a TLS certificate from ACM (in us-east-1)

CloudFront can only use certificates from us-east-1, no matter where your users are. Request one for the apex (example.com, no www in front) plus a wildcard (*.example.com), validated by DNS :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CERT_ARN=$(aws acm request-certificate \
  --domain-name "$DOMAIN" \
  --subject-alternative-names "*.$DOMAIN" \
  --validation-method DNS \
  --region us-east-1 \
  --query CertificateArn --output text)

# ACM hands you a CNAME to prove you own the domain:
aws acm describe-certificate --certificate-arn "$CERT_ARN" --region us-east-1 \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord'

Keep that CNAME name/value handy — we’ll drop it into Route 53 in the next step, then wait for the cert to go green.

Step 3 — Create the Route 53 hosted zone

1
2
aws route53 create-hosted-zone --name "$DOMAIN" --caller-reference "$(date +%s)" \
  --query '{Zone:HostedZone.Id,NS:DelegationSet.NameServers}'

Take the four NS name servers it returns and set them at your domain registrar so the world delegates DNS for example.com to Route 53. Save the zone id:

1
2
ZONE_ID=$(aws route53 list-hosted-zones-by-name --dns-name "$DOMAIN" \
  --query 'HostedZones[0].Id' --output text | cut -d/ -f3)

Now add the ACM validation CNAME from Step 2 (fill in the name/value ACM gave you):

1
2
3
4
5
6
7
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --change-batch '{
  "Changes":[{"Action":"UPSERT","ResourceRecordSet":{
    "Name":"_xxxx.example.com","Type":"CNAME","TTL":300,
    "ResourceRecords":[{"Value":"_yyyy.acm-validations.aws."}]}}]}'

# Block until the certificate is issued (usually a couple of minutes).
aws acm wait certificate-validated --certificate-arn "$CERT_ARN" --region us-east-1

Step 4 — A CloudFront Function so directory URLs resolve

S3’s REST endpoint (the private one OAC uses) does not automatically serve index.html for a directory request. So a request for /posts/hello/ would 404. A tiny CloudFront Function on the viewer-request event fixes that:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
cat > rewrite.js <<'EOF'
function handler(event) {
    var request = event.request;
    var uri = request.uri;
    if (uri.endsWith('/')) {
        request.uri += 'index.html';                 // /posts/hello/ -> /posts/hello/index.html
    } else if (uri.lastIndexOf('.') < uri.lastIndexOf('/')) {
        request.uri += '/index.html';                // /posts/hello   -> /posts/hello/index.html
    }
    return request;                                  // leaves /css/site.css, /img/x.png alone
}
EOF

aws cloudfront create-function --name "${BUCKET}-index-rewrite" \
  --function-config Comment="dir index rewrite",Runtime=cloudfront-js-2.0 \
  --function-code fileb://rewrite.js
EF=$(aws cloudfront describe-function --name "${BUCKET}-index-rewrite" --query 'ETag' --output text)
aws cloudfront publish-function --name "${BUCKET}-index-rewrite" --if-match "$EF"
FN_ARN=$(aws cloudfront describe-function --name "${BUCKET}-index-rewrite" \
  --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)

Step 5 — Create the Origin Access Control

1
2
3
OAC_ID=$(aws cloudfront create-origin-access-control --origin-access-control-config \
  Name="${BUCKET}-oac",Description="OAC for ${BUCKET}",SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3 \
  --query 'OriginAccessControl.Id' --output text)

Step 6 — Create the CloudFront distribution

This is the big one. Write a dist.json that wires together the S3 REST origin, the OAC, the function, the certificate, and the cache behavior. The CachePolicyId below is AWS’s managed CachingOptimized policy.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
cat > dist.json <<EOF
{
  "CallerReference": "${BUCKET}-$(date +%s)",
  "Comment": "${DOMAIN} static site",
  "Enabled": true,
  "HttpVersion": "http2and3",
  "IsIPV6Enabled": true,
  "DefaultRootObject": "index.html",
  "Aliases": { "Quantity": 2, "Items": ["${DOMAIN}", "www.${DOMAIN}"] },
  "Origins": { "Quantity": 1, "Items": [{
    "Id": "s3-origin",
    "DomainName": "${BUCKET}.s3.${REGION}.amazonaws.com",
    "OriginAccessControlId": "${OAC_ID}",
    "S3OriginConfig": { "OriginAccessIdentity": "" },
    "CustomHeaders": { "Quantity": 0 },
    "OriginShield": { "Enabled": false },
    "ConnectionAttempts": 3, "ConnectionTimeout": 10, "OriginPath": ""
  }]},
  "DefaultCacheBehavior": {
    "TargetOriginId": "s3-origin",
    "ViewerProtocolPolicy": "redirect-to-https",
    "Compress": true,
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "AllowedMethods": { "Quantity": 2, "Items": ["GET","HEAD"],
      "CachedMethods": { "Quantity": 2, "Items": ["GET","HEAD"] } },
    "FunctionAssociations": { "Quantity": 1, "Items": [
      { "FunctionARN": "${FN_ARN}", "EventType": "viewer-request" } ] }
  },
  "CustomErrorResponses": { "Quantity": 2, "Items": [
    { "ErrorCode": 403, "ResponsePagePath": "/404.html", "ResponseCode": "404", "ErrorCachingMinTTL": 10 },
    { "ErrorCode": 404, "ResponsePagePath": "/404.html", "ResponseCode": "404", "ErrorCachingMinTTL": 10 }
  ]},
  "ViewerCertificate": {
    "ACMCertificateArn": "${CERT_ARN}",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "PriceClass": "PriceClass_All"
}
EOF

read DIST_ID DIST_DOMAIN < <(aws cloudfront create-distribution \
  --distribution-config file://dist.json \
  --query '[Distribution.Id,Distribution.DomainName]' --output text)
echo "Distribution $DIST_ID at $DIST_DOMAIN"

A private S3 bucket returns 403 (not 404) for a key that doesn’t exist, which is why both 403 and 404 map to /404.html above.

Step 7 — Let only this distribution read the bucket

Now attach the bucket policy that makes OAC work. It grants s3:GetObject to the CloudFront service principal , scoped by AWS:SourceArn to this exact distribution — so no other CloudFront distribution, and certainly no anonymous visitor, can read it. Because the policy names a principal rather than granting to *, S3 doesn’t score it as public — so BlockPublicPolicy lets it through.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
cat > bucket-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontOAC",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::${BUCKET}/*",
    "Condition": { "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}" } }
  }]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://bucket-policy.json

Step 8 — Point DNS at CloudFront

Finally, alias both the apex and www at the distribution. Route 53 alias records are special — they point at the AWS resource directly (no charge for the lookup), and you need both A (IPv4) and AAAA (IPv6). Z2FDTNDATAQYW2 is CloudFront’s fixed, global hosted-zone id for aliases — it’s the same for everyone.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cat > dns.json <<EOF
{ "Comment": "point apex + www at CloudFront", "Changes": [
  { "Action": "UPSERT", "ResourceRecordSet": { "Name": "${DOMAIN}", "Type": "A",
    "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "${DIST_DOMAIN}", "EvaluateTargetHealth": false } } },
  { "Action": "UPSERT", "ResourceRecordSet": { "Name": "${DOMAIN}", "Type": "AAAA",
    "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "${DIST_DOMAIN}", "EvaluateTargetHealth": false } } },
  { "Action": "UPSERT", "ResourceRecordSet": { "Name": "www.${DOMAIN}", "Type": "A",
    "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "${DIST_DOMAIN}", "EvaluateTargetHealth": false } } },
  { "Action": "UPSERT", "ResourceRecordSet": { "Name": "www.${DOMAIN}", "Type": "AAAA",
    "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "${DIST_DOMAIN}", "EvaluateTargetHealth": false } } }
]}
EOF
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --change-batch file://dns.json

Step 9 — Deploy: the one-liner that does all the work

Everything above is one-time plumbing. This is the command you’ll run for the rest of the site’s life. aws s3 sync compares your local public/ folder against the bucket and uploads only what changed; --delete removes anything in the bucket that’s no longer in public/, so the bucket is always an exact mirror of your last build:

1
2
3
hugo --gc --minify
aws s3 sync public/ "s3://$BUCKET/" --delete
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"

Build, mirror to S3, then tell CloudFront to forget its cache so visitors see the new version immediately. That’s the entire publish workflow — three lines, no FTP, no dashboard. I keep mine in a small deploy.py so a single command builds and ships.

Optional hardening — security headers without a policy

You’d normally add HTTP security headers with a CloudFront response-headers policy . If you’re on CloudFront’s free flat-rate plan, those policies are blocked (see the gotchas) — but Functions aren’t, so a second function on the viewer-response event does the same job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat > headers.js <<'EOF'
function handler(event) {
    var h = event.response.headers;
    h['strict-transport-security'] = { value: 'max-age=63072000; includeSubDomains' };
    h['x-content-type-options']    = { value: 'nosniff' };
    h['x-frame-options']           = { value: 'DENY' };
    h['referrer-policy']           = { value: 'strict-origin-when-cross-origin' };
    return event.response;
}
EOF

Create and publish it the same way as Step 4, then add it to the distribution’s FunctionAssociations with "EventType": "viewer-response" alongside the rewrite function.

Two gotchas that cost me an afternoon

  1. Don’t put dots in the bucket name. OAC talks to the S3 REST endpoint over HTTPS, and S3’s TLS certificate is a single-label wildcard, *.s3.us-east-1.amazonaws.com. A bucket named example.com produces the endpoint example.com.s3.us-east-1.amazonaws.com — two labels before .s3, which the wildcard doesn’t match — so the TLS handshake fails and CloudFront serves 502. Name the bucket something dot-free like example-site. The bucket name is internal; it has nothing to do with your public domain.

  2. The CloudFront free plan blocks response-headers policies. CloudFront’s newer free flat-rate plan bundles a WAF, DDoS protection, DNS, and TLS at no cost, which is great — but it disallows a few “advanced” features, including attaching a response-headers policy. CloudFront Functions are allowed, so I set my security headers with a function instead (above). If you see Distributions with the Free pricing plan can't have the following features, that’s this.

What it costs

Here’s the part that makes the whole exercise worth it. This is real billing for this site:

AWS cost and usage: about ten cents a month

A forecasted ten cents a month — mostly the Route 53 hosted zone, with S3 storage and requests rounding to nearly nothing because CloudFront serves almost everything from its edge cache. No fixed monthly hosting fee, no plan tier, no paying for headroom I’m not using. If this site suddenly got popular, the bill would scale with the traffic and still be trivial; and if I want to bolt on an API, a database, or a second project tomorrow, it’s the same account, same CLI, same workflow.

That’s the trade I wanted: a little more setup up front, in exchange for owning every layer and paying only for what I actually use.