Skip to content
← BACK TO POSTS

Building Static Sites with AWS S3 and CloudFront

15 min read

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:

GeneratorCommon output directory
Next.js static exportout/
Hugopublic/
Astrodist/
Jekyll_site/
Eleventy_site/
Angulardist/

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.

Inspect the Static Export
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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 pageFlat outputNested-index output
/aboutabout.htmlabout/index.html
/blog/postblog/post.htmlblog/post/index.html

You will use that answer when you configure pretty URLs. Also look for intentional files without extensions:

Find Extensionless Files
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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.

  1. Store the root password in a password manager.
  2. Enable MFA on the root user.
  3. Save the recovery method somewhere separate.
  4. 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

  1. Open IAM Identity Center in the Region where you want Identity Center configured and enable the organization instance.
  2. Create your user and complete the email activation.
  3. Create an Administrators group and add the user.
  4. Under Multi-account permissions → Permission sets, create a predefined AdministratorAccess permission set for initial setup.
  5. 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:

  1. Choose Amazon S3 as the origin type.
  2. Use Browse S3 and select the bucket – not a URL containing s3-website.
  3. Choose Use recommended origin settings.
  4. 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:

Install AWS CLI v2 on x86-64 Linux
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Configure the AWS SSO Profile
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.aws configure sso

The prompts ask for:

PromptValue
SSO session nameA local label, such as personal-aws
SSO start URLThe access portal URL saved earlier
SSO RegionThe Region where IAM Identity Center is configured
Registration scopesAccept sso:account:access
AWS account and roleSelect the assigned account and permission set
Default client RegionYour S3 workload Region
CLI profile nameA clear name, such as personal-site

Authenticate and confirm the identity before deploying:

Verify the AWS Session
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Set Deployment Variables
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Preview the S3 Sync
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Upload the Static Export
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Set Metadata on an Extensionless PNG
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

CloudFront Function – Pretty URLs
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

  1. Save and publish the function.
  2. Open the distribution's default behavior.
  3. Under Function associations, attach it to Viewer request.
  4. 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 errorResponse page pathViewer responseError-cache minimum TTL
403/404.html40410 seconds
404/404.html40410 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:

Check CloudFront Responses
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

  1. Switch the AWS console to US East (N. Virginia), us-east-1.
  2. In AWS Certificate Manager, request a public certificate for the domain names you will use, such as example.com and www.example.com.
  3. Add ACM's DNS validation records at your DNS provider and wait for the certificate to become issued.
  4. Add those names as alternate domain names on the CloudFront distribution and select the certificate.
  5. 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:

Deploy a Site Update
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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:

Inspect the Deployment
Scrollable code sample. Focus the code block and use the arrow keys to review clipped lines.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.