Capstone ProjectIn Progress

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

PRIVATE SUBNETS (VPC)USRUsersCFCloudFrontS3S3 (Frontend)COGCognitoJWT authAGWAPI GatewayλLambda HandlerDDBDynamoDBAuAurora ServerlessPostgreSQLSVCCloudWatchlogs + alarmsHTTPSstatic assets/api/*validates JWTproxyGetItem/PutSQLlogs

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