HomeBlogHow to Host a Startup Website on S3 + CloudFront
AWSS3CloudFrontCI/CD

How to Host a Startup Website on S3 + CloudFront

May 22, 2026·13 min read·Omphora Technologies

Why S3 + CloudFront for a startup website

When you're building a startup website, you want something that's fast, cheap, reliable, and easy to deploy. A managed hosting platform like Vercel or Netlify works fine — but if your team is already on AWS, S3 + CloudFront gives you the same result with zero vendor lock-in, full control, and a bill that rarely exceeds $5/month for a typical marketing site.

Here's how to set it up end-to-end.

What you get:

  • Global CDN with sub-50ms TTFB from every continent
  • HTTPS with ACM (free TLS certificates, auto-renewed)
  • Custom domain with www → apex redirect
  • GitHub Actions CI/CD on every push to main
  • Automatic CloudFront cache invalidation after each deploy
  • Costs roughly $1–5/month for a typical startup site

Architecture overview

Browser → CloudFront → S3 (static files)
          ↑
      ACM cert (HTTPS)
      Route 53 (DNS)

S3 stores the static HTML, CSS, JS, and assets. CloudFront serves them globally from 400+ edge locations. ACM handles TLS. Route 53 manages DNS and the www redirect.

Step 1: Build a static site

CloudFront serves static files, so your site needs to output static HTML. Any static site generator works: Next.js (with output: 'export'), Astro, Hugo, Gatsby, or plain HTML.

For Next.js, add this to next.config.ts:

const nextConfig = {
  output: 'export',
  trailingSlash: true,
}

export default nextConfig

Running npm run build produces an out/ directory of static files ready to upload to S3.

Step 2: Create the S3 bucket

Create the bucket with the same name as your domain:

aws s3api create-bucket \
  --bucket mystartup.com \
  --region us-east-1

Disable public access block — CloudFront will access S3 using an Origin Access Control (OAC), not a public URL. But first, create the bucket:

aws s3api put-public-access-block \
  --bucket mystartup.com \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Do not enable S3 static website hosting. You'll serve through CloudFront instead, which gives you HTTPS and better performance.

Step 3: Request an ACM certificate

Certificates for CloudFront must be in us-east-1, regardless of where your bucket is:

aws acm request-certificate \
  --domain-name mystartup.com \
  --subject-alternative-names "*.mystartup.com" \
  --validation-method DNS \
  --region us-east-1

ACM returns a CNAME record to add to your DNS. Add it in Route 53 (or wherever your DNS is hosted), and ACM will validate and issue the certificate within a few minutes. It auto-renews.

Step 4: Create the CloudFront distribution

In the AWS Console (or Terraform), create a CloudFront distribution with these key settings:

Origin:

  • Origin domain: your S3 bucket's regional endpoint (mystartup.com.s3.us-east-1.amazonaws.com)
  • Origin access: Origin Access Control (OAC) — not legacy OAI

Default cache behavior:

  • Viewer protocol policy: Redirect HTTP to HTTPS
  • Allowed HTTP methods: GET, HEAD
  • Cache policy: CachingOptimized (managed policy)
  • Compress objects automatically: Yes

Settings:

  • Alternate domain names (CNAMEs): mystartup.com, www.mystartup.com
  • SSL certificate: the ACM cert you just issued
  • Default root object: index.html
  • Custom error responses: 404 → /404.html, 200

After creating the distribution, CloudFront gives you a bucket policy to copy. Apply it to your S3 bucket so CloudFront can read the files:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::mystartup.com/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/YOUR_DIST_ID"
        }
      }
    }
  ]
}

Step 5: Handle the www → apex redirect

CloudFront doesn't natively redirect www to your apex domain. The cleanest solution is a CloudFront Function:

// cloudfront-functions/redirect-www.js
function handler(event) {
  var request = event.request;
  var host = request.headers.host.value;

  if (host.startsWith('www.')) {
    return {
      statusCode: 301,
      statusDescription: 'Moved Permanently',
      headers: {
        location: {
          value: 'https://' + host.slice(4) + request.uri,
        },
      },
    };
  }

  // Append index.html for directory-style URLs
  if (request.uri.endsWith('/')) {
    request.uri += 'index.html';
  }

  return request;
}

Attach this function to the Viewer Request event of your CloudFront distribution. It handles both the www redirect and the trailing-slash-to-index.html mapping that static exports need.

Step 6: Configure Route 53 DNS

Create two records in your Route 53 hosted zone:

mystartup.com     A   ALIAS → your-distribution.cloudfront.net
www.mystartup.com A   ALIAS → your-distribution.cloudfront.net

Both point to the same CloudFront distribution. The CloudFront Function handles the www → apex redirect at the edge.

DNS propagation takes 0–60 seconds for Route 53 ALIAS records.

Step 7: Deploy with GitHub Actions

The deploy workflow builds the site and syncs to S3, then invalidates the CloudFront cache so visitors see the latest content immediately:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Build
        run: npm run build

      - name: Sync to S3
        run: |
          aws s3 sync out/ s3://mystartup.com --delete

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"

Use OIDC (not static AWS credentials) so you never store long-lived secrets in GitHub. The IAM role needs s3:PutObject, s3:DeleteObject, s3:ListBucket, and cloudfront:CreateInvalidation.

What does it cost?

For a typical startup marketing site:

Service Monthly cost
S3 storage (< 1 GB) ~$0.02
S3 PUT/GET requests ~$0.10
CloudFront data transfer (10 GB) ~$0.85
CloudFront requests (1M) ~$0.10
Route 53 hosted zone $0.50
ACM certificate Free
Total ~$1.60

A comparable Vercel Pro plan is $20/month. You're saving money and owning the stack.

Key things to get right

Cache invalidation — always invalidate /* after every deploy. Otherwise visitors may see stale HTML for up to 24 hours (the default CloudFront TTL). The GitHub Actions step above does this automatically.

Custom error page — set a 404 error response that maps to your /404.html and returns a 200 status code. This is required for SPA-style routing where React/Next.js handles 404s client-side.

index.html mapping — the CloudFront Function handles appending index.html to directory URLs. Without it, /about/ returns a 403 from S3 instead of serving /about/index.html.

robots.txt and sitemap.xml — put these in your public/ directory so they're included in the static export and synced to S3. Submit the sitemap to Google Search Console after your first deploy.

Compression — enable "Compress objects automatically" in CloudFront. It gzip/brotli compresses text files at the edge for free, typically halving the size of HTML/CSS/JS responses.

Summary

S3 + CloudFront is a reliable, cheap, and fully AWS-native way to host a static website. The setup takes about two hours end-to-end. After that, every push to main deploys automatically in under two minutes.

If you're building on AWS and your site is statically generated, there's no reason to pay for managed hosting.

Not sure where to start?
Let's talk for an hour.

One conversation, no commitment. We listen to what your team is struggling with and give you an honest picture of what needs to change — and what doesn't.

  • What's slowing down your team's deployment process
  • Where your cloud spend is going — and what's being wasted
  • Security vulnerabilities in your current setup
  • Reliability gaps that could cause downtime
  • Blind spots in your monitoring and alerting
Available for new projectsResponse within 1 business dayNo long-term commitment required
your-infra ~ after-omphora
$ terraform apply
✓ 23 resources. Apply complete in 4m 12s
$ kubectl get nodes
NAME STATUS ROLES AGE
ip-10-0-1 Ready worker 2d
ip-10-0-2 Ready worker 2d
ip-10-0-3 Ready worker 2d
$ argocd app list
production Synced Healthy
staging Synced Healthy
$ # Commit → production: 3m 42s
✓ Zero downtime · p99: 82ms · cost ↓ 38%
$ # This could be your stack.
3m 42s
Deploy time
38%
Cost saved
99.9%
Uptime