Table of Contents
- Introduction
- Why Serverless Static Hosting?
- What We're Building
- Prerequisites
- Project Setup
- Creating the CDK Stack
- Building the Frontend
- Deployment
- Custom Domain Setup
- Gotchas & Common Pitfalls
- Best Practices
- Cost Considerations
- Conclusion
- References
Introduction
Quick Links:
- π Source Code: GitHub Repository
- π Live Demo: https://dmcechq7isaw7.cloudfront.net/
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 β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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
- 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
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
Initialize the CDK project:
npx cdk init app --language typescript
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"
}
}
Install dependencies:
npm install
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();
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",
});
}
}
Let me break down what's happening here:
S3 Bucket Configuration
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL;
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);
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",
},
];
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>
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
You'll see output like:
β
ContactFormWebsiteStack
Outputs:
ContactFormWebsiteStack.WebsiteURL = https://d1234abcd.cloudfront.net
ContactFormWebsiteStack.BucketName = contactformwebsitestack-websitebucket-xyz123
ContactFormWebsiteStack.DistributionId = E1234ABCD
Open the WebsiteURL β your website is live! π
Custom Domain Setup
Want to use your own domain like contact.yourcompany.com? Here's how:
Prerequisites
- Domain registered in Route 53 (or hosted zone created)
- 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),
),
});
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 "/*"
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",
);
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;
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),
}),
4. Use DESTROY Only for Development
For production, use:
removalPolicy: cdk.RemovalPolicy.RETAIN;
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:
- Lost in AWS CDK? Here's Your Map for Debugging Like a Pro
- AWS Step Functions: The Conductor of Your Microservices Orchestra
- Unlocking DynamoDB's Hidden Potential: Batch Operations Mastery
GitHub Repository: aws-serverless-website-tutorial
See you until next time. Happy coding! π
Top comments (0)