You already know how to build. The hard part is deploying it right.
If you're a developer, running your own static site is one of the easiest ways to get something online while staying in your comfort zone – building, writing, and shipping.
The harder part is putting it somewhere fast and reliable without signing up to manage a server. I keep coming back to AWS S3 paired with CloudFront. S3 stores the generated files. CloudFront serves them over HTTPS from edge locations and keeps the bucket off the public internet.
It looks intimidating the first time and stops being so after you've done it once. The tradeoff is an hour or two of setup and a handful of AWS settings you need to understand. This guide walks through those settings end-to-end.
One architecture choice matters before everything else: this guide uses a private S3 REST origin with CloudFront Origin Access Control (OAC). It does not enable S3 website hosting. S3 website endpoints are public custom origins and cannot use OAC.
Why static sites – and why CloudFront + S3?
Every static-site generator finishes with a folder of HTML, CSS, JavaScript, images, and other assets. The folder name changes, but the deployment pattern does not:
| Generator | Common output directory |
|---|---|
| Next.js static export | out/ |
| Hugo | public/ |
| Astro | dist/ |
| Jekyll | _site/ |
| Eleventy | _site/ |
| Angular | dist/ |
AWS does not care which tool produced the files. You build locally, upload that directory to S3, and put CloudFront in front of it.
That gives you:
- No server process, database, or runtime to patch
- A private origin that viewers cannot bypass
- Global caching and HTTPS
- Deployments that can be reproduced from a terminal or CI runner
- Storage and transfer costs based on use instead of a fixed server
It works well for blogs, documentation, portfolios, marketing pages, and other sites whose request-time work can be moved into the build.
Step 1: Build and inspect the output
Run the production build and look at what it generated before touching AWS.
pnpm build
find out -maxdepth 2 -type f | sort | head -50
Replace out with your generator's output directory. At minimum, confirm that the directory contains index.html and every asset the page references.
Pay attention to how page files are laid out:
| Public page | Flat output | Nested-index output |
|---|---|---|
/about | about.html | about/index.html |
/blog/post | blog/post.html | blog/post/index.html |
You will use that answer when you configure pretty URLs. Also look for intentional files without extensions:
find out -type f ! -name '*.*' -print
An extensionless image or download needs two bits of care later: the URL rewrite must leave it alone, and the S3 object must have the correct Content-Type metadata.
Step 2: Secure the AWS account
The first login to a new AWS account is the root user. Root can change payment details, close the account, and bypass permissions you create later, so treat it as break-glass access.
- Store the root password in a password manager.
- Enable MFA on the root user.
- Save the recovery method somewhere separate.
- Do not create root access keys.
AWS's own root-user guidance is worth reading once. After this setup, daily console and CLI access should use temporary credentials through IAM Identity Center.
Pick the workload Region
Choose an S3 Region near your audience or one that meets your regulatory requirements. There is no blanket reason to avoid us-east-1.
CloudFront is global, but a few supporting services have fixed regional requirements. The important exception in this guide is the viewer certificate: an ACM certificate attached to CloudFront must be requested or imported in US East (N. Virginia), us-east-1, even if the S3 bucket lives somewhere else.
Understand the new-account tradeoff first
IAM Identity Center needs an organization instance to assign permission sets to an AWS account. An account instance can assign access to applications, but it cannot provide sign-in access to the AWS account itself.
That has a billing consequence for newer accounts. If your account is on the AWS Free account plan, enabling an organization instance creates an AWS Organization, upgrades the account to the paid account plan, and ends its remaining Free Tier credits. AWS calls this out in both the Identity Center setup documentation and the Free Tier FAQ.
"Paid account plan" does not mean a monthly support subscription. It means the account can use the full service catalog and pays the normal rates for what it consumes. Still, losing credits is irreversible, so decide before clicking through. This guide assumes you are deploying a real site and choose the organization instance.
Create and assign the Identity Center user
- Open IAM Identity Center in the Region where you want Identity Center configured and enable the organization instance.
- Create your user and complete the email activation.
- Create an
Administratorsgroup and add the user. - Under Multi-account permissions → Permission sets, create a predefined
AdministratorAccesspermission set for initial setup. - Go to AWS accounts, select the management account, choose Assign users or groups, select the group, and then select the permission set.
Creating a user and creating a permission set are not enough. The account assignment is what provisions the role and makes the account appear in the access portal.
Save the AWS access portal URL, sign out of root, and confirm that the new user can enter the account. If you need billing access, create and assign the predefined Billing permission set as well and enable IAM access to Billing in the account settings. Once the site works, replace AdministratorAccess for routine deployments with a permission set scoped to this bucket and distribution.
Step 3: Create the private S3 bucket
Open S3 and create a general purpose bucket. I prefer one bucket per site because it makes policies and sync --delete much harder to aim at the wrong project.
Use these settings:
- Region: the workload Region you chose above
- Block all public access: enabled
- Object Ownership: Bucket owner enforced
- Default encryption: SSE-S3
- Object Lock: disabled unless you have a specific retention requirement
Every new S3 object is encrypted at rest with SSE-S3 by default. You can explicitly select it to make the intent clear. Do not enable an S3 Bucket Key with SSE-S3: Bucket Keys reduce KMS request costs and apply to SSE-KMS, not SSE-S3. AWS documents the distinction in its default-encryption guide.
Do not enable Static website hosting. That creates a website endpoint, which would force CloudFront to treat S3 as a public custom origin. The regular S3 endpoint is what lets CloudFront authenticate with OAC.
Leave the bucket empty for now. CloudFront can create the read policy after the distribution exists.
Step 4: Create the CloudFront distribution
Open CloudFront, choose Create distribution, give it a name, and choose Single website or app. That creates a standard distribution, which is the right shape for one site.
Separate distribution type from pricing
CloudFront now presents two choices that are easy to conflate:
- Standard distribution describes the CloudFront resource. It is the single-site alternative to a multi-tenant SaaS distribution.
- Pay-as-you-go or a flat-rate pricing plan describes how that distribution and related services are billed.
Pay-as-you-go bills CloudFront, WAF, logging, and DNS separately and leaves WAF optional. Flat-rate plans come in Free, Pro, Business, and Premium tiers and bundle a distribution with allowances for CloudFront, WAF, bot management, Route 53, log ingestion, and S3 storage credits. AWS says there are no overage charges, although sustained use above an allowance can eventually change how traffic is delivered.
For a low-traffic personal site, either pay-as-you-go without WAF or an eligible flat-rate Free plan can be reasonable. Read the live flat-rate plan table and restrictions instead of choosing by the word "free."
The catches matter:
- AWS accounts currently using the account-level Free Tier are not eligible for CloudFront flat-rate plans.
- A flat-rate plan requires an attached WAF web ACL.
- Some features, including real-time access logs and shared CloudFront Functions, are incompatible with flat-rate plans.
- Lambda@Edge, some logging destinations and formats, and other features can still create separate charges.
- On pay-as-you-go, WAF and Bot Control are separate metered services.
Configure the origin
On the origin page:
- Choose Amazon S3 as the origin type.
- Use Browse S3 and select the bucket – not a URL containing
s3-website. - Choose Use recommended origin settings.
- Confirm the origin uses Origin access control settings (recommended) and signs requests.
The current wizard creates an OAC and updates the bucket policy. After creation, review the policy anyway. It should allow the cloudfront.amazonaws.com service principal to call s3:GetObject, with an AWS:SourceArn condition scoped to this distribution. AWS provides the exact read-only OAC policy shape.
For the default behavior, use:
- Viewer protocol policy: Redirect HTTP to HTTPS
- Allowed methods: GET and HEAD
- Compression: enabled
- Cache policy: the recommended caching policy to start
Set the distribution's Default root object to index.html. That setting handles / only. CloudFront explicitly does not apply it to subdirectories, which is why /about/ still needs the function later.
If the wizard asks about WAF security protections, make the choice that matches the pricing model above. On pay-as-you-go, the console shows a price estimate; leaving WAF off is a valid starting point for a private-origin static site with no forms, login, or API. You can add it after looking at real traffic.
Create the distribution and wait until its status is deployed. Save both the distribution ID and its d123example.cloudfront.net domain.
Step 5: Install AWS CLI v2 and configure SSO
Use AWS CLI version 2. On macOS and Windows, use the official package installer linked from the AWS CLI installation guide. On Linux, AWS supports its bundled x86-64 and ARM installers plus the official Snap package; it does not guarantee that other distributions' repositories contain the latest version.
For x86-64 Linux:
tmpdir="$(mktemp -d)"
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
-o "$tmpdir/awscliv2.zip"
unzip -q "$tmpdir/awscliv2.zip" -d "$tmpdir"
sudo "$tmpdir/aws/install"
rm -rf "$tmpdir"
aws --version
For ARM Linux, use the awscli-exe-linux-aarch64.zip package shown in the same AWS guide. For a production workstation, follow AWS's signature-verification steps before installing the download.
Now configure the named SSO profile you will use for this site:
aws configure sso
The prompts ask for:
| Prompt | Value |
|---|---|
| SSO session name | A local label, such as personal-aws |
| SSO start URL | The access portal URL saved earlier |
| SSO Region | The Region where IAM Identity Center is configured |
| Registration scopes | Accept sso:account:access |
| AWS account and role | Select the assigned account and permission set |
| Default client Region | Your S3 workload Region |
| CLI profile name | A clear name, such as personal-site |
Authenticate and confirm the identity before deploying:
aws sso login --profile personal-site
aws sts get-caller-identity --profile personal-site
Step 6: Deploy the build
Set variables once so the same directory, bucket, distribution, and profile appear in every command:
export BUILD_DIR="out"
export SITE_BUCKET="your-site-bucket"
export DISTRIBUTION_ID="E123EXAMPLE"
export AWS_PROFILE="personal-site"
Change BUILD_DIR if your generator uses dist, public, or _site. Then preview the sync:
test -f "$BUILD_DIR/index.html"
aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \
--delete \
--dryrun \
--profile "$AWS_PROFILE"
--delete removes remote files that no longer exist in the build. That is useful for a dedicated site bucket and dangerous if you point at a shared bucket. Read the dry run, then remove --dryrun:
aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \
--delete \
--profile "$AWS_PROFILE"
The AWS CLI infers metadata from file extensions. If the earlier scan found an extensionless asset, upload it again with its real MIME type. For example:
aws s3 cp "$BUILD_DIR/icon" "s3://$SITE_BUCKET/icon" \
--content-type "image/png" \
--profile "$AWS_PROFILE"
Open https://d123example.cloudfront.net/ and a direct file such as /about.html or /about/index.html. Clean page URLs come next.
Step 7: Add one pretty-URL function
A private S3 REST origin serves object keys. It does not turn /about into about.html, and CloudFront's default root object does not turn /about/ into about/index.html.
A viewer-request CloudFront Function can perform that mapping before CloudFront checks its cache or asks S3. The function must know which layout your generator produced; it cannot inspect the bucket to guess.
In CloudFront, open Functions, create a function named pretty-urls, and paste this:
var OUTPUT_STYLE = 'flat'; // Use 'flat' or 'nested'.
// Add every real object that intentionally has no file extension.
var EXTENSIONLESS_FILES = {
'/icon': true,
'/apple-icon': true
};
function handler(event) {
var request = event.request;
if (request.method !== 'GET' && request.method !== 'HEAD') {
return request;
}
var uri = request.uri;
// The distribution's default root object handles the site root.
if (uri === '/') {
return request;
}
// Treat /about and /about/ the same, and avoid double extensions.
var cleanUri = uri.replace(/\/+$/, '');
if (EXTENSIONLESS_FILES[cleanUri]) {
request.uri = cleanUri;
return request;
}
var lastSegment = cleanUri.substring(cleanUri.lastIndexOf('/') + 1);
if (lastSegment.indexOf('.') !== -1) {
request.uri = cleanUri;
return request;
}
if (OUTPUT_STYLE === 'nested') {
request.uri = cleanUri + '/index.html';
} else {
request.uri = cleanUri + '.html';
}
return request;
}
Set OUTPUT_STYLE to flat for about.html output or nested for about/index.html output. Replace the example extensionless-file list with the objects your build produced. If you forget one, a request for /icon becomes /icon.html or /icon/index.html and fails even though the object exists.
Use the function's test tab for /about, /about/, an asset such as /styles.css, every extensionless object, and a missing page. Then:
- Save and publish the function.
- Open the distribution's default behavior.
- Under Function associations, attach it to Viewer request.
- Save and wait for the distribution update to deploy.
Both /about and /about/ now resolve to the same S3 object without changing the URL in the browser. Your page's canonical metadata should name the public form you want search engines to keep.
Step 8: Configure real 404s
Most generators emit a 404.html file. Upload it with the rest of the build, then open the distribution's Error pages tab and create two custom error responses:
| Origin error | Response page path | Viewer response | Error-cache minimum TTL |
|---|---|---|---|
| 403 | /404.html | 404 | 10 seconds |
| 404 | /404.html | 404 | 10 seconds |
Map both. With a private S3 origin that can read objects but cannot list the bucket, a missing key commonly arrives at CloudFront as 403. Other origins or configurations can return 404. Keep the viewer response code at 404 – returning the error page as 200 creates soft 404s.
CloudFront lets you choose the page, response code, and error-cache TTL in its custom error response settings. Remember that a 403 can also mean a broken bucket policy or a WAF block, so troubleshoot a sudden wave of them instead of assuming every one is a missing page.
Now test the full path matrix:
curl -I https://d123example.cloudfront.net/
curl -I https://d123example.cloudfront.net/about
curl -I https://d123example.cloudfront.net/about/
curl -I https://d123example.cloudfront.net/styles.css
curl -I https://d123example.cloudfront.net/icon
curl -I https://d123example.cloudfront.net/definitely-not-a-page
Expect 200 for real pages and assets, the correct Content-Type for each asset, and 404 for the last request.
Step 9: Add the custom domain and certificate
If the domain is in Route 53, the current CloudFront wizard can automate part of this. For any other DNS provider, the manual path is still short:
- Switch the AWS console to US East (N. Virginia),
us-east-1. - In AWS Certificate Manager, request a public certificate for the domain names you will use, such as
example.comandwww.example.com. - Add ACM's DNS validation records at your DNS provider and wait for the certificate to become issued.
- Add those names as alternate domain names on the CloudFront distribution and select the certificate.
- Point DNS at the CloudFront domain. Use a CNAME for a subdomain or your provider's alias/flattening feature for the apex domain.
The regional requirement is easy to miss: CloudFront only accepts ACM viewer certificates from us-east-1. The S3 bucket does not need to move there.
Test both HTTP and HTTPS after DNS propagates. HTTP should redirect to HTTPS, and the certificate should cover every hostname that reaches the distribution.
Step 10: Add logging and bot controls deliberately
Neither feature needs to be enabled just because the console offers it.
Standard logging
CloudFront standard logging v2 can send selected fields to CloudWatch Logs, Firehose, or S3. For a small site, S3 is usually the least complicated destination.
If you enable it:
- Use a separate private log bucket or prefix.
- Select only fields you expect to use.
- Leave out
c-ip,x-forwarded-for, cookies, query strings, and referrers if you do not need them. - Add an S3 lifecycle rule so old logs expire; CloudFront does not delete them for you.
- Check the pricing for delivery, ingestion, storage, conversion, and queries.
There is no generic "anonymize IPs" switch to rely on. Standard logging v2 gives you field selection, so data minimization is the cleaner option.
WAF and bots
On a flat-rate plan, the plan requires and bundles a WAF web ACL within its documented limits. On pay-as-you-go, WAF rules, requests, CAPTCHA, and Bot Control can add charges; the CloudFront security screen provides an estimate.
If you enable Bot Control, start in monitor mode and look at the categories before blocking anything. AWS says verified common bots, such as validated search crawlers, are not subject to unverified-category actions. Unverified traffic is not automatically malicious, though: feed readers, link checkers, uptime monitors, command-line clients, and accessibility tools can all look non-browser-like.
That is why I would not begin by blocking every data-center request or CAPTCHA-challenging every non-browser user agent. Use robots.txt for cooperative crawlers, a rate-based WAF rule for demonstrated abuse, and category actions only after you know what they catch. AWS documents both the monitor-first WAF setup and the fact that Bot Control adds charges.
Step 11: Deploy updates and invalidate the cache
The normal update loop is build, verify, sync, invalidate:
set -euo pipefail
pnpm build
test -f "$BUILD_DIR/index.html"
aws sso login --profile "$AWS_PROFILE"
aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \
--delete \
--profile "$AWS_PROFILE"
# Repeat any extensionless-asset metadata uploads here.
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths '/*' \
--profile "$AWS_PROFILE"
The test keeps a failed or misconfigured build from syncing an empty directory with --delete. The invalidation tells CloudFront to fetch changed objects instead of waiting for their cached copies to expire.
If the build contains extensionless assets, keep their aws s3 cp metadata commands from Step 6 between the sync and invalidation on every deployment. A later sync that reuploads one of those objects can replace its metadata.
As the site grows, you can give hashed assets long immutable cache lifetimes, keep HTML on a shorter policy, and invalidate fewer paths. Start with the understandable version first.
Useful checks:
aws s3 ls "s3://$SITE_BUCKET/" --recursive --profile "$AWS_PROFILE"
aws cloudfront get-distribution \
--id "$DISTRIBUTION_ID" \
--profile "$AWS_PROFILE"
aws cloudfront list-invalidations \
--distribution-id "$DISTRIBUTION_ID" \
--profile "$AWS_PROFILE"
Wrapping up
Once this is in place, deploying a change is three operations: build, sync, invalidate. There is no server to patch and no runtime to monitor.
The parts worth remembering are the parts AWS's wizard can hide: use a regular private S3 origin with signed OAC requests, inspect your generator's output before writing URL rewrites, map both 403 and 404 errors, and request the CloudFront certificate in us-east-1. Everything after that is repetition you can safely turn into a script or CI workflow.