Table of Contents
- Introduction
- What We've Built
- Full Stack Architecture
- The Complete CDK Stack
- Frontend Integration
- One-Command Deployment
- Production Hardening
- Monitoring & Observability
- Cost Analysis
- Next Steps
- Conclusion
- References
Introduction
Quick Links:
- 📂 Source Code: GitHub Repository
- 🚀 Live Demo: https://dmcechq7isaw7.cloudfront.net/
We've come a long way, haven't we?
In Part 1, we launched a static website with S3 and CloudFront.
In Part 2, we built a REST API with API Gateway and Lambda.
In Part 3, we added data persistence with DynamoDB.
But here's the thing – they were separate pieces. Three different stacks. Three deployments. What if I told you we could combine everything into a single, elegant, production-ready deployment?
One command. One stack. Complete application.
This is the grand finale of our Serverless Web Mastery series. Today, we're going to:
- Combine all components into a single CDK stack
- Configure CloudFront to route between frontend and API
- Implement production-ready patterns
- Deploy everything with one command
- Discuss monitoring, security, and cost optimization
Let's ship this thing to production! 🚀
What We've Built
By the end of this tutorial, you'll have:
┌─────────────────────────────────────────────────────────────┐
│ Contact Form for Lead Generation │
├─────────────────────────────────────────────────────────────┤
│ │
│ Production HTTPS website via CloudFront │
│ REST API with full CRUD operations │
│ Persistent data storage in DynamoDB │
│ Automatic scaling from 0 to millions of users │
│ Infrastructure as Code with AWS CDK │
│ ~$0-2/month for small to medium traffic │
│ │
└─────────────────────────────────────────────────────────────┘
Features
For Users:
- Beautiful, responsive contact form
- Instant form submission
- Success/error feedback
- Mobile-friendly design
For Developers:
- One-command deployment
- Environment-agnostic configuration
- Structured logging
- Health check endpoints
For Admins:
- Query leads by email, status, or ID
- Update lead status (new → contacted → converted)
- Delete processed leads
- View all leads with filters
Full Stack Architecture
Here's our complete architecture:
┌──────────────────┐
│ Users │
└────────┬─────────┘
│
▼
┌──────────────────────────┐
│ CloudFront │
│ (HTTPS, Caching) │
└──────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
/static/* ──────────────┐ │ ┌────────────── /api/*
│ │ │ │ │
▼ │ │ │ ▼
┌───────────────┐ │ │ │ ┌───────────────┐
│ S3 Bucket │ │ │ │ │ API Gateway │
│ │ │ │ │ │ (REST) │
│ index.html │ │ │ │ └───────┬───────┘
│ styles.css │ │ │ │ │
│ app.js │ │ │ │ ▼
└───────────────┘ │ │ │ ┌───────────────┐
│ │ │ │ Lambda │
│ │ │ │ Functions │
│ │ │ └───────┬───────┘
│ │ │ │
│ │ │ ▼
│ │ │ ┌───────────────┐
│ │ │ │ DynamoDB │
│ │ │ │ Table │
│ │ │ └───────────────┘
│ │ │
└───┴───┘
Why CloudFront for Both?
Traditional approach:
- Frontend:
https://d1234.cloudfront.net - API:
https://abc123.execute-api.amazonaws.com - Problem: CORS headaches, separate deployments, inconsistent caching
Our approach:
- Frontend:
https://d1234.cloudfront.net/* - API:
https://d1234.cloudfront.net/api/* - Benefits: Same domain = no CORS, unified caching, simpler code
The Complete CDK Stack
Here's our unified stack:
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 * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";
export class FullIntegrationStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ============================================
// Data Layer
// ============================================
const leadsTable = new dynamodb.Table(this, "LeadsTable", {
tableName: "ContactFormLeads-Prod",
partitionKey: { name: "leadId", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: cdk.RemovalPolicy.RETAIN, // Keep data!
});
// GSIs for query patterns
leadsTable.addGlobalSecondaryIndex({
indexName: "email-index",
partitionKey: { name: "email", type: dynamodb.AttributeType.STRING },
sortKey: { name: "createdAt", type: dynamodb.AttributeType.STRING },
});
// ============================================
// API Layer
// ============================================
const leadsHandler = new lambdaNodejs.NodejsFunction(this, "LeadsHandler", {
runtime: lambda.Runtime.NODEJS_22_X,
entry: path.join(__dirname, "../../lambda/handlers/leads.ts"),
handler: "handler",
description: "Leads CRUD operations with DynamoDB",
environment: {
TABLE_NAME: leadsTable.tableName,
},
bundling: {
minify: true,
sourceMap: true,
},
});
leadsTable.grantReadWriteData(leadsHandler);
const api = new apigateway.RestApi(this, "Api", {
restApiName: "Contact Form API",
deployOptions: { stageName: "v1" },
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
},
});
const apiResource = api.root.addResource("api");
const leads = apiResource.addResource("leads");
leads.addMethod(
"POST",
new apigateway.LambdaIntegration(leadsHandler, { proxy: true }),
);
leads.addMethod(
"GET",
new apigateway.LambdaIntegration(leadsHandler, { proxy: true }),
);
// ============================================
// Frontend Layer
// ============================================
const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// ============================================
// CloudFront: Unified Entry Point
// ============================================
const distribution = new cloudfront.Distribution(this, "Distribution", {
defaultRootObject: "index.html",
// Default: S3 static files
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(websiteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
// /api/*: Route to API Gateway
additionalBehaviors: {
"/api/*": {
origin: new origins.HttpOrigin(
`${api.restApiId}.execute-api.${this.region}.amazonaws.com`,
{ originPath: "/v1" },
),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
originRequestPolicy:
cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
},
},
});
// Deploy frontend
new s3deploy.BucketDeployment(this, "Deploy", {
sources: [s3deploy.Source.asset("../frontend")],
destinationBucket: websiteBucket,
distribution,
distributionPaths: ["/*"],
});
// Outputs
new cdk.CfnOutput(this, "URL", {
value: `https://${distribution.domainName}`,
});
}
}
Key Insight: CloudFront Path Routing
The magic is in additionalBehaviors:
additionalBehaviors: {
'/api/*': {
origin: new origins.HttpOrigin(
`${api.restApiId}.execute-api.${this.region}.amazonaws.com`,
{ originPath: '/v1' }
),
// ...
},
},
This routes:
-
https://cloudfronturl.com/api/leads→https://apigateway/v1/leads
The frontend simply calls /api/leads – no hard-coded URLs!
Frontend Integration
Smart API Configuration
const CONFIG = {
// Automatically detect environment
API_ENDPOINT:
window.location.hostname === "localhost"
? "http://localhost:3000" // Local dev
: "/api", // Production
};
Retry with Exponential Backoff
async function submitToAPI(formData, attempt = 1) {
try {
const response = await fetch(`${CONFIG.API_ENDPOINT}/leads`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
if (attempt < 3) {
// Exponential backoff: 1s, 2s, 4s
await delay(1000 * Math.pow(2, attempt - 1));
return submitToAPI(formData, attempt + 1);
}
throw error;
}
}
One-Command Deployment
cd part-4-full-integration/cdk
npm install
npx cdk deploy
That's it. One command. Everything deployed.
Output:
✅ ContactFormFullStack
Outputs:
ContactFormFullStack.WebsiteURL = https://d1234567abcdef.cloudfront.net
ContactFormFullStack.ApiEndpoint = https://abc123xyz.execute-api.us-east-1.amazonaws.com/v1/
ContactFormFullStack.TableName = ContactFormLeads-Prod
Open the WebsiteURL – your application is live! 🎉
Production Hardening
Before going live, consider these enhancements:
1. Custom Domain
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
// Create certificate in us-east-1
const certificate = new acm.Certificate(this, 'Cert', {
domainName: 'contact.yourdomain.com',
validation: acm.CertificateValidation.fromDns(hostedZone),
});
// Add to distribution
domainNames: ['contact.yourdomain.com'],
certificate,
2. WAF Protection
import * as wafv2 from "aws-cdk-lib/aws-wafv2";
const webAcl = new wafv2.CfnWebACL(this, "WebAcl", {
scope: "CLOUDFRONT",
defaultAction: { allow: {} },
rules: [
{
name: "RateLimitRule",
priority: 1,
action: { block: {} },
statement: {
rateBasedStatement: {
limit: 1000, // requests per 5 minutes
aggregateKeyType: "IP",
},
},
visibilityConfig: {
/* ... */
},
},
],
});
3. Specific CORS Origins
defaultCorsPreflightOptions: {
allowOrigins: ['https://contact.yourdomain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
},
4. Lambda Security
// Principle of least privilege
leadsTable.grantReadWriteData(leadsHandler); // ✅ Specific table
// NOT: grantFullAccess() // ❌ Too broad
Monitoring & Observability
CloudWatch Dashboard
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
const dashboard = new cloudwatch.Dashboard(this, "Dashboard", {
dashboardName: "ContactForm-Production",
});
dashboard.addWidgets(
new cloudwatch.GraphWidget({
title: "API Requests",
left: [api.metricCount()],
}),
new cloudwatch.GraphWidget({
title: "Lambda Duration",
left: [leadsHandler.metricDuration()],
}),
new cloudwatch.GraphWidget({
title: "DynamoDB Throttles",
left: [leadsTable.metricThrottledRequests()],
}),
);
Alarms
new cloudwatch.Alarm(this, "ErrorAlarm", {
metric: leadsHandler.metricErrors(),
threshold: 10,
evaluationPeriods: 1,
alarmDescription: "Lambda errors exceeded threshold",
});
Cost Analysis
Let's break down the total monthly cost:
| Service | Usage | Free Tier | Cost |
|---|---|---|---|
| S3 | 100MB, 50K requests | 5GB, 20K GET | $0.02 |
| CloudFront | 10GB, 100K requests | 1TB, 10M | $0.00 |
| API Gateway | 50K requests | 1M | $0.00 |
| Lambda | 50K invocations, 5s average | 1M, 400K GB-s | $0.00 |
| DynamoDB | 1GB, 50K writes | 25GB, 25 WCU | $0.00 |
| CloudWatch | Basic metrics | Always free | $0.00 |
| Total | ~$0.02/month |
For a high-traffic site (1M monthly visitors): ~$10-20/month
Compare to traditional hosting: $50-200/month minimum
Next Steps
Now that you've mastered serverless web development, consider:
1. Add Email Notifications
Use SES to send confirmation emails when leads are created.
2. Implement Authentication
Add Cognito for admin dashboard access.
3. Add File Uploads
Accept resume/document uploads stored in S3.
4. Build an Admin Dashboard
Create a separate protected area to manage leads.
5. Add Analytics
Integrate CloudWatch Logs Insights or third-party analytics.
Conclusion
🎉 Congratulations! You've completed the Serverless Web Mastery series!
You've built a complete, production-ready serverless application:
✅ Part 1: S3 + CloudFront for static hosting
✅ Part 2: API Gateway + Lambda for serverless API
✅ Part 3: DynamoDB for persistent storage
✅ Part 4: Full integration with production patterns
What you've learned:
- Infrastructure as Code with AWS CDK
- Modern frontend development
- REST API design
- NoSQL database patterns
- Cost optimization techniques
- Production deployment best practices
The best part? This entire stack follows the serverless-first approach:
- Zero servers to manage
- Automatic scaling
- Pay-per-use pricing
- Built-in high availability
Related Posts:
- Lost in AWS CDK? Debugging Guide
- AWS Step Functions: Orchestrating Microservices
- DynamoDB Batch Operations Mastery
GitHub Repository: aws-serverless-website-tutorial
Thank you for joining me on this serverless journey. Now go build something amazing!
Happy coding! 🚀
Top comments (0)