paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

April 01, 2024

Static Site Hosting on AWS: A Complete Guide to S3, CloudFront, and Route 53

From your first bucket to a production-grade, globally distributed deployment pipeline

Introduction

Most websites don't need a server. A marketing page, a documentation site, a portfolio, a single-page React or Vue application, even a fairly complex web app that talks to APIs over HTTP - none of these require a process sitting on a machine somewhere, waiting to render HTML on every request. They need files: HTML, CSS, JavaScript, images, fonts. Once you accept that, a whole category of operational complexity disappears. There's no runtime to patch, no autoscaling group to tune, no application server to keep alive at 3 a.m. What's left is a much simpler problem: how do you store a set of files reliably, deliver them quickly to users anywhere in the world, and point a human-readable domain name at them.

AWS answers that problem with three services that are almost always used together: S3 for durable object storage, CloudFront for global content delivery and caching, and Route 53 for DNS. Individually, each service is well documented and reasonably simple. Combined, they form one of the most cost-effective and resilient hosting architectures available on any cloud platform. This article walks through why that combination works, how the pieces fit together mechanically, and what it actually takes to run it in production - including the security model, the cost structure, the common mistakes, and a working infrastructure-as-code example you can adapt directly.

Why Static Hosting, and Why AWS

The case for static hosting starts with a simple observation: a large share of the content on the web doesn't change per request. A blog post looks the same to every visitor. A product landing page is identical whether it's viewed by the first person or the millionth. Even applications that feel dynamic - a dashboard, a documentation portal with search, a single-page app - are frequently built so that the shell (HTML, JS bundles, CSS) is static, and only the data underneath is dynamic, fetched client-side from an API. Recognizing this distinction is the foundation of the "Jamstack" approach: pre-build what can be pre-built, serve it from a CDN, and reserve compute for the parts that genuinely need it.

Once you've decided a site can be static, the hosting decision becomes a question of durability, latency, and operational overhead rather than compute capacity. A traditional server-based setup - even a small one - still needs an OS, a web server process, security patching, health checks, and a story for what happens when that single instance fails. Static hosting removes essentially all of it. You're no longer running anything; you're publishing files to storage and letting a content delivery network do the serving. There is no server to compromise, no process to crash, and no capacity planning beyond storage size, which for text and image-heavy sites is trivial.

AWS is a natural fit for this pattern not because S3, CloudFront, and Route 53 are unique in what they do - every major cloud has an object store, a CDN, and a DNS service - but because of how tightly they interoperate within a single account, IAM model, and billing relationship. A CloudFront distribution can be granted access to a private S3 bucket without the bucket ever being public. Route 53 can alias a domain directly to a CloudFront distribution without an extra CNAME hop. Certificates from AWS Certificate Manager attach to CloudFront with no manual renewal process. None of this requires third-party integration work; it's provisioned within one account, one IAM policy language, and largely one infrastructure-as-code tool. That operational coherence is the real argument for choosing AWS over stitching together storage, CDN, and DNS from three different vendors.

Core Building Blocks: S3, CloudFront, and Route 53

Before assembling the architecture, it's worth understanding what each service actually is, because the mental model for one is different enough from the others that conflating them causes real design mistakes later.

Amazon S3: Durable Object Storage, Not a Web Server

S3 (Simple Storage Service) stores objects - arbitrary blobs of bytes with a key and metadata - inside buckets, which are globally-namespaced containers scoped to a specific AWS region. S3 is engineered for extremely high durability, and AWS documents S3 Standard as designed for 99.999999999% (eleven nines) annual durability of objects, achieved by redundantly storing data across multiple facilities within a region. S3 has an optional feature called "static website hosting" that adds an HTTP interface returning index.html for a directory-style request and letting you configure a custom error document, but it's important to understand that this feature is a convenience layer, not a real web server. It has no support for HTTPS on its own endpoint, no built-in caching tier, and its website endpoint format is separate from the standard REST API endpoint used for programmatic access.

Amazon CloudFront: The Actual Content Delivery Layer

CloudFront is AWS's content delivery network: a globally distributed system of edge locations that cache content close to end users and reduce the number of requests that need to travel back to the origin (in this case, S3). When a request for /index.html arrives at an edge location and the object is already cached there, CloudFront serves it directly, often in single-digit milliseconds. When it isn't cached - a "cache miss" - CloudFront fetches it from the origin, serves it, and stores a copy for subsequent requests. CloudFront is also where TLS termination happens, where you attach an ACM certificate for HTTPS, and where you configure caching behavior, compression, custom error responses, and increasingly, lightweight compute at the edge via CloudFront Functions or Lambda@Edge.

Amazon Route 53: DNS, Not Just a Registrar

Route 53 is AWS's DNS service. It resolves human-readable names like www.example.com to the infrastructure actually serving traffic. For this architecture, the feature that matters most is the alias record, an AWS-specific extension to standard DNS record types that lets a zone apex (example.com, not just www.example.com) point directly at an AWS resource like a CloudFront distribution - something a standard CNAME record cannot do at the apex, because the DNS specification prohibits a CNAME from coexisting with other record types at the same name. Route 53 also supports standard record types (A, AAAA, MX, TXT, CNAME) and is commonly used as the domain registrar too, though registration and DNS hosting are logically separate functions that happen to be bundled in the same console.

AWS Certificate Manager: The Quiet Fourth Piece

Technically a fourth service, ACM is worth mentioning here because it's structurally required for HTTPS on a custom domain. ACM issues and - critically - auto-renews TLS certificates at no additional cost, and CloudFront can reference an ACM certificate directly without you ever handling a private key. The one operational detail that trips up almost everyone the first time: a certificate used by CloudFront must be requested in the us-east-1 (N. Virginia) region, regardless of which region your S3 bucket or your users are in, because CloudFront is a global service that only looks for certificates in that specific region.

Request Flow and Architecture

Understanding the pieces individually is useful, but the architecture only clicks once you trace a single request through the whole system from a cold start. Say a user types example.com into a browser for the first time. The browser needs an IP address, so it queries DNS, which eventually reaches Route 53's authoritative name servers for that hosted zone. Route 53 returns an alias pointing to the CloudFront distribution's domain, which resolves to an anycast IP address for the nearest CloudFront edge location. The browser then opens a TLS connection to that edge location, presenting the domain name via SNI, and CloudFront responds using the ACM certificate attached to the distribution. All of this happens before a single byte of the actual page has been requested.

Once the TLS handshake completes, the browser sends its HTTP request for /index.html to the edge location. CloudFront checks its cache. On a cache miss - the first request for that path, or the first since the last invalidation or TTL expiry - CloudFront forwards the request to the origin, which is the S3 bucket, over a private connection authorized by an Origin Access Control (OAC) configuration. S3 returns the object, CloudFront stores a copy according to the cache policy attached to that behavior, and returns the response to the browser. Every subsequent request for that same object, from any user hitting that edge location (or often, other nearby edge locations depending on regional caching layers), is served from cache without touching S3 at all. This is the entire value proposition of the architecture in one sentence: the origin is only touched on cache misses; everything else is edge-served, which is why latency for cached content is largely independent of where the S3 bucket's region actually is.

Implementation Walkthrough

With the mental model in place, the practical build is a matter of ordering a handful of AWS resources correctly. Doing this by hand through the console is fine for a one-off experiment, but for anything you intend to maintain, defining it as code pays for itself almost immediately - mainly because the dependency order between these resources (certificate before distribution, distribution before DNS record, bucket policy referencing the distribution's ARN) is easy to get wrong manually and easy to encode correctly once in a template.

The AWS CDK is a good fit here because it has first-class, well-maintained L2 constructs for exactly this pattern: aws-cdk-lib/aws-s3, aws-cdk-lib/aws-cloudfront, aws-cdk-lib/aws-route53, and the BucketDeployment construct from aws-cdk-lib/aws-s3-deployment, which handles uploading your built assets and - importantly - can trigger a CloudFront invalidation as part of the same deployment so users don't see stale cached files after a release. The example below provisions a private S3 bucket (no public access at all), a CloudFront distribution using Origin Access Control to read from it, an ACM certificate validated via DNS, and a Route 53 alias record, all wired together with explicit dependencies.

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

interface StaticSiteProps extends cdk.StackProps {
  domainName: string;         // e.g. "example.com"
  siteSubDomain: string;      // e.g. "www"
  assetsPath: string;         // local path to the built site, e.g. "./dist"
}

export class StaticSiteStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: StaticSiteProps) {
    super(scope, id, props);

    const siteDomain = `${props.siteSubDomain}.${props.domainName}`;
    const zone = route53.HostedZone.fromLookup(this, "Zone", {
      domainName: props.domainName,
    });

    // Private bucket - no website hosting, no public access.
    // CloudFront reaches it via Origin Access Control only.
    const siteBucket = new s3.Bucket(this, "SiteBucket", {
      bucketName: siteDomain,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      encryption: s3.BucketEncryption.S3_MANAGED,
    });

    // Certificates for CloudFront must live in us-east-1.
    const certificate = new acm.Certificate(this, "SiteCertificate", {
      domainName: siteDomain,
      validation: acm.CertificateValidation.fromDns(zone),
    });

    const distribution = new cloudfront.Distribution(this, "SiteDistribution", {
      defaultRootObject: "index.html",
      domainNames: [siteDomain],
      certificate,
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        compress: true,
      },
      errorResponses: [
        // SPA fallback: unknown paths resolve to index.html client-side routing
        { httpStatus: 403, responseHttpStatus: 200, responsePagePath: "/index.html" },
        { httpStatus: 404, responseHttpStatus: 200, responsePagePath: "/index.html" },
      ],
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
    });

    new route53.ARecord(this, "SiteAliasRecord", {
      zone,
      recordName: props.siteSubDomain,
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(distribution)
      ),
    });

    new s3deploy.BucketDeployment(this, "DeploySite", {
      sources: [s3deploy.Source.asset(props.assetsPath)],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: ["/*"],
    });

    new cdk.CfnOutput(this, "DistributionDomainName", {
      value: distribution.distributionDomainName,
    });
  }
}

This template captures the shape of a production setup, but two details deserve emphasis beyond what the code shows. First, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL combined with S3BucketOrigin.withOriginAccessControl means the bucket is never reachable directly - only CloudFront, using a signed request mechanism, can read from it. Second, BucketDeployment's distribution and distributionPaths parameters automatically issue a cache invalidation for the paths you specify after each deployment, which is the piece people most often forget to automate when they set this up manually, leading to users seeing stale assets after a release.

For teams that prefer not to invalidate the entire distribution on every deploy - invalidations beyond the free monthly allowance incur a per-path charge - a more surgical approach is to compute which files actually changed and invalidate only those paths, while giving immutable, content-hashed assets (like app.a1b2c3.js) effectively infinite cache lifetimes since their filename changes whenever their content does.

import hashlib
import json
import boto3
from pathlib import Path

s3 = boto3.client("s3")
cloudfront = boto3.client("cloudfront")

def sync_and_invalidate(build_dir: str, bucket: str, distribution_id: str) -> None:
    manifest_key = "deploy-manifest.json"
    previous_manifest = {}
    try:
        obj = s3.get_object(Bucket=bucket, Key=manifest_key)
        previous_manifest = json.loads(obj["Body"].read())
    except s3.exceptions.NoSuchKey:
        pass

    current_manifest = {}
    changed_paths = []

    for file_path in Path(build_dir).rglob("*"):
        if file_path.is_dir():
            continue
        relative_key = str(file_path.relative_to(build_dir))
        content = file_path.read_bytes()
        content_hash = hashlib.sha256(content).hexdigest()
        current_manifest[relative_key] = content_hash

        if previous_manifest.get(relative_key) != content_hash:
            content_type = "text/html" if file_path.suffix == ".html" else None
            cache_control = (
                "public, max-age=0, must-revalidate"
                if file_path.suffix in (".html",)
                else "public, max-age=31536000, immutable"
            )
            extra_args = {"CacheControl": cache_control}
            if content_type:
                extra_args["ContentType"] = content_type

            s3.upload_file(str(file_path), bucket, relative_key, ExtraArgs=extra_args)
            changed_paths.append(f"/{relative_key}")

    if changed_paths:
        cloudfront.create_invalidation(
            DistributionId=distribution_id,
            InvalidationBatch={
                "Paths": {"Quantity": len(changed_paths), "Items": changed_paths},
                "CallerReference": hashlib.sha1(str(changed_paths).encode()).hexdigest(),
            },
        )

    s3.put_object(
        Bucket=bucket,
        Key=manifest_key,
        Body=json.dumps(current_manifest).encode(),
        ContentType="application/json",
    )

This script keeps a manifest of content hashes in the bucket itself, uploads only files whose hash has changed, sets long-lived immutable caching on hashed static assets while forcing HTML to revalidate on every request, and invalidates only the specific paths that changed rather than the whole distribution. It's a pattern that scales well: a documentation site with thousands of pages doesn't need a full-distribution invalidation for a one-line fix to a single page.

Security, HTTPS, and Access Control

The single most important security decision in this architecture is whether the S3 bucket is reachable directly by end users at all. There are two legitimate patterns, and conflating them is where most misconfigured deployments come from. The older, simpler pattern uses S3's native static website hosting feature, which requires the bucket (or specific objects in it) to be publicly readable over plain HTTP; CloudFront then sits in front of it as a cache and TLS terminator, but the origin itself remains directly accessible if someone finds the S3 website URL. The current, AWS-recommended pattern uses a private bucket with Origin Access Control (OAC), where CloudFront authenticates to S3 using a signed service-to-service mechanism and S3's bucket policy only permits requests from that specific CloudFront distribution. OAC superseded the older Origin Access Identity (OAI) mechanism and is what AWS now recommends for all new distributions, since it supports all S3 encryption types and all AWS regions, including those OAI could not reach.

Choosing the private-bucket-plus-OAC pattern has a structural benefit beyond "feels more secure": it makes CloudFront the only path to your content, which means every security control you configure at the CDN layer - geographic restrictions, signed URLs for gated content, WAF rules, rate-based throttling - is actually enforced, because there's no side door. If the bucket is public, a determined user (or a bot scraping S3 website endpoints en masse, which happens more than most teams expect) can bypass CloudFront entirely and hit the origin directly, ignoring every control you thought you'd put in place at the edge.

HTTPS enforcement is comparatively simple once ACM and CloudFront are wired together correctly, but two operational habits matter here. The certificate should be requested with DNS validation rather than email validation, both because it can be fully automated (Route 53 can host the CNAME validation record CDK creates for you, as in the example above) and because email validation depends on a mailbox existing at the domain, which is a fragile assumption for anything beyond a personal project. And the CloudFront behavior's viewer protocol policy should be set to redirect HTTP to HTTPS rather than simply allowing both - leaving plain HTTP available, even as a fallback, is an unnecessary attack surface for a static site with no reason to ever serve content unencrypted.

For content that genuinely needs to be restricted - an internal design system, a paywalled resource, a preview environment - CloudFront supports signed URLs and signed cookies, generated with a key pair you control, which let you grant time-limited access to specific paths without making any part of the distribution public. This is a meaningfully different mechanism from IAM-based access control and is worth knowing about even if most static marketing sites never need it, because reaching for a full authentication service when signed URLs would suffice is a common source of unnecessary complexity.

Advanced Patterns

Beyond the baseline setup, a handful of patterns come up repeatedly once a static site moves from "working" to "production-grade," and they're worth understanding before you need them rather than while debugging them under time pressure.

Single-page application routing. Client-side routers (React Router, Vue Router, and similar) expect the server to return index.html for any path the app might handle client-side - /dashboard/settings, for instance - because the actual routing decision happens in JavaScript after the page loads. S3 has no concept of this; a request for /dashboard/settings simply won't match an object with that key and will return a 404 or 403 depending on configuration. The fix, shown in the CDK example earlier, is to configure CloudFront's custom error responses to rewrite both 403 and 404 responses from the origin into a 200 response serving /index.html, letting the client-side router take over from there. The trade-off is that genuinely missing resources - a mistyped image URL, for example - will now also silently serve the app shell instead of a real 404, which is usually an acceptable cost but is worth being deliberate about rather than discovering by accident.

CI/CD integration. The build-sync-invalidate sequence shown earlier is almost always automated behind a git push. A typical pipeline builds the static assets (npm run build, hugo, mkdocs build, or equivalent), uploads them to S3 using either the AWS CLI (aws s3 sync ./dist s3://bucket-name --delete) or a scripted approach like the Python example above, and then invalidates the relevant CloudFront paths. AWS CodePipeline and CodeBuild can run this natively within AWS, while GitHub Actions and GitLab CI are equally common choices when the rest of a team's tooling already lives there; the important architectural point is that IAM permissions for this pipeline should be scoped narrowly - s3:PutObject/s3:DeleteObject on the specific bucket and cloudfront:CreateInvalidation on the specific distribution - rather than broad account-level access, since a compromised CI credential with narrow scope is a contained incident rather than an account-wide one.

Multi-region resilience and failover. S3 buckets are regional, but CloudFront is global, which means the CDN layer already provides latency-based distribution without any extra configuration. For teams that need origin-level resilience - protection against an entire AWS region being unavailable, not just an edge location - S3 supports Cross-Region Replication (CRR) to keep a secondary bucket in another region synchronized, and CloudFront's origin groups feature lets you define a primary and secondary origin with automatic failover based on configurable HTTP status codes. This is meaningfully more infrastructure than most static sites need, and it's worth being honest about whether the failure mode being protected against - a full regional S3 outage - is likely enough to justify the added complexity and cost for your specific site.

Edge compute for genuinely dynamic behavior. CloudFront Functions (lightweight, JavaScript, sub-millisecond execution, ideal for header manipulation or simple redirects) and Lambda@Edge (heavier, supports Node.js and Python, can call other services) let you inject logic into the request or response path without standing up an origin server. A/B testing via cookie-based routing, geolocation-based redirects, and request header normalization are common uses. This is the point at which "static hosting" starts to blur into "edge-first application architecture," and it's a reasonable place to stop and ask whether the added logic still belongs at the edge or whether it's a sign the site has outgrown a purely static model.

Trade-offs and Common Pitfalls

No architecture is free of trade-offs, and it's worth being direct about where this one has real limits rather than presenting it as a universal answer. The most fundamental constraint is that this stack has no server-side compute of its own. Anything that requires session state, server-rendered personalization per request, or direct database access has to live somewhere else - typically an API behind API Gateway and Lambda, or a container service - with the static site calling out to it. That's a perfectly normal architecture (it's the "Jamstack" pattern), but it does mean the static hosting layer is only ever part of the picture for anything beyond a purely informational site, and teams sometimes underestimate how much of their "static site" work is actually going to be spent designing that API layer.

Cache invalidation and staleness are the other recurring source of pain, and they tend to show up in ways that are confusing to debug precisely because they're intermittent. A misconfigured cache policy, a deploy that forgot to invalidate, or a browser that cached a response longer than intended can all produce the same symptom: some users see the old version of the site while others see the new one, seemingly at random, depending on which edge location and which browser cache each request happened to hit. The fix is procedural rather than architectural - treat cache invalidation as a mandatory, automated step of every deploy, never a manual afterthought - but the failure mode is genuinely disorienting the first time a team encounters it, and it's worth explaining to new team members before they spend an afternoon debugging what looks like a flaky CDN but is actually a missing invalidation step.

Cost Model

One of the more persuasive arguments for this architecture, especially for engineering leaders evaluating it against a server-based alternative, is how the costs behave under real traffic patterns. S3 charges for storage (a small monthly rate per GB), for requests (PUT/GET/LIST operations, priced per thousand requests), and for data transfer out to the internet - though transfer from S3 to CloudFront specifically is free, which is a deliberate design incentive toward exactly this architecture. For a typical static site - even one with substantial image assets - storage costs are usually a rounding error; a site with several gigabytes of assets costs cents per month to store.

CloudFront's pricing is primarily driven by data transfer out to viewers and by the number of HTTP/HTTPS requests served, both billed per region (CloudFront divides the world into pricing tiers, roughly corresponding to geographic regions, with North America and Europe typically the cheapest). The PriceClass setting used in the CDK example above - PRICE_CLASS_100 restricts distribution to the lowest-cost edge locations, mainly North America and Europe - is a legitimate lever for cost control if your audience is concentrated in those regions, at the cost of slightly higher latency for users elsewhere. AWS also provides a CloudFront free tier allotment for new accounts, and separately, the first 1 TB of data transfer out and first 10,000,000 HTTP/HTTPS requests per month have historically been covered under AWS's broader free tier for the first twelve months, though free tier terms are subject to change and should be checked against current AWS pricing pages rather than assumed.

Route 53 is the one line item that doesn't scale with traffic at all: each hosted zone costs a flat monthly fee (on the order of fifty cents), plus a small per-million-query charge for DNS resolution, which for the vast majority of sites amounts to a few dollars a year regardless of how much traffic the site actually gets, since DNS queries are cached by resolvers far more aggressively than HTTP responses are. Put together, a low-to-moderate traffic static site - a blog, a documentation portal, a marketing page - typically runs for a few dollars a month total, and the cost scales roughly linearly with traffic rather than requiring a step-function jump in spend when a new server tier becomes necessary, which is the cost profile most teams actually want for content that isn't yet proven to need heavy investment.

Best Practices

Several habits separate a static site that was set up correctly once from one that stays correctly configured as it grows and changes hands across a team. The first is treating the entire stack as code from day one rather than as a console configuration, for the reason touched on earlier: the dependency graph between certificate, distribution, DNS record, and bucket policy is easy to get subtly wrong by hand, and infrastructure-as-code makes the correct order the only order that's possible to express. This also means that recreating the stack in a disaster-recovery scenario, or replicating it for a staging environment, is a matter of changing a few parameters rather than repeating a multi-step console walkthrough from memory.

The second habit is being deliberate about cache behavior per content type rather than applying one blanket policy to the whole site. HTML documents, which determine what version of the app or content a user sees, should generally be set to revalidate frequently or not be cached at the browser level at all, while content-hashed JavaScript, CSS, and image bundles - where the filename itself changes whenever the content does - can be cached essentially forever, since a stale cache of an immutable file is a contradiction in terms. Getting this distinction right, as shown in the Python deployment script earlier, is what allows a team to invalidate the smallest possible set of paths on each deploy rather than defaulting to a full-distribution invalidation out of caution.

Finally, observability shouldn't be an afterthought bolted on after an incident. CloudFront supports both real-time logs and standard access logs delivered to S3, and enabling them from the start - even before you think you need them - means that when a stakeholder asks "why did traffic drop on Tuesday" or "is this domain actually serving over HTTPS for everyone," the data already exists rather than needing to be reconstructed after the fact. Pairing this with CloudWatch alarms on 4xx/5xx error rates from the distribution gives an early warning if a bad deploy or a misconfigured error response starts serving broken pages, which is otherwise the kind of failure that a purely static architecture can hide for longer than a traditional server would, precisely because there's no server process to crash and page someone.

Mental Models and the 80/20 Insight

If it helps to compress this architecture into a single analogy: S3 is a warehouse, CloudFront is a network of local delivery depots, and Route 53 is the postal service's address-lookup system that tells a courier which depot to route a package through. The warehouse holds the authoritative stock and is deliberately hard to walk into directly - you don't want random people wandering the warehouse floor, which is exactly what a public S3 bucket would allow. Depots keep the popular items on hand locally so that most deliveries never have to travel all the way back to the warehouse; when a depot doesn't have what's needed, it makes exactly one trip to the warehouse, restocks, and serves every future request for that item locally from then on until that stock expires or gets swapped out. The address-lookup system doesn't store or move any packages itself - it just tells every incoming request which depot network to talk to, once, before the actual delivery relationship begins.

If you strip away every advanced feature covered above, the 80% of practical value in this architecture comes from a genuinely small set of decisions: keep the S3 bucket fully private and reach it only through CloudFront using Origin Access Control; issue the ACM certificate in us-east-1 and let DNS validation and auto-renewal handle the rest; set cache behavior so that HTML revalidates and hashed static assets cache indefinitely; and automate invalidation as a mandatory step of every deploy rather than a manual afterthought. Signed URLs, edge compute, multi-region failover, and fine-grained WAF rules are real and occasionally necessary, but they're the remaining 20% that most teams should defer until a specific, demonstrated need arises rather than build in preemptively.

Key Takeaways

For engineers setting this up for the first time, or auditing an existing setup, these five steps cover the majority of what matters:

Conclusion

The combination of S3, CloudFront, and Route 53 has become something close to a default answer for static hosting on AWS not because it's the only option, but because each piece does one job well and the seams between them are unusually clean. S3 gives you storage with strong durability guarantees and no server to maintain. CloudFront gives you global distribution, TLS termination, and a caching layer that keeps the origin almost entirely out of the request path for popular content. Route 53 gives you DNS that integrates with both, including the alias-record behavior that lets a bare domain point directly at a CDN without a workaround. None of these are exotic capabilities, but the fact that they compose this cleanly, inside one account and one IAM model, is what makes the architecture worth learning properly rather than treating as a black box you configure once and never revisit.

What separates a fragile version of this setup from a durable one isn't advanced features - most sites never need signed URLs, multi-region failover, or edge compute. It's the handful of fundamentals covered here: a genuinely private origin, a certificate requested in the right region, cache behavior that matches content type, and a deployment process that treats invalidation as mandatory rather than optional. Get those right, encode them as infrastructure-as-code so they stay right as the team changes, and the rest of this architecture tends to take care of itself.

References

  1. Amazon Web Services - Hosting a static website using Amazon S3, AWS S3 User Guide. https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html
  2. Amazon Web Services - Amazon S3 Storage Classes and Durability, AWS S3 User Guide. https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-class-intro.html
  3. Amazon Web Services - What Is Amazon CloudFront?, CloudFront Developer Guide. https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Introduction.html
  4. Amazon Web Services - Restricting access to an Amazon S3 origin (Origin Access Control), CloudFront Developer Guide. https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html
  5. Amazon Web Services - Requirements for using SSL/TLS certificates with CloudFront, CloudFront Developer Guide. https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html
  6. Amazon Web Services - What Is Amazon Route 53?, Route 53 Developer Guide. https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html
  7. Amazon Web Services - Choosing between alias and non-alias records, Route 53 Developer Guide. https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html
  8. Amazon Web Services - AWS Certificate Manager User Guide. https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html
  9. Amazon Web Services - AWS Cloud Development Kit (CDK) v2 Developer Guide. https://docs.aws.amazon.com/cdk/v2/guide/home.html
  10. Amazon Web Services - Amazon CloudFront Functions, CloudFront Developer Guide. https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html
  11. Amazon Web Services - Amazon S3 pricing. https://aws.amazon.com/s3/pricing/
  12. Amazon Web Services - Amazon CloudFront pricing. https://aws.amazon.com/cloudfront/pricing/
  13. Amazon Web Services - Amazon Route 53 pricing. https://aws.amazon.com/route53/pricing/
  14. Amazon Web Services - AWS Free Tier. https://aws.amazon.com/free/
  15. Internet Engineering Task Force - RFC 9111: HTTP Caching. https://www.rfc-editor.org/rfc/rfc9111

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - intermediate - auto-graded

Comparing Jamstack-based static hosting to traditional server-based hosting, which operational responsibility is uniquely required by the server-based approach?

Choose an answer