DEV Community

Cover image for Launching Your First Serverless Website: Mastering S3 Static Hosting with AWS CDK

Launching Your First Serverless Website: Mastering S3 Static Hosting with AWS CDK

Table of Contents


Introduction

Quick Links:

Picture this: You've just built a beautiful landing page for your startup, a portfolio showcasing your work, or in our case, a sleek contact form for capturing leads. Now comes the daunting question – where do you host it? Traditional servers? That means dealing with patching, scaling, and those dreaded 3 AM alerts when traffic spikes.

What if I told you there's a way to host your website that scales automatically to millions of users, costs pennies, requires zero server management, and delivers content at lightning speed from edge locations worldwide?

Welcome to the world of serverless static hosting with AWS S3 and CloudFront.

In this first part of our Serverless Web Mastery series, we'll transform a simple contact form into a globally-distributed, production-ready website using Infrastructure as Code. No more clicking through AWS consoles – we'll define everything in TypeScript using AWS CDK.

Let's dive in!


Why Serverless Static Hosting?

Before we write any code, let's understand why this architecture is a game-changer:

πŸš€ Performance

  • Content served from 400+ CloudFront edge locations worldwide
  • Sub-100ms latency for users globally
  • Automatic compression and HTTP/2 support

πŸ’° Cost Efficiency

  • Pay only for what you use (storage + requests)
  • No idle servers burning money 24/7
  • AWS Free Tier covers most small to medium sites

πŸ”’ Security

  • HTTPS by default with CloudFront
  • S3 bucket completely private (no public access)
  • DDoS protection via AWS Shield Standard (included free!)

πŸ“ˆ Scalability

  • Handles millions of requests without configuration
  • No capacity planning or auto-scaling rules needed
  • Zero downtime deployments

πŸ› οΈ Simplicity

  • No servers to patch or manage
  • One command deployment with CDK
  • Infrastructure defined as version-controlled code

What We're Building

Throughout this series, we're building a Contact Form for Lead Generation – a real-world application that captures visitor information and stores it in a database.

In Part 1, we'll create:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   Architecture                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                     β”‚
β”‚   User  ──────►  CloudFront  ──────►  S3 Bucket     β”‚
β”‚          HTTPS      CDN         Private Storage     β”‚
β”‚                                                     β”‚
β”‚   Features:                                         β”‚
β”‚   βœ“ HTTPS everywhere                                β”‚
β”‚   βœ“ Global edge caching                             β”‚
β”‚   βœ“ Automatic compression                           β”‚
β”‚   βœ“ Origin Access Control                           β”‚
β”‚                                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

In future parts:

  • Part 2: API Gateway + Lambda for form submission
  • Part 3: DynamoDB for data persistence
  • Part 4: Full integration and production deployment

Prerequisites

Before we begin, ensure you have:

  • Node.js 22.x or later (Download)
  • AWS CLI configured with credentials (Setup Guide)
  • AWS CDK installed globally:
  npm install -g aws-cdk
Enter fullscreen mode Exit fullscreen mode
  • An AWS Account with appropriate permissions

Bootstrap Your AWS Account

If you haven't used CDK in your account before, bootstrap it:

cdk bootstrap aws://YOUR_ACCOUNT_ID/YOUR_REGION
Enter fullscreen mode Exit fullscreen mode

Replace YOUR_ACCOUNT_ID and YOUR_REGION with your values (e.g., us-east-1).


Project Setup

Let's create our project structure:

mkdir aws-serverless-website-tutorial
cd aws-serverless-website-tutorial
mkdir -p part-1-s3-static-hosting/{cdk,frontend}
cd part-1-s3-static-hosting/cdk
Enter fullscreen mode Exit fullscreen mode

Initialize the CDK project:

npx cdk init app --language typescript
Enter fullscreen mode Exit fullscreen mode

Wait – we're going to do this a bit differently. Instead of the default template, let's create a clean, well-organized structure.

Create package.json:

{
  "name": "s3-website-cdk",
  "version": "1.0.0",
  "description": "CDK stack for S3 static website hosting with CloudFront",
  "main": "bin/app.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "cdk": "cdk",
    "synth": "cdk synth",
    "deploy": "cdk deploy",
    "destroy": "cdk destroy"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.170.0",
    "constructs": "^10.4.2"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "typescript": "~5.7.0",
    "aws-cdk": "^2.170.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Creating the CDK Stack

Now for the exciting part – defining our infrastructure!

CDK Entry Point

Create bin/app.ts:

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { S3WebsiteStack } from "../lib/s3-website-stack";

const app = new cdk.App();

// Create the S3 Website Stack
new S3WebsiteStack(app, "ContactFormWebsiteStack", {
  // Add meaningful tags for resource management
  tags: {
    Project: "ServerlessWebMastery",
    Part: "1-S3StaticHosting",
    Environment: "Development",
  },
  description:
    "Serverless Web Mastery Part 1: S3 Static Website with CloudFront",
});

app.synth();
Enter fullscreen mode Exit fullscreen mode

The Main Stack

Here's where the magic happens. Create lib/s3-website-stack.ts:

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 origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import { Construct } from "constructs";
import * as path from "path";

export class S3WebsiteStack extends cdk.Stack {
  public readonly websiteBucket: s3.Bucket;
  public readonly distribution: cloudfront.Distribution;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ============================================
    // S3 Bucket for Static Website Files
    // ============================================
    this.websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      // Block ALL public access - CloudFront is our only entry point
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,

      // Enable versioning for rollback capability
      versioned: true,

      // Encryption at rest
      encryption: s3.BucketEncryption.S3_MANAGED,

      // Cleanup on stack deletion (use RETAIN for production!)
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,

      // CORS for API calls (we'll use this in Part 2)
      cors: [
        {
          allowedHeaders: ["*"],
          allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.HEAD],
          allowedOrigins: ["*"],
          maxAge: 3000,
        },
      ],
    });

    // ============================================
    // CloudFront Distribution
    // ============================================
    this.distribution = new cloudfront.Distribution(
      this,
      "WebsiteDistribution",
      {
        defaultBehavior: {
          // Origin Access Control - the modern, secure way
          origin: origins.S3BucketOrigin.withOriginAccessControl(
            this.websiteBucket,
          ),

          // Always redirect HTTP to HTTPS
          viewerProtocolPolicy:
            cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,

          // Optimized caching for static content
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,

          // Allow standard methods
          allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,

          // Enable compression
          compress: true,
        },

        // Default page
        defaultRootObject: "index.html",

        // Handle SPA routing - return index.html for 404s
        errorResponses: [
          {
            httpStatus: 403,
            responseHttpStatus: 200,
            responsePagePath: "/index.html",
            ttl: cdk.Duration.minutes(5),
          },
          {
            httpStatus: 404,
            responseHttpStatus: 200,
            responsePagePath: "/index.html",
            ttl: cdk.Duration.minutes(5),
          },
        ],

        // Use cheaper edge locations (US, Canada, Europe)
        priceClass: cloudfront.PriceClass.PRICE_CLASS_100,

        // Enable HTTP/2 and HTTP/3 for better performance
        httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,

        comment: "Contact Form Static Website - Serverless Web Mastery",
      },
    );

    // ============================================
    // Deploy Frontend Files
    // ============================================
    new s3deploy.BucketDeployment(this, "DeployWebsite", {
      sources: [s3deploy.Source.asset(path.join(__dirname, "../../frontend"))],
      destinationBucket: this.websiteBucket,

      // Invalidate CloudFront cache after deployment
      distribution: this.distribution,
      distributionPaths: ["/*"],
    });

    // ============================================
    // Outputs
    // ============================================
    new cdk.CfnOutput(this, "WebsiteURL", {
      value: `https://${this.distribution.distributionDomainName}`,
      description: "Website URL (CloudFront HTTPS)",
    });

    new cdk.CfnOutput(this, "DistributionId", {
      value: this.distribution.distributionId,
      description: "CloudFront Distribution ID",
    });

    new cdk.CfnOutput(this, "BucketName", {
      value: this.websiteBucket.bucketName,
      description: "S3 Bucket Name",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Let me break down what's happening here:

S3 Bucket Configuration

blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL;
Enter fullscreen mode Exit fullscreen mode

Why? Security best practice. Our bucket is completely private. CloudFront accesses it via Origin Access Control (OAC), not public URLs.

Origin Access Control

origin: origins.S3BucketOrigin.withOriginAccessControl(this.websiteBucket);
Enter fullscreen mode Exit fullscreen mode

Why? OAC is the modern replacement for Origin Access Identity (OAI). It's more secure and supports additional features like SigV4 signing.

Error Responses for SPAs

errorResponses: [
  {
    httpStatus: 404,
    responseHttpStatus: 200,
    responsePagePath: "/index.html",
  },
];
Enter fullscreen mode Exit fullscreen mode

Why? If you're building a Single Page Application (SPA) with client-side routing, this ensures that deep links like /about or /contact return index.html instead of a 404.


Building the Frontend

Our contact form needs three files. Here's a glimpse (see the GitHub repository for complete code):

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Contact Us | Lead Generation Form</title>
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <!-- Hero Section -->
    <section class="hero">
      <h1>Get in Touch with <span class="gradient-text">Our Team</span></h1>
      <p>Fill out the form below and we'll get back to you within 24 hours.</p>
    </section>

    <!-- Contact Form -->
    <form id="contactForm" class="contact-form">
      <div class="form-group">
        <label for="name">Full Name</label>
        <input type="text" id="name" required />
      </div>

      <div class="form-group">
        <label for="email">Email Address</label>
        <input type="email" id="email" required />
      </div>

      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" required></textarea>
      </div>

      <button type="submit">Send Message</button>
    </form>

    <script src="app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The complete frontend includes:

  • Modern glassmorphism design
  • Dark theme with gradient animations
  • Form validation
  • Responsive layout
  • Accessibility features

Deployment

With everything in place, deployment is a single command:

cd part-1-s3-static-hosting/cdk
npm install
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

You'll see output like:

βœ…  ContactFormWebsiteStack

Outputs:
ContactFormWebsiteStack.WebsiteURL = https://d1234abcd.cloudfront.net
ContactFormWebsiteStack.BucketName = contactformwebsitestack-websitebucket-xyz123
ContactFormWebsiteStack.DistributionId = E1234ABCD
Enter fullscreen mode Exit fullscreen mode

Open the WebsiteURL – your website is live! πŸŽ‰


Custom Domain Setup

Want to use your own domain like contact.yourcompany.com? Here's how:

Prerequisites

  1. Domain registered in Route 53 (or hosted zone created)
  2. ACM certificate in us-east-1 (required for CloudFront)

Code Changes

Add to your stack:

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";

// Custom domain configuration
const domainName = "contact.yourcompany.com";
const hostedZoneDomain = "yourcompany.com";

// Look up your hosted zone
const hostedZone = route53.HostedZone.fromLookup(this, "Zone", {
  domainName: hostedZoneDomain,
});

// Create SSL certificate
const certificate = new acm.Certificate(this, "Certificate", {
  domainName: domainName,
  validation: acm.CertificateValidation.fromDns(hostedZone),
});

// Add to CloudFront distribution
this.distribution = new cloudfront.Distribution(this, "WebsiteDistribution", {
  // ... existing config ...
  domainNames: [domainName],
  certificate: certificate,
});

// Create DNS record pointing to CloudFront
new route53.ARecord(this, "AliasRecord", {
  zone: hostedZone,
  recordName: "contact",
  target: route53.RecordTarget.fromAlias(
    new targets.CloudFrontTarget(this.distribution),
  ),
});
Enter fullscreen mode Exit fullscreen mode

Gotchas & Common Pitfalls

1. CloudFront Propagation Time

Problem: After deployment, the website shows "Access Denied" or returns 403.

Solution: CloudFront distributions take 5-15 minutes to propagate. Wait and retry.

2. Caching Issues

Problem: Updated files aren't appearing on the website.

Solution: Our CDK deployment automatically invalidates the cache. If you manually upload files, create an invalidation:

aws cloudfront create-invalidation \
  --distribution-id YOUR_DIST_ID \
  --paths "/*"
Enter fullscreen mode Exit fullscreen mode

3. Region Mismatch for ACM

Problem: ACM certificate not found when adding custom domain.

Solution: CloudFront requires certificates in us-east-1. Create your certificate there, even if your stack is in another region:

const certificate = acm.Certificate.fromCertificateArn(
  this,
  "Certificate",
  "arn:aws:acm:us-east-1:ACCOUNT:certificate/CERT_ID",
);
Enter fullscreen mode Exit fullscreen mode

4. S3 Bucket Already Exists

Problem: Deployment fails with "bucket already exists."

Solution: Bucket names are globally unique. Either:

  • Let CDK auto-generate the name (default)
  • Choose a unique name with your account/project prefix

Best Practices

1. Use Origin Access Control (OAC), Not OAI

OAC is the modern, more secure approach. It supports:

  • KMS encryption on S3
  • All S3 regions
  • Additional request headers

2. Enable Versioning for Rollbacks

versioned: true;
Enter fullscreen mode Exit fullscreen mode

If a deployment goes wrong, you can restore previous versions.

3. Set Appropriate Cache TTLs

CloudFront caches by default. For frequently changing content, consider:

cachePolicy: new cloudfront.CachePolicy(this, 'CustomCachePolicy', {
  defaultTtl: cdk.Duration.hours(1),
  maxTtl: cdk.Duration.days(1),
  minTtl: cdk.Duration.minutes(1),
}),
Enter fullscreen mode Exit fullscreen mode

4. Use DESTROY Only for Development

For production, use:

removalPolicy: cdk.RemovalPolicy.RETAIN;
Enter fullscreen mode Exit fullscreen mode

This prevents accidental data loss.


Cost Considerations

Let's break down the costs:

Service Free Tier Typical Cost
S3 Storage 5GB/month $0.023/GB
S3 Requests 20K GET, 2K PUT $0.0004 per 1K GET
CloudFront Transfer 1TB/month $0.085/GB (to Internet)
CloudFront Requests 10M/month $0.0075 per 10K HTTPS

Real-World Example

For a small contact form website with:

  • 100MB of static files
  • 50,000 monthly visitors
  • 5 requests per visit

Estimated Monthly Cost: $0 (well within free tier!)

For a high-traffic site with 1 million monthly visitors: ~$15-25/month


Conclusion

Congratulations! πŸŽ‰ You've just deployed a production-ready static website with:

βœ… HTTPS encryption (free!)
βœ… Global CDN distribution
βœ… Automatic scaling
βœ… Infrastructure as Code
βœ… One-command deployments

This is just the beginning. In Part 2, we'll add a serverless backend with API Gateway and Lambda to actually process those form submissions.

If you liked this blog and found it helpful, check out my other AWS blogs:

GitHub Repository: aws-serverless-website-tutorial

See you until next time. Happy coding! πŸš€


References

Top comments (0)