This site is just files: HTML pages, one stylesheet, one script, and some images. There's no server to patch and no database to back up. That makes it cheap and safe to host, as long as the files are served correctly. Below is exactly how I set it up, with generic names in place of my real ones:

  • example.com is the domain
  • example-site is the S3 bucket
  • us-east-2 is the region where the site lives
  • us-east-1 is the region where the certificate lives (CloudFront requires this)

The big picture

When someone opens the site, their request goes through four AWS services in order:

  1. The visitor opens https://example.com
  2. Route 53 DNS: turns the domain into CloudFront's address
  3. CloudFront HTTPS + caching at edge locations, TLS certificate from ACM
  4. S3 bucket Private. Only CloudFront can read it.
Visitor → Route 53 → CloudFront → private S3 bucket. ACM supplies CloudFront's HTTPS certificate.

The key idea is that the bucket is never public. Visitors can access the files only through CloudFront, which adds HTTPS, caches pages near visitors, and is the only entity allowed to read the bucket.

My account's guardrails

My AWS account belongs to an AWS Organization with a couple of security policies attached. They shaped some of my choices, so they're worth knowing about:

  • A region-restriction SCP (service control policy). In us-east-1, I can only use global services like ACM, CloudFront, Route 53, IAM, and billing. Creating an S3 bucket is denied. us-east-2 allows everything, so that's where the site lives. Other regions are blocked or heavily restricted.
  • A resource control policy (RCP) that blocks anyone outside my Organization from reaching S3, DynamoDB, KMS, and similar services. CloudFront still works because it reads the bucket through Origin Access Control in the same account.

Why it matters:Guardrails like these stop mistakes before they happen. A bucket can't end up in a region nobody monitors, and data can't be read from outside the Organization even if a policy is misconfigured later. The trade-off is that you have to know which region does what.

One harmless oddity: the ACM console may show an acm-pca:ListCertificateAuthorities "access denied" message. That's because private certificate authorities are blocked. Public certificates, which are all this site needs, still work fine.

Step 1: The domain

I registered the domain through Route 53, AWS's DNS service, with WHOIS privacy protection enabled so my contact details aren't publicly available.

Registering the domain automatically creates a public hosted zone. That's the place where the domain's DNS records live, and it starts with two records: NS (name servers) and SOA.

Why it matters:The name servers on the domain registration must match the NS record in the hosted zone. If they don't match, the internet asks the wrong servers for your site's location, and nothing you add to the hosted zone will work.

Step 2: A private S3 bucket

S3 stores the site's files. I created a general-purpose bucket in us-east-2 with these settings:

  • Block all public access: on. Nobody can read the bucket directly.
  • Object ownership: bucket owner enforced (ACLs disabled). Access is controlled by a single bucket policy rather than per-file permissions.
  • Versioning: on. If a deploy overwrites or deletes something, the old version can be restored.
  • Default encryption: SSE-S3. Files are encrypted at rest. I chose S3-managed keys over KMS because KMS costs extra and would need its own permissions for CloudFront.
  • Object Lock: off. It's meant for compliance retention, and it would get in the way of normal deploys.
  • Static website hosting: off. That feature needs a public bucket, and CloudFront serves the site instead.

The bucket holds the pages (index.html, resume.html, and so on) plus the css/, js/, and assets/ folders. Repo-only files like .git/, README.md, and the Terraform code never get uploaded.

Why it matters:A public bucket is one of the most common ways data leaks on AWS. Keeping it private and letting only CloudFront in removes that risk. You also get HTTPS and caching, which S3's website feature can't do on a custom domain.

Step 3: A free TLS certificate

HTTPS needs a certificate. AWS Certificate Manager (ACM) issues public certificates for free when you use them with CloudFront. I requested one in us-east-1, because CloudFront only accepts certificates from that region:

  • Names: example.com and www.example.com
  • Validation: DNS
  • Key: RSA 2048
  • Export: disabled

For DNS validation, ACM gives you a CNAME record for each name. Once those records exist in the Route 53 hosted zone, ACM confirms you own the domain and changes the status to Issued. Wait for that before moving on.

Why it matters:ACM renews the certificate automatically as long as the CNAME records stay in place and the certificate is in use (attached to CloudFront). You never have to remember to renew the certificate.

Step 4: CloudFront

CloudFront is AWS's content delivery network. It sits in front of the bucket, serves the site over HTTPS, and caches copies of the files in locations around the world. The settings that matter:

  • Origin: the bucket's REST endpoint, example-site.s3.us-east-2.amazonaws.com. Not the "website" endpoint, which only works for public buckets.
  • Origin Access Control (OAC): on. CloudFront signs its requests to S3, and the bucket policy allows reads only from this one distribution.
  • Viewer protocol policy: redirect HTTP to HTTPS.
  • Alternate domain names: example.com and www.example.com, using the certificate from step 3.
  • Default root object: index.html, so visiting the bare domain shows the home page.
  • Custom error responses: 403 and 404 both show /404.html with a 404 status. A private bucket returns "403 Forbidden" for files that don't exist, so the 403 mapping catches most broken links.
  • WAF: off. It's a paid add-on, and a static site with no forms or logins has little for it to protect.
  • Pricing: pay-as-you-go. The always-free tier covers 1 TB of transfer and 10 million requests a month, far more than a personal site uses.

Every page is a flat file, such as resume.html, not a folder like /resume/. CloudFront only adds index.html at the root, so flat files mean I don't need a CloudFront Function to rewrite URLs.

Why it matters:OAC is what allows the bucket to remain private while still serving the site. Without it, you'd have to make the bucket public, and you'd be back to the risk from step 2.

Step 5: Pointing the domain at CloudFront

In the Route 53 hosted zone, I added two A record sets as aliases to the CloudFront distribution: one for example.com and one for www.example.com.

Why it matters:An alias record is Route 53's way of pointing a domain straight at an AWS resource. Unlike a regular CNAME, it works on the bare domain (example.com, not just www), and Route 53 doesn't charge for alias queries to CloudFront.

Step 6: Deploying updates

Publishing a change takes two commands. The first uploads new and changed files and removes files I've deleted locally, while skipping repo-only files:

aws s3 sync . s3://example-site --delete \
  --exclude ".git/*" --exclude "README.md" --exclude "DESIGN.md" \
  --exclude "infra/*"

The second tells CloudFront to drop its cached copies so visitors see the update right away:

aws cloudfront create-invalidation --distribution-id YOUR_DISTRIBUTION_ID --paths "/*"

Why it matters:Without the invalidation, CloudFront can keep serving the old version until its cache expires. The --exclude flags keep private files, like Terraform state, off the public site.

Step 7: Automatic deploys with GitHub Actions

Instead of running those commands by hand, every push to the main branch now deploys the site in about a minute. GitHub Actions signs in to AWS with OpenID Connect (OIDC), so no AWS access keys are stored anywhere:

git push (main)
  → GitHub Actions requests a signed OIDC token
  → AWS STS checks it against the IAM role's trust policy
  → temporary credentials (1 hour max)
  → sync files to S3, then invalidate CloudFront

Why OIDC instead of access keys

The common shortcut is an IAM user whose access key sits in GitHub Secrets. Those keys never expire, so a leak through a log, a fork, or a compromised account means lasting access to your AWS account. With OIDC, nothing sensitive is stored in GitHub, credentials expire within an hour, only one repository and branch can use the role, and every sign-in is logged in CloudTrail.

The setup

  1. Trust GitHub in IAM. Add an OpenID Connect identity provider with the URL https://token.actions.githubusercontent.com and the audience sts.amazonaws.com. On its own, this grants nothing.
  2. Create a deploy role with a strict trust policy. Two conditions do the work: aud proves the token was issued for AWS, and sub pins it to one repository and branch:
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:OWNER/REPO:ref:refs/heads/main"
      }
    }
    Some GitHub accounts put numeric IDs in sub, like repo:OWNER@OWNER_ID/REPO@REPO_ID:ref:refs/heads/main. Those IDs never change, so a re-registered username or repo name won't match. If the first run fails, find the AssumeRoleWithWebIdentity event in CloudTrail and copy its userName value into the policy.
  3. Grant only what the deploy needs: s3:ListBucket on the bucket, s3:PutObject and s3:DeleteObject on its objects, and cloudfront:CreateInvalidation on the one distribution. The role can't touch anything else in the account.

The workflow

.github/workflows/deploy.yml:

name: Deploy site
on:
  push:
    branches: [main]

permissions:
  id-token: write   # lets the workflow request an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME
          aws-region: us-east-2
      - run: |
          aws s3 sync . s3://example-site --delete \
            --exclude ".git/*" --exclude ".github/*" --exclude "README.md" \
            --exclude "DESIGN.md" --exclude "infra/*"
      - run: aws cloudfront create-invalidation --distribution-id YOUR_DISTRIBUTION_ID --paths "/*"

Without id-token: write, GitHub won't issue the OIDC token and the sign-in fails.

Why it matters:A deploy pipeline is a door into your AWS account. OIDC keeps the key out of GitHub entirely, and the trust policy plus least-privilege permissions mean the workflow can only update one bucket and clear one cache.

Step 8: What it costs

ItemCost
Route 53 hosted zone$0.50 / month
S3 storage and requestsA few cents / month
CloudFront (under the free tier)$0
ACM public certificate$0
Domain registrationAbout $15 / year

So the site costs about $1 a month to run, and the domain is a separate yearly fee on top of that.

To make sure a mistake can't run up a bill, I set an AWS Budgets alert at $5 a month. It lives in my Organization's management account, which sees spending across all member accounts.

Why it matters:A budget alert is the cheapest insurance on AWS. If something unexpected starts costing money, you hear about it in days instead of at the end of the month.

Problems I hit

1. S3 was blocked in us-east-1

My first attempt at creating the bucket was in us-east-1, where many tutorials start. It was denied. The cause was my Organization's region-restriction SCP, which allows only global services in that region. Fix: I created the bucket in us-east-2. The certificate still goes in us-east-1, because ACM is one of the allowed global services there.

2. The first certificate was missing www

My first certificate only covered example.com. CloudFront requires the certificate to cover every alternate domain name you add, so www.example.com couldn't be used with it. Fix: ACM certificates can't be edited to add names. I requested a new certificate with both names, validated it, and swapped it into CloudFront.

3. The validation records weren't created

Requesting a certificate doesn't create validation records for it. ACM generates the CNAMEs, but until they are in DNS, the certificate remains "Pending validation" and can't be attached to CloudFront. Fix: Add the CNAMEs to the Route 53 hosted zone. The ACM console's Create records in Route 53 button does it in one click. Then wait for the status to change to Issued.

Doing it again with Terraform

I first built everything by clicking through the AWS console, which is a good way to learn what each setting does. Afterward, I described the same setup in Terraform, in the infra/ folder of this site's repository. It covers the bucket, the certificate and its validation records, CloudFront with OAC, and the DNS records.

With the setup written as code, the whole thing can be rebuilt with a single terraform apply, and any change to it shows up in version control.