The Setup

I was building my portfolio website on AWS.
S3, CloudFront, Route53 - the whole serverless static site stack. Everything was going great until I got to a seemingly simple question:

How do I make cloudwithsarah.com redirect to www.cloudwithsarah.com?

I did what any reasonable developer does: I googled it. I found the AWS docs. I found tutorials. I found a Reddit thread from 2018 where someone asked the exact same question.

The answer was unanimous: you need two S3 buckets.

Guess I'll just do what the docs say

One bucket holds your actual website files. The other bucket exists solely to redirect traffic to the first one. The AWS docs literally say:

"If your root domain is example.com, and you want to serve requests for both http://example.com and http://www.example.com, you must create two buckets named example.com and www.example.com."

Seems official enough. Stack Overflow agreed. Reddit agreed. Blog posts from 2025 are STILL recommending it. Who was I to argue?

So I created two buckets, wrote my CDK code, deployed everything, tested both URLs, and they both worked.

Mission accomplished.

...Right?


Why Does This Even Matter?

Before we go further, let's talk about what problem is solved by using bucket redirect (rather than just using two duplicate buckets named www.example.com and example.com).

The SEO Problem: If both URLs serve the same content, search engines might treat them as two separate websites with identical content. This can dilute your page rank; Google sees two mediocre sites instead of one authoritative one. They've gotten smarter about this, but "just do the redirect" is still standard advice.

The Cache Problem: If CloudFront caches content for both www.example.com/page AND example.com/page, you're storing the same bytes twice at every edge location worldwide. That's wasteful and potentially confusing.

The User Problem: Some people type www, some don't. You want them all to end up in the same place with a consistent URL in their address bar.

The Solution: Pick one (www or non-www) as your "canonical" URL, and 301 redirect the other. A 301 tells browsers and search engines "this content has permanently moved here, so update your bookmarks."

Simple enough concept. The implementation? That's where AWS gets... AWS-y.


The "Official" Two-Bucket Method

Here's what the AWS docs tell you to do (and what countless tutorials repeat):

S3-Only redirect architecture showing User to Route53 to S3 redirect bucket to S3 content bucket
The classic S3-only approach: two buckets, redirect bucket catches traffic

Why S3 needs two buckets for this:

S3's static website hosting feature can do ONE of two things:

  1. Host content (serve your HTML/CSS/JS files)
  2. Redirect all requests to another hostname

It cannot do both. There's no "serve content for www requests but redirect non-www requests" option.
If you want redirection behavior, you need to create a separate bucket configured just for that purpose. The redirect bucket is essentially a single-purpose traffic cop.

This made perfect sense to me. I implemented it in CDK:

// The content bucket - holds actual website files
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
  bucketName: 'www.cloudwithsarah.com',
  // ... website hosting config
});

// The redirect bucket - completely empty, just redirects
const redirectBucket = new s3.Bucket(this, 'RedirectBucket', {
  bucketName: 'cloudwithsarah.com',
  websiteRedirect: {
    hostName: 'www.cloudwithsarah.com',
    protocol: s3.RedirectProtocol.HTTPS
  },
});

Both URLs worked. I moved on with my life.


Fast Forward: Something Doesn't Add Up

Months later, I was optimizing my site and started poking around my infrastructure. I ran a simple curl command to check my headers:

curl -I https://cloudwithsarah.com

If my redirect was working, I would see:

HTTP/2 301
location: https://www.cloudwithsarah.com/

But what I actually saw was:

HTTP/2 200
content-type: text/html
server: AmazonS3
x-cache: Miss from cloudfront
Confused math lady meme

Headers looked great! But HTTP 200 is not a redirect... That's a successful response serving actual content.

I checked my browser. Typed https://cloudwithsarah.com/ explicitly in the search bar. The page loaded.. but the URL bar still showed https://cloudwithsarah.com. It never changed to www.

So my redirect bucket was doing absolutely nothing. Both URLs were just.. serving the same content from a single CloudFront distribution.

In other words, the redirect bucket never needed to redirect because CloudFront already points both URLs directly at the content bucket.

Always has been astronaut meme

The Browser Deception

Here's what made this extra sneaky: sometimes when I typed cloudwithsarah.com (without the https://), it DID show up as www.cloudwithsarah.com.

But that wasn't my redirect working โ€” that was my browser "helpfully" autocompleting from my browsing history! When I explicitly typed https://cloudwithsarah.com/, bypassing autocomplete, the truth was revealed: no redirect. Just duplicate content.

Lesson: Always test redirects with curl -I, not by typing in your browser. Browsers lie to you. They're trying to be helpful. They are not.


Investigating the Zombie Bucket ๐ŸงŸ

Here's what I found when I actually looked at my CDK code.
Both Route53 A records point to CloudFront. Not to S3:

// My CloudFront distribution
const distribution = new cloudfront.Distribution(this, 'Distribution', {
  domainNames: ['cloudwithsarah.com', 'www.cloudwithsarah.com'],  // ๐Ÿ‘€ BOTH domains
  defaultBehavior: {
    origin: S3BucketOrigin.withOriginAccessControl(websiteBucket),  // Points to www bucket
  },
});

// My Route53 records
new route53.ARecord(this, 'RootRecord', {
  recordName: 'cloudwithsarah.com',
  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution))  // ๐Ÿ‘€ CloudFront
});

new route53.ARecord(this, 'WwwRecord', {
  recordName: 'www.cloudwithsarah.com',
  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution))  // ๐Ÿ‘€ CloudFront
});

See the problem?

CloudFront is configured to serve content from www.cloudwithsarah.com bucket for BOTH domain names.

The redirect bucket exists. It's configured correctly. It would totally redirect if anyone ever talked to it.

But nobody does.

Traffic for cloudwithsarah.com goes: Route53 โ†’ CloudFront โ†’ content bucket โ†’ serves content. The redirect bucket is never consulted. It's a ghost. A zombie. A perfectly configured piece of infrastructure doing absolutely nothing, waiting for traffic that will never arrive.

zombie waiting for traffic

โš ๏ธ How CloudFront Changes the Game

This is the part nobody explains clearly. This is the whole point of this article. If you take nothing else away, take this:

The Two-Bucket Pattern Was Designed for S3-Only Hosting

The AWS docs, the tutorials, the Stack Overflow answers โ€” they all assume a world where S3 is your edge. Meaning: users' browsers talk directly to S3.

In that S3-only world:

S3-Only Hosting

What the tutorials assume
  1. User visits example.com
  2. Route53 points to S3 website endpoint for example.com
  3. S3 redirect bucket catches the request
  4. S3 returns 301 redirect to www.example.com
  5. Browser follows redirect
  6. Route53 points to S3 website endpoint for www bucket
  7. S3 serves content

The redirect bucket is IN THE PATH. It gets hit.

But modern static sites use CloudFront. You need CloudFront for:

And when you add CloudFront, the architecture fundamentally changes:

CloudFront Hosting

What you're actually building
  1. User visits example.com
  2. Route53 points to CloudFront (NOT S3!)
  3. CloudFront says "I serve both domains"
  4. CloudFront fetches from origin bucket
  5. Content is served
  6. NO REDIRECT EVER HAPPENS

The redirect bucket is NEVER IN THE PATH. Route53 bypasses it entirely by going to CloudFront.

CloudFront becomes your edge. Route53 points to CloudFront, not S3. CloudFront handles both domains. The redirect bucket sits there, configured perfectly, completely unused.

This is why I had zombie infrastructure. I followed S3-only tutorials while building a CloudFront architecture.


The Options: A Complete Comparison

So what ARE your actual options for handling www redirects in 2026?

Option 1: Two Buckets + Two CloudFront Distributions

The "make the old pattern work with CloudFront" approach.

Two buckets with two CloudFront distributions architecture diagram showing the legacy redirect pattern
The legacy pattern: 2 S3 buckets, 2 CloudFront distros, redirect flows through both
Pros Cons
Follows AWS docs Two CloudFront distributions ($$$)
Conceptually familiar Two S3 buckets to manage
Redirect bucket needs public access OR website endpoint origin
More complex infrastructure
More things that can break

Why people choose this: It's what the docs say. It's what tutorials show. It feels "official."

Why it's not ideal: You're paying for and managing double the infrastructure just to redirect traffic. And the redirect bucket's requirement for public access or website endpoint origin conflicts with modern OAC security practices.


Option 2: One Bucket + Both Domains on CloudFront (No Redirect)

This is what I accidentally built. ๐Ÿคก

CloudFront serving both domains without redirect, with zombie S3 bucket shown disconnected
My first draft: both domains served, no redirect, zombie bucket ignored
Pros Cons
Simple setup NO REDIRECT โ€” both URLs serve content
Single distribution Duplicate content SEO issues
Works with private buckets + OAC Cache inefficiency
Cheap Unprofessional (different URLs for same content)

Why people end up here: They follow CloudFront tutorials that don't mention redirects, or they follow redirect tutorials without realizing CloudFront changes everything. The site "works" so they don't investigate further.

Why it's problematic: You're telling search engines you have two websites with identical content. That's not a great look.


Option 3: CloudFront Functions (The Modern Way) โœจ

One bucket. One distribution. Actual redirect. Edge-powered.

CloudFront Function redirect architecture showing the function intercepting non-www requests
The modern approach: CloudFront Function handles redirect at the edge
Pros Cons
Single S3 bucket Requires writing ~10 lines of JavaScript
Single CloudFront distribution ...that's it. That's the only con.
Proper 301 redirect
Works with private buckets + OAC
Runs at the edge (sub-millisecond)
2 million FREE invocations/month
Clean IaC (all in CDK)

Why people don't know about this: CloudFront Functions launched in 2021. The tutorials, Stack Overflow answers, and even some AWS docs predate this. The two-bucket pattern became "common knowledge" before a better option existed.

Why it's the right choice: It's simpler, cheaper, more secure, and actually works. The only reason NOT to use it is if you don't have CloudFront โ€” but if you don't have CloudFront in 2026, you probably should.


Option 4: Lambda@Edge

Like CloudFront Functions but more powerful (and more expensive).

Pros Cons
Can do complex logic $0.60 per million invocations (vs $0.10)
Access to request body Higher latency
Longer execution time More complex deployment
us-east-1 requirement

Why you might choose this: You need to inspect request bodies, run for longer than 1ms, or do something CloudFront Functions can't handle.

Why it's overkill for redirects: A www redirect is, again, literally 10 lines of code that runs in microseconds. Lambda@Edge is like using a sledgehammer for a thumbtack.


The Solution: CloudFront Functions + CDK

Here's how to actually implement www redirects the modern way.

The CloudFront Function

// In your CDK stack
const wwwRedirect = new cloudfront.Function(this, 'WwwRedirect', {
  functionName: 'www-redirect',
  code: cloudfront.FunctionCode.fromInline(`
    function handler(event) {
      var request = event.request;
      var host = request.headers.host.value;

      // If request is for non-www, redirect to www
      if (!host.startsWith('www.')) {
        return {
          statusCode: 301,
          statusDescription: 'Moved Permanently',
          headers: {
            'location': { value: 'https://www.' + host + request.uri }
          }
        };
      }

      // Otherwise, continue to origin
      return request;
    }
  `),
});

That's it. That's the whole redirect logic. 10 lines.

Attach It to CloudFront

const distribution = new cloudfront.Distribution(this, 'Distribution', {
  domainNames: ['cloudwithsarah.com', 'www.cloudwithsarah.com'],
  defaultBehavior: {
    origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(websiteBucket),
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    functionAssociations: [{
      function: wwwRedirect,
      eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
    }],
  },
  certificate: certificate,
});

Kill the Zombie

// DELETE THIS โ€” you don't need it anymore!
// const redirectBucket = new s3.Bucket(this, 'RedirectBucket', {
//   bucketName: rootDomain,
//   websiteRedirect: { ... }
// });

// ๐Ÿชฆ Rest in peace, little bucket. You were configured perfectly.
// You just never got any traffic.

Verify It Works

After deploying:

$ curl -I https://cloudwithsarah.com

HTTP/2 301
location: https://www.cloudwithsarah.com/
server: CloudFront
x-cache: FunctionGeneratedResponse from cloudfront

Get the 301 redirect! ๐ŸŽ‰ The URL changes in browsers, search engines understand your canonical URL, and you're running lean infrastructure.

get clout

Full Working CDK Stack

Here's the complete, copy-paste-ready solution:

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';

export class ModernStaticSiteStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const rootDomain = 'example.com';  // Change this
    const wwwDomain = `www.${rootDomain}`;

    // ============================================
    // ONE bucket is all you need. <3
    // ============================================
    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      bucketName: wwwDomain,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,  // Private!
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // ============================================
    // DNS & Certificate
    // ============================================
    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: rootDomain,
    });

    const certificate = new acm.Certificate(this, 'Certificate', {
      domainName: rootDomain,
      subjectAlternativeNames: [wwwDomain],
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });

    // ============================================
    // THE MAGIC: CloudFront Function for redirect
    // ============================================
    const wwwRedirect = new cloudfront.Function(this, 'WwwRedirect', {
      functionName: `${rootDomain.replace(/\./g, '-')}-www-redirect`,
      code: cloudfront.FunctionCode.fromInline(`
        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://www.' + host + request.uri }
              }
            };
          }
          return request;
        }
      `),
    });

    // ============================================
    // ONE CloudFront distribution
    // ============================================
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      domainNames: [rootDomain, wwwDomain],
      defaultRootObject: 'index.html',
      defaultBehavior: {
        origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(websiteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        functionAssociations: [{
          function: wwwRedirect,
          eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
        }],
      },
      certificate: certificate,
    });

    // ============================================
    // Route53 - both domains point to CloudFront
    // ============================================
    new route53.ARecord(this, 'RootRecord', {
      zone: hostedZone,
      recordName: rootDomain,
      target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
    });

    new route53.ARecord(this, 'WwwRecord', {
      zone: hostedZone,
      recordName: 'www',
      target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
    });
  }
}

When You Actually Need Two Buckets

To be fair, the AWS docs aren't wrong, they're just written for a specific scenario.

Use two buckets when:

Use CloudFront Functions when:


For basically any static website in 2026?
CloudFront Functions is the answer.

prefer 1 bucket + Cloudfront

Bonus: Other Modern Best Practices

While you're modernizing your redirect, here are other 2026 best practices worth implementing:

Use OAC, Not OAI

What it is: Origin Access Control (OAC) is the modern way for CloudFront to access private S3 buckets. It replaced Origin Access Identity (OAI).

Why it matters: OAC uses AWS SigV4 signing (the same authentication your AWS CLI uses), provides better CloudTrail audit logging, and supports newer S3 features like SSE-KMS encryption. OAI is deprecated and has known limitations.

// โœ… Modern: OAC (Origin Access Control)
origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(bucket)

// โŒ Legacy: OAI (Origin Access Identity) โ€” deprecated!
origin: new cloudfrontOrigins.S3Origin(bucket)

Add Security Headers

What it is: HTTP headers that tell browsers to enable security features.

Why it matters: These headers protect against common attacks like clickjacking (X-Frame-Options), XSS (Content-Security-Policy), and protocol downgrade attacks (Strict-Transport-Security). Adding them demonstrates security awareness โ€” something technical interviewers notice.

const securityHeaders = new cloudfront.ResponseHeadersPolicy(this, 'SecurityHeaders', {
  securityHeadersBehavior: {
    strictTransportSecurity: {
      accessControlMaxAge: cdk.Duration.days(365),
      includeSubdomains: true,
      preload: true,
      override: true,
    },
    contentTypeOptions: { override: true },  // Prevents MIME sniffing
    frameOptions: {
      frameOption: cloudfront.HeadersFrameOption.DENY,  // Prevents clickjacking
      override: true
    },
    xssProtection: {
      protection: true,
      modeBlock: true,
      override: true
    },
  },
});

Use OIDC for CI/CD, Not Access Keys

What it is: OpenID Connect lets GitHub Actions authenticate with AWS using temporary credentials instead of stored access keys.

Why it matters: Access keys are long-lived secrets. If they leak (and secrets leak), attackers have permanent access until you notice and revoke them. OIDC credentials expire in minutes and are never stored anywhere. It's the difference between leaving a house key under your mat vs using a one-time entry code.

const provider = new iam.OpenIdConnectProvider(this, 'GitHubOIDC', {
  url: 'https://token.actions.githubusercontent.com',
  clientIds: ['sts.amazonaws.com'],
});

Lessons Learned

roll safe meme
  1. Understand WHY, not just HOW. I followed tutorials without understanding the architecture they assumed. The two-bucket approach made sense for S3-only hosting โ€” but I wasn't doing S3-only hosting.
  2. Test your assumptions. A simple curl -I would have revealed the problem immediately. I assumed "both URLs load the site" meant "the redirect is working." It didn't.
  3. Documentation lags behind features. The two-bucket method is still documented as standard, but it predates CloudFront Functions (2021). Just because something is in the official docs doesn't mean it's the best current approach.
  4. Infrastructure as code reveals intent. Looking at my CDK code made the problem obvious โ€” both Route53 records pointed to CloudFront. The redirect bucket was never in the request path. IaC isn't just about automation; it's documentation that can't lie.
  5. Communities perpetuate outdated patterns. That Reddit thread from 2018 wasn't wrong at the time โ€” CloudFront Functions didn't exist. But copying 8-year-old solutions for modern infrastructure creates zombie infrastructure.

TL;DR

๐ŸŽฏ The Modern Static Site Stack

For any static website using CloudFront (which should be all of them):

  • โœ… 1 S3 bucket (private, Block Public Access ON)
  • โœ… 1 CloudFront distribution with OAC
  • โœ… 1 CloudFront Function for www redirect (~10 lines)
  • โœ… 1 ACM certificate (covers both domains)
  • โœ… Route53 A records pointing both domains to CloudFront

No zombie buckets. No duplicate content. No wasted infrastructure.

Complete modern static site architecture with Route53, ACM, CloudFront Function, OAC, and private S3
The complete modern stack: single bucket, single distribution, proper security
If you're using... Do this
S3-only hosting (no CloudFront) Two buckets, S3 website redirect
CloudFront (like everyone in 2026) One bucket + CloudFront Function
CloudFront + complex logic needs Lambda@Edge (but probably not)

Resources


Sarah Wadley is a Software Engineer and Cloud Architect with experience in defense systems and data infrastructure. She builds AI-augmented developer tools and writes about AWS architecture and infrastructure-as-code. Currently seeking her next role โ€” check out her portfolio or connect on LinkedIn.

Related Project

See full architecture, tech stack, and design decisions behind this site:

This Portfolio Site โ€” Architecture & Infrastructure Breakdown โ†’