How to Host a Static Website on S3: Step-by-Step Guide

Hosting a static website on Amazon S3 is one of the most cost-effective ways to serve HTML, CSS, and JavaScript files at scale — but the path from an empty bucket to a publicly accessible URL involves several configuration layers that trip up even experienced engineers. This guide walks through every required step to enable S3 static website hosting correctly, including the public access settings that silently block most first attempts.

TL;DR: S3 Static Website Hosting at a Glance

StepWhat You ConfigureWhy It Matters
1Create S3 bucketBucket name must match your domain if using Route 53
2Disable Block Public AccessAccount and bucket-level settings both block public policies by default
3Attach bucket policyGrants anonymous read access to objects
4Enable static website hostingActivates the HTTP endpoint and index/error document routing
5Upload your filesObjects must exist before the endpoint serves them
6Verify the endpointConfirm routing works before pointing DNS

How S3 Static Website Hosting Works

S3 exposes two distinct endpoint types for every bucket: the REST API endpoint (used for programmatic access, requires authentication by default) and the website endpoint (an HTTP-only endpoint that serves objects like a web server, supporting index document resolution and custom error pages). Enabling static website hosting activates the website endpoint and configures S3 to route requests for directory paths to your index document. The website endpoint URL follows one of two regional patterns depending on the region: http://<bucket-name>.s3-website-<region>.amazonaws.com or http://<bucket-name>.s3-website.<region>.amazonaws.com. Note that the website endpoint supports HTTP only — HTTPS requires CloudFront in front of S3.

graph LR Browser["Browser"] -->|"HTTP GET /"| WebEndpoint["S3 Website Endpoint"] WebEndpoint -->|"Check: hosting enabled?"| HostingCheck{"Static Website
Hosting Enabled?"} HostingCheck -->|"No"| Error1["HTTP 404 / Error"] HostingCheck -->|"Yes"| PolicyCheck{"Bucket Policy
Allows s3:GetObject?"} PolicyCheck -->|"No"| Error2["HTTP 403 Forbidden"] PolicyCheck -->|"Yes"| IndexCheck{"Path is
directory?"} IndexCheck -->|"Yes"| AppendIndex["Append index.html"] IndexCheck -->|"No"| FetchObject["Fetch Object from S3"] AppendIndex --> FetchObject FetchObject -->|"Object exists"| Response200["HTTP 200 OK"] FetchObject -->|"Object missing"| ErrorDoc["Serve error.html"]
  1. Browser sends an HTTP GET to the S3 website endpoint.
  2. S3 Website Endpoint checks if static website hosting is enabled on the bucket.
  3. S3 evaluates the bucket policy to confirm s3:GetObject is allowed for anonymous principals.
  4. If the request path is a directory (e.g., /), S3 appends the configured index document (e.g., index.html).
  5. S3 returns the object. If the object is missing, S3 serves the configured error document.

Step 1: Create the S3 Bucket

Create a bucket in the AWS region closest to your users. If you plan to use a custom domain with Route 53 and an S3 website redirect, the bucket name must exactly match the domain name. For this guide, we use my-static-site-example as the bucket name.

aws s3api create-bucket \
  --bucket my-static-site-example \
  --region us-east-1

For regions other than us-east-1, you must include the --create-bucket-configuration parameter:

aws s3api create-bucket \
  --bucket my-static-site-example \
  --region us-west-2 \
  --create-bucket-configuration LocationConstraint=us-west-2

Step 2: Disable Block Public Access Settings

This is where most engineers get stuck. AWS enables Block Public Access at both the account level and the bucket level by default. A bucket policy granting public access will be silently ignored if either layer is still blocking it. You must disable the relevant settings at both layers.

2a — Disable at the bucket level:

aws s3api put-public-access-block \
  --bucket my-static-site-example \
  --public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"

2b — Check and disable at the account level (if your account has account-level Block Public Access enabled):

aws s3control get-public-access-block \
  --account-id 123456789012

If any setting returns true, disable it:

aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"
Think of Block Public Access as a master circuit breaker sitting above your bucket policy. Even a perfectly written policy granting s3:GetObject to * will have no effect if the circuit breaker is still open. The bucket policy and Block Public Access are evaluated independently — both must permit the request.

Step 3: Attach a Bucket Policy for Public Read Access

With Block Public Access disabled, attach a bucket policy that grants anonymous s3:GetObject access to all objects in the bucket. Save the following as bucket-policy.json:

🔽 Click to expand bucket-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-static-site-example/*"
    }
  ]
}

Apply the policy:

aws s3api put-bucket-policy \
  --bucket my-static-site-example \
  --policy file://bucket-policy.json

The Resource ARN uses the /* suffix to target all objects within the bucket. The bucket ARN itself (arn:aws:s3:::my-static-site-example without /*) does not grant object-level access — a common misconfiguration that results in 403 errors on every object request.

Step 4: Enable Static Website Hosting on the Bucket

This step activates the website endpoint and tells S3 which document to serve for root and directory requests, and which document to serve when an object is not found.

aws s3api put-bucket-website \
  --bucket my-static-site-example \
  --website-configuration '{
    "IndexDocument": {"Suffix": "index.html"},
    "ErrorDocument": {"Key": "error.html"}
  }'

The Suffix value is appended to requests that end in /. The Key value for ErrorDocument must be an object that exists in the bucket — S3 does not generate a default error page.

Step 5: Upload Your Website Files

Use the aws s3 sync command to upload your local directory. The --delete flag removes objects in S3 that no longer exist locally, keeping the bucket in sync with your source.

aws s3 sync ./my-website-directory s3://my-static-site-example \
  --delete

Verify that index.html and error.html are present — the website endpoint will return a generic S3 error if either configured document is missing.

aws s3 ls s3://my-static-site-example

Step 6: Verify the S3 Static Website Hosting Endpoint

Retrieve your website configuration to confirm it was applied correctly. Note that get-bucket-website returns the configuration JSON (index document, error document, routing rules) — it does not return the endpoint URL directly. You must construct the endpoint URL using the regional patterns below.

aws s3api get-bucket-website \
  --bucket my-static-site-example

Expected output:

{
    "IndexDocument": {
        "Suffix": "index.html"
    },
    "ErrorDocument": {
        "Key": "error.html"
    }
}

Construct your website endpoint URL using the appropriate regional pattern for your bucket's region. AWS uses two formats depending on the region:

  • Dash format: http://my-static-site-example.s3-website-us-east-1.amazonaws.com
  • Dot format: http://my-static-site-example.s3-website.us-west-2.amazonaws.com

Check the AWS S3 Website Endpoints regional reference to confirm which pattern applies to your region. Then test the endpoint with curl:

curl -I http://my-static-site-example.s3-website-us-east-1.amazonaws.com

A successful response returns HTTP/1.1 200 OK. A 403 Forbidden at this stage almost always means Block Public Access is still active at the account or bucket level — revisit Step 2.

graph TD Test["curl -I website-endpoint"] --> Result{"HTTP Response"} Result -->|"403 Forbidden"| BlockCheck["Check Block Public Access
bucket-level AND account-level"] Result -->|"404 Not Found"| MissingFile["Verify index.html exists
in bucket / hosting enabled"] Result -->|"200 OK"| Success["Endpoint correctly configured"] BlockCheck --> BucketLevel["aws s3api get-public-access-block
--bucket my-static-site-example"] BlockCheck --> AccountLevel["aws s3control get-public-access-block
--account-id 123456789012"] BucketLevel --> Fix1["put-public-access-block on bucket"] AccountLevel --> Fix2["put-public-access-block via s3control"]
  1. 403 Forbidden: Block Public Access is still enabled at the bucket or account level, or the bucket policy is missing/incorrect.
  2. 404 Not Found: The index document (index.html) does not exist in the bucket, or static website hosting is not enabled.
  3. 200 OK: The endpoint is correctly configured and serving content.

IAM Considerations for Operators

If you are configuring this as an IAM user or role (rather than root), ensure the principal has the following permissions. The s3control:PutPublicAccessBlock and s3control:GetPublicAccessBlock actions for account-level Block Public Access settings require the resource ARN in the format arn:aws:s3control::<account-id>:configuration/publicAccessBlock.

🔽 Click to expand IAM policy for S3 static website setup
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "S3BucketManagement",
      "Effect": "Allow",
      "Action": [
        "s3:CreateBucket",
        "s3:PutBucketPolicy",
        "s3:PutBucketWebsite",
        "s3:PutBucketPublicAccessBlock",
        "s3:GetBucketWebsite",
        "s3:GetBucketPublicAccessBlock",
        "s3:PutObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-static-site-example",
        "arn:aws:s3:::my-static-site-example/*"
      ]
    },
    {
      "Sid": "S3AccountPublicAccessBlock",
      "Effect": "Allow",
      "Action": [
        "s3control:GetPublicAccessBlock",
        "s3control:PutPublicAccessBlock"
      ],
      "Resource": "arn:aws:s3control::123456789012:configuration/publicAccessBlock"
    }
  ]
}

Always verify IAM action-to-resource mappings against the S3 Service Authorization Reference before applying restrictions, as resource-level support varies by action.

Experience Signal: The Silent 403 That Isn't the Bucket Policy

A common failure pattern: you write a correct bucket policy, apply it, hit the website endpoint, and still get 403 Forbidden. The S3 console shows the policy is attached. The CLI confirms it. Nothing in the bucket policy is wrong.

The misdiagnosis is spending time iterating on the bucket policy — tightening and loosening the Principal, checking the Resource ARN, re-applying the policy. None of it changes the 403.

The actual cause: account-level Block Public Access is still enabled. The bucket-level setting was disabled in Step 2a, but the account-level setting (a separate API call to s3control) was never touched. S3 evaluates both layers independently, and the account-level restriction overrides the bucket policy regardless of its content.

The fix is the aws s3control put-public-access-block call from Step 2b. Once the account-level setting is cleared, the existing bucket policy takes effect immediately — no policy change required.

Account-level Block Public Access is invisible from the bucket configuration view in the S3 console unless you navigate to the account-level settings under S3 > Block Public Access settings for this account.

Next Steps: Adding HTTPS with CloudFront

The S3 website endpoint serves content over HTTP only. For HTTPS, you need to place an Amazon CloudFront distribution in front of your S3 bucket. CloudFront also provides edge caching, custom domain support via ACM certificates, and improved performance for geographically distributed users. See the CloudFront Getting Started documentation for the integration steps.

For custom domain routing without CloudFront, Route 53 supports alias records that point directly to S3 website endpoints — but this path requires the bucket name to exactly match the domain name, and it remains HTTP-only.

Glossary: Key Terms for S3 Static Website Hosting

TermDefinition
Website EndpointAn HTTP endpoint activated by enabling static website hosting on a bucket. Distinct from the REST API endpoint; supports index document resolution and custom error pages.
Block Public AccessA set of account-level and bucket-level settings that override bucket policies and ACLs to prevent public access. Must be disabled at both layers for public hosting.
Bucket PolicyA resource-based IAM policy attached to an S3 bucket that controls access to the bucket and its objects. Required to grant anonymous s3:GetObject for public websites.
Index DocumentThe object S3 serves when a request targets a directory path (e.g., / or /about/). Configured as a suffix (e.g., index.html).
Error DocumentThe object S3 serves when a requested object does not exist. Must be an object present in the bucket.

Related Posts

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Fix Internet Gateway and Route Table

EC2 SSH Connection Timeout: Which Security Group Rules to Check

Difference Between IAM User and IAM Role: Which One Should Your EC2 Use?