Deploy a 3-Tier App on AWS
Bring together everything from this course: a production-grade 3-tier application deployed with Python CDK, secured with Cognito, and observable through CloudWatch.
What You'll Build
A three-tier architecture: a static React frontend served by CloudFront, a serverless API layer using API Gateway and Lambda with Cognito JWT authentication, and a data layer with DynamoDB (for fast key-value lookups) and Aurora PostgreSQL Serverless v2 (for relational data) - all inside a VPC with private subnets. The entire infrastructure is defined in Python CDK, split across three separate stacks that reference each other.
Production 3-Tier Architecture
Three CDK stacks: DataStack (VPC + DB), ApiStack (Lambda + API GW + Cognito), FrontendStack (S3 + CloudFront)
1. CDK App Structure
Splitting into three stacks makes deployments safer: you can update the API layer without touching the database stack, and redeploy the frontend without triggering a Lambda rebuild. The stacks share outputs via CDK cross-stack references:
# app.py: CDK application entry point import aws_cdk as cdk from stacks.frontend_stack import FrontendStack from stacks.api_stack import ApiStack from stacks.data_stack import DataStack app = cdk.App() env = cdk.Environment(account="123456789012", region="us-east-1") data = DataStack(app, "DataStack", env=env) api = ApiStack(app, "ApiStack", env=env, table=data.table, vpc=data.vpc) front = FrontendStack(app, "FrontendStack", env=env, api_url=api.api_url) app.synth()
2. Data Stack: VPC + DynamoDB + Aurora
# stacks/data_stack.py
import aws_cdk as cdk
from aws_cdk import aws_ec2 as ec2, aws_dynamodb as dynamodb, aws_rds as rds
from constructs import Construct
class DataStack(cdk.Stack):
def __init__(self, scope: Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
# VPC with public and private subnets across 2 AZs
self.vpc = ec2.Vpc(self, "AppVpc",
max_azs=2,
nat_gateways=1,
subnet_configuration=[
ec2.SubnetConfiguration(name="Public", subnet_type=ec2.SubnetType.PUBLIC, cidr_mask=24),
ec2.SubnetConfiguration(name="Private", subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS, cidr_mask=24),
],
)
# DynamoDB for session/cache data (on-demand billing)
self.table = dynamodb.Table(self, "AppTable",
partition_key=dynamodb.Attribute(name="pk", type=dynamodb.AttributeType.STRING),
sort_key=dynamodb.Attribute(name="sk", type=dynamodb.AttributeType.STRING),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=cdk.RemovalPolicy.RETAIN,
)
# RDS Aurora PostgreSQL Serverless v2 (scales to 0 when idle)
self.db = rds.DatabaseCluster(self, "AppDb",
engine=rds.DatabaseClusterEngine.aurora_postgres(
version=rds.AuroraPostgresEngineVersion.VER_15_4
),
writer=rds.ClusterInstance.serverless_v2("Writer"),
serverless_v2_min_capacity=0.5,
serverless_v2_max_capacity=4,
vpc=self.vpc,
vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
default_database_name="appdb",
removal_policy=cdk.RemovalPolicy.SNAPSHOT,
)3. API Stack: Lambda + API Gateway + Cognito
# stacks/api_stack.py
import aws_cdk as cdk
from aws_cdk import (
aws_lambda as lambda_,
aws_apigatewayv2 as apigwv2,
aws_apigatewayv2_integrations as integrations,
aws_cognito as cognito,
aws_apigatewayv2_authorizers as authorizers,
)
from constructs import Construct
class ApiStack(cdk.Stack):
def __init__(self, scope, id, *, table, vpc, **kwargs):
super().__init__(scope, id, **kwargs)
# Cognito User Pool for JWT auth
pool = cognito.UserPool(self, "UserPool",
self_sign_up_enabled=True,
sign_in_aliases=cognito.SignInAliases(email=True),
removal_policy=cdk.RemovalPolicy.DESTROY,
)
client = pool.add_client("WebClient",
auth_flows=cognito.AuthFlow(user_password=True, user_srp=True),
)
# Lambda function in the private subnet
fn = lambda_.Function(self, "ApiHandler",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="api.handler",
code=lambda_.Code.from_asset("lambda"),
vpc=vpc,
environment={"TABLE_NAME": table.table_name},
)
table.grant_read_write_data(fn)
# HTTP API with Cognito JWT authorizer
jwt_auth = authorizers.HttpJwtAuthorizer("Auth",
jwt_issuer=f"https://cognito-idp.us-east-1.amazonaws.com/{pool.user_pool_id}",
jwt_audience=[client.user_pool_client_id],
)
api = apigwv2.HttpApi(self, "Api",
cors_preflight=apigwv2.CorsPreflightOptions(
allow_origins=["*"],
allow_methods=[apigwv2.CorsHttpMethod.ANY],
allow_headers=["Authorization", "Content-Type"],
),
)
api.add_routes(
path="/items/{proxy+}",
methods=[apigwv2.HttpMethod.ANY],
integration=integrations.HttpLambdaIntegration("LambdaInt", fn),
authorizer=jwt_auth,
)
self.api_url = api.api_endpoint
cdk.CfnOutput(self, "ApiUrl", value=self.api_url)4. Frontend Stack: S3 + CloudFront
# stacks/frontend_stack.py
import aws_cdk as cdk
from aws_cdk import aws_s3 as s3, aws_cloudfront as cf, aws_cloudfront_origins as origins
from constructs import Construct
class FrontendStack(cdk.Stack):
def __init__(self, scope, id, *, api_url, **kwargs):
super().__init__(scope, id, **kwargs)
# S3 bucket for static assets (no public access - CloudFront only)
bucket = s3.Bucket(self, "WebBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
removal_policy=cdk.RemovalPolicy.DESTROY,
auto_delete_objects=True,
)
# CloudFront with OAC to S3
oac = cf.S3OriginAccessControl(self, "OAC")
dist = cf.Distribution(self, "Dist",
default_behavior=cf.BehaviorOptions(
origin=origins.S3BucketOrigin.with_origin_access_control(bucket, oac),
viewer_protocol_policy=cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
),
additional_behaviors={
"/api/*": cf.BehaviorOptions(
origin=origins.HttpOrigin(api_url.replace("https://", "")),
allowed_methods=cf.AllowedMethods.ALLOW_ALL,
cache_policy=cf.CachePolicy.CACHING_DISABLED,
),
},
default_root_object="index.html",
error_responses=[
cf.ErrorResponse(http_status=404, response_page_path="/index.html",
response_http_status=200),
],
)
cdk.CfnOutput(
self, "DistributionUrl",
value=f"https://{dist.distribution_domain_name}",
)5. Production Readiness Checklist
Security
- IAM roles follow least privilege - no wildcard actions on production resources
- Secrets in SSM Parameter Store or Secrets Manager, not environment variables
- CloudFront enforces HTTPS; S3 bucket blocks all public access
- Cognito JWT required on all API routes; unauthenticated requests return 401
- VPC: Lambda and RDS in private subnets; no public IP on compute
Reliability
- Multi-AZ: VPC spans 2 AZs; Aurora has failover replica
- DLQ configured on any SQS queue; CloudWatch alarm on DLQ message count
- Lambda concurrency reserved to prevent noisy-neighbor issues
- CloudFront caches static assets globally; origin is S3 not EC2
Observability
- CloudWatch alarms on Lambda error rate, API Gateway 5xx, RDS CPU
- Structured JSON logs from Lambda (easier to query in Log Insights)
- CloudTrail enabled in all regions; logs shipped to S3 with lifecycle policy
- Budget alert set at 80% of monthly target
Cost
- Aurora Serverless v2 scales to 0.5 ACU minimum (no always-on cost for dev)
- DynamoDB on-demand billing (no provisioned throughput to manage)
- NAT Gateway: one per AZ is standard; for dev, one shared NAT is acceptable
- All resources tagged with Project, Env, Owner for cost attribution
6. Deploying the Full Stack
# Bootstrap (one-time per account/region) cdk bootstrap aws://123456789012/us-east-1 # Preview everything that will be created cdk diff --all # Deploy in dependency order (CDK handles this automatically) cdk deploy --all --require-approval never # After deployment, the outputs section shows: # DataStack.VpcId = vpc-xxxxxxxxxxxxxxxxx # ApiStack.ApiUrl = https://xxxxxxxx.execute-api.us-east-1.amazonaws.com # FrontendStack.DistributionUrl = https://xxxx.cloudfront.net # Deploy only the API layer (e.g., after a Lambda code change) cdk deploy ApiStack --require-approval never # Build and upload the React frontend npm run build aws s3 sync dist/ s3://$(aws cloudformation describe-stacks \ --stack-name FrontendStack \ --query 'Stacks[0].Outputs[?OutputKey==`BucketName`].OutputValue' \ --output text) --delete aws cloudfront create-invalidation \ --distribution-id $(aws cloudformation describe-stacks \ --stack-name FrontendStack \ --query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \ --output text) \ --paths "/*"
Course Complete!
You've gone from cloud fundamentals to a production 3-tier deployment with IAM, VPC, Lambda, DynamoDB, Aurora, CloudFront, Cognito, and CDK. The pattern you've built here scales from a side project to a startup's production backend. Keep iterating: add CI/CD with GitHub Actions, wire up CloudWatch alarms, and explore AWS Organizations for multi-account setups.
What You Built
- Three CDK stacks - DataStack, ApiStack, FrontendStack - each independently deployable, passing outputs via cross-stack references
- VPC with private subnets - Lambda and Aurora run in private subnets with no public internet exposure
- Cognito JWT auth - API Gateway validates tokens before forwarding to Lambda; no auth logic in application code
- Dual data stores - DynamoDB for low-latency key-value access; Aurora Serverless v2 for relational queries; both grant read/write to Lambda
- CloudFront edge delivery - static assets cached globally; API requests forwarded to the same domain, avoiding CORS issues
- Production-ready defaults - HTTPS enforced, S3 public access blocked, Secrets Manager for credentials, retention policies on databases