Setup and deploy: from empty AWS to your own domain
This walks through everything from an empty AWS account to a website
running live on your own domain, deploying automatically on every push.
The steps apply to any static site (Astro, a Next.js static export, Hugo…)
deployed with the S3 + CloudFront + GitHub Actions pattern — not just this
repo. The examples use the domain obiz-solution.com for illustration;
swap in your own domain wherever you see YOUR_DOMAIN. If IAM/Role/OIDC
concepts are unfamiliar, read What is AWS IAM? first.
Target architecture
Route 53 (DNS) → CloudFront (CDN + SSL) → S3 (origin, private)
↑
ACM (SSL certificate)
GitHub Actions --(OIDC, no stored keys)--> IAM Role --> sync S3 + invalidate CloudFront
The domain’s nameservers move to Route 53 so the apex domain
(YOUR_DOMAIN itself, not a subdomain) can point straight at CloudFront
via an ALIAS record — the standard, reliable way to do this with
CloudFront. You keep ownership of the domain at your original registrar
(Namecheap, GoDaddy…); only the nameservers change.
What you need
- AWS Console access (an IAM user with temporary admin rights for setup — not what deploys will use going forward).
- Access to the domain registrar account (Namecheap or similar) to change nameservers.
- A domain you own, e.g.
YOUR_DOMAIN(illustrated here asobiz-solution.com). - A GitHub repo
GITHUB_ORG/REPO(illustrated astranvinhvu/obiz-solution) with the site’s code and a build step (npm run buildproducingdist/) already in place.
Step 1 — Create the S3 bucket (private origin)
This bucket does not enable Static website hosting and is not public — CloudFront reads it directly through Origin Access Control (OAC), so the bucket can stay fully private, which is safer.
- S3 Console → Create bucket
- Bucket name:
YOUR_BUCKET— doesn’t have to match the domain, but naming it after the domain (e.g.obiz-solution.com) makes it easy to identify - Region: any region you’re comfortable with (e.g.
ap-southeast-1) — doesn’t affect speed since CloudFront caches globally - Block all public access: leave it ON — that’s intentional, since CloudFront will be the only thing allowed to read the bucket
- Skip Static website hosting — not needed
aws s3api create-bucket \
--bucket YOUR_BUCKET \
--region YOUR_REGION \
--create-bucket-configuration LocationConstraint=YOUR_REGION
Step 2 — Request an SSL certificate in ACM
CloudFront only accepts ACM certificates from the us-east-1 (N. Virginia) region, regardless of which region the bucket or distribution live in.
- Switch to the US East (N. Virginia) region in the console
- ACM → Request certificate → Public certificate
- Domain names: add both
YOUR_DOMAINwww.YOUR_DOMAIN
- Validation method: DNS validation (recommended, auto-renews)
- Once created, ACM gives you a CNAME record (name + value) per domain — you’ll add these to real DNS to prove ownership. That happens in Step 4, after DNS moves to Route 53.
- The certificate stays Pending validation until the CNAME record is detected — anywhere from a few minutes to a few hours.
Step 3 — Create a Route 53 hosted zone and switch nameservers
Back up every existing record at your registrar before switching nameservers. A freshly created Route 53 hosted zone is completely empty — any existing records (MX for email, a TXT verifying Google Workspace, a CNAME for SSL certificate validation, records for other subdomains…) stop working the moment the nameservers change, because resolvers no longer ask the old registrar at all. In Namecheap, go to Advanced DNS and screenshot (or copy to a file) the full Host Records table and the Mail Settings tab (MX is often managed separately there, not shown under Host Records) before doing step 4 below. Skipping this is the most common reason a domain stops receiving email right after moving DNS to AWS.
- Route 53 Console → Hosted zones → Create hosted zone
- Domain name:
YOUR_DOMAIN, Type: Public hosted zone - Route 53 generates 4 NS (nameserver) records — copy all 4 values
(they look like
ns-xxx.awsdns-xx.com, etc.) - Log into the domain registrar account (Namecheap…) → find
YOUR_DOMAIN→ the Nameservers section → select Custom DNS → paste in the 4 Route 53 nameservers (replacing the registrar’s defaults) - Save. DNS propagation can take anywhere from a few minutes to 24-48 hours (usually much faster in practice).
Check it’s pointed correctly:
Linux / macOS:
dig NS YOUR_DOMAIN +short
# should show 4 lines like ns-xxxx.awsdns-xx.{com,net,org,co.uk}
Windows (PowerShell) — dig isn’t available on Windows by default, use:
Resolve-DnsName -Name YOUR_DOMAIN -Type NS
# the NameHost column should show 4 lines like ns-xxxx.awsdns-xx.{com,net,org,co.uk}
Don’t want to move DNS to Route 53? Most domain registrars (Namecheap included) don’t support a real CNAME/ALIAS at the apex domain, so you could only point
www.YOUR_DOMAINvia CNAME to CloudFront, whileYOUR_DOMAIN(no www) would have to rely on the registrar’s “URL Redirect” feature to forward to thewwwversion — less reliable (doesn’t preserve HTTPS as cleanly, depends on their own redirect service). Route 53 ALIAS in Step 6 is AWS’s officially recommended approach and what this guide uses.
Step 4 — Add the SSL validation records to Route 53
Once the hosted zone is live (nameservers pointed correctly):
- Back in ACM → the certificate you created → each domain has a Create records in Route 53 button — click it to have ACM add the validation CNAME record straight into the right hosted zone
- Wait for the certificate status to flip from Pending validation to Issued (usually a few minutes after DNS is correctly pointed)
Step 5 — Create the CloudFront distribution
- CloudFront Console → Create distribution
- Origin domain: select the
YOUR_BUCKETS3 bucket from the list - Origin access: choose Origin access control settings (recommended) → create a new OAC → CloudFront shows a sample bucket policy, copy it
- Viewer protocol policy: Redirect HTTP to HTTPS
- Alternate domain name (CNAME): add both
YOUR_DOMAINwww.YOUR_DOMAIN
- Custom SSL certificate: select the ACM certificate that’s now Issued (Step 4)
- Default root object:
index.html - Create the distribution — global rollout takes roughly 5–15 minutes (status moves from “Deploying” to “Enabled”)
Required, or every page except
/returns403 Access Denied. “Default root object” above only applies to the literal root URL/— every other path (/about/,/vi/,/blog/hello-world/…) gets forwarded to S3 as-is, but the bucket only has an object likeabout/index.html, not one literally namedabout/orabout, so S3 errors out (and because the bucket policy only grantss3:GetObject, nots3:ListBucket, the error surfaces as403instead of404). Astro (and most static site generators) outputs exactly thispath/index.htmlstructure for every page, so the site will hit this unless you do the following:
- CloudFront Console → Functions → Create function, name it e.g.
rewrite-index-html- Paste this into the Build tab:
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; }- Save → Publish
- Go to the distribution → Behaviors tab → select the default (
*) behavior → Edit → Function associations → Viewer request → CloudFront Functions → pick the function you just created → Save changes- Wait for the distribution to redeploy (a few minutes) before testing any route other than
/
Step 6 — Attach the bucket policy that lets CloudFront read it
Go to the S3 bucket → Permissions → Bucket policy → paste the policy CloudFront suggested in Step 5 (it already fills in the bucket and distribution ARNs):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
}
]
}
This is the OAC mechanism: only this specific CloudFront distribution can read the bucket — nobody else, not even someone who knows the raw S3 URL.
Step 7 — Point the domain at CloudFront (Route 53 ALIAS)
In Route 53 → the YOUR_DOMAIN hosted zone → Create record:
Record 1 — apex domain:
- Record name: leave blank (meaning
YOUR_DOMAINitself) - Record type: A
- Alias: toggle ON
- Route traffic to: Alias to CloudFront distribution → pick the one you just created
Record 2 — www:
- Record name:
www - Record type: A
- Alias: ON, pointing to the same CloudFront distribution
ALIAS differs from CNAME in that it works at the apex domain (no prefix required) and isn’t billed per query — this is exactly why Route 53 is needed instead of a plain CNAME at the registrar.
Step 8 — Create the OIDC provider + IAM role for GitHub Actions
This is what lets GitHub Actions deploy without ever storing an AWS access key — see the mechanism explained in What is AWS IAM?.
- IAM → Identity providers → Add provider (skip if the account
already has a GitHub provider):
- Provider type: OpenID Connect
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
- IAM → Roles → Create role → Trusted entity type: Web
identity → pick the provider above, audience
sts.amazonaws.com - After creating the role, edit its Trust policy to scope it to this
exact repo and the
mainbranch. Start with this plain form (then do Step 3b right below — you’ll almost certainly need to revisit it):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:GITHUB_ORG/REPO:ref:refs/heads/main"
}
}
}
]
}
Step 3b — almost certainly needed. GitHub appends the owner’s and repository’s permanent numeric IDs to the
subclaim (asrepo:org@OWNER_ID/repo@REPO_ID:ref:...) as an anti-impersonation safeguard whenever the account or repo has ever been renamed — if your account falls into that bucket, the real value won’t match the plain format above, andAssumeRoleWithWebIdentitygets rejected even though everything else is configured correctly (the error looks likeNot authorized to perform sts:AssumeRoleWithWebIdentity).Add this debug step early in the workflow to see the exact
subvalue the token actually sends (no need to wait for the build to finish):- name: Debug OIDC token claims run: | JWT=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value') PAYLOAD=$(echo -n "$JWT" | cut -d '.' -f2 | tr '_-' '/+') case $(( ${#PAYLOAD} % 4 )) in 2) PAYLOAD="${PAYLOAD}==";; 3) PAYLOAD="${PAYLOAD}=";; esac echo "$PAYLOAD" | base64 -d | jq '{iss, aud, sub}'Push, check this step’s output in the Actions tab, then copy the printed
subvalue exactly (it may look likerepo:org@12345/repo@67890:ref:refs/heads/main) and paste it into the trust policy — keep usingStringEqualswith that exact value, not a wildcard (*). The AWS IAM console itself recommends against wildcards insub(“Specific GitHub Repo And Branch Recommended”) — since only one repo ever needs this role, and you now know the exact value, an exact match is both the safest option and clears every console warning.
- Attach a permissions policy to the role, scoped to the exact bucket
and distribution created above (replace
ACCOUNT_ID,YOUR_BUCKET,DISTRIBUTION_ID):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET",
"arn:aws:s3:::YOUR_BUCKET/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
- Copy the role ARN (looks like
arn:aws:iam::ACCOUNT_ID:role/role-name) — needed for the next step.
Step 9 — Add the GitHub secrets
GITHUB_ORG/REPO repo → Settings → Secrets and variables →
Actions → New repository secret, add these 4:
| Secret | Value |
|---|---|
AWS_ROLE_ARN | the role ARN copied in Step 8 |
AWS_REGION | the bucket’s region, e.g. ap-southeast-1 |
AWS_S3_BUCKET | YOUR_BUCKET |
AWS_CLOUDFRONT_DISTRIBUTION_ID | the distribution ID (in the CloudFront console) |
The deploy workflow (.github/workflows/deploy.yml) uses these 4 secrets
exactly as-is to build, aws s3 sync, and
cloudfront create-invalidation — nothing else to change if the workflow
already follows that pattern.
Step 10 — Deploy and verify
git push origin main
- Watch the Actions tab on GitHub — the deploy workflow runs: install
dependencies, build the site,
aws s3 sync,cloudfront create-invalidation - Once it’s green, open https://YOUR_DOMAIN — check the HTTPS
padlock (a valid ACM certificate), and also try
https://www.YOUR_DOMAIN - If the page doesn’t look updated right away, wait a minute or two — the CloudFront invalidation needs time to propagate to edge locations
Common issues
dig NS/Resolve-DnsNameshows nothing from Route 53 yet — DNS hasn’t propagated; wait, or double-check the nameservers saved correctly at the registrar- ACM certificate stuck on “Pending validation” — check whether the validation CNAME record actually landed in the Route 53 hosted zone (Step 4)
403 Access Deniedonly on/, every other page fails — missing the CloudFront Function URI rewrite from Step 5, the most common cause of this particular pattern403 Access Deniedeven on the home page — the bucket policy has the wrong distribution ARN, or the OAC isn’t attached to the right origin (Step 6)- Creating the
wwwALIAS record in Step 7 shows “no record found”, the CloudFront distribution isn’t in the dropdown — Route 53 only offers a distribution as an alias target if that exact domain is already listed in the distribution’s Alternate domain name (CNAME). Go back to CloudFront and check the distribution has bothYOUR_DOMAINandwww.YOUR_DOMAINunder Alternate domain name, add the missing one, wait for status to return to “Enabled”, then try again - CloudFront still says “Deploying” — normal, can take up to 15 minutes after creation or after a config change
- GitHub Actions fails with
Not authorized to perform sts:AssumeRoleWithWebIdentity— most often the trust policy’ssubvalue doesn’t match: wrong repo name, wrong branch, or forgetting that GitHub appends numeric IDs tosubonce the account or repo has been renamed (see Step 3b in Step 8). Use the OIDC token debug step to see exactly what the token sends, then paste that exact value into the trust policy. Also double-check the OIDC identity provider actually exists in the right account, and that theAWS_ROLE_ARNsecret points at the right role
Worked example: this site (Obiz Solutions) is deployed exactly this way,
with YOUR_DOMAIN = obiz-solution.com, YOUR_BUCKET = obiz-solution.com,
and GITHUB_ORG/REPO = tranvinhvu/obiz-solution — see
Obiz Solutions — this very site for more.