import * as cdk from "aws-cdk-lib"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as ecr from "aws-cdk-lib/aws-ecr"; import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; import * as logs from "aws-cdk-lib/aws-logs"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as iam from "aws-cdk-lib/aws-iam"; import { Construct } from "constructs"; interface EcsServiceStackProps extends cdk.StackProps { /** * Environment name (dev, staging, prod) */ environment: string; /** * VPC to deploy services in */ vpc: ec2.IVpc; /** * ACM certificate for HTTPS */ certificate: acm.ICertificate; /** * Backend ECR repository */ backendRepository: ecr.IRepository; /** * Frontend ECR repository */ frontendRepository: ecr.IRepository; /** * Database credentials secret */ databaseSecret: secretsmanager.ISecret; /** * Application secrets (JWT, etc.) */ appSecret: secretsmanager.ISecret; /** * Database security group (to allow ECS -> RDS access) */ databaseSecurityGroup: ec2.ISecurityGroup; /** * Database endpoint */ dbEndpoint: string; /** * Database port */ dbPort: number; /** * Database name */ dbName: string; /** * Domain name for the environment (e.g., dev.village-share.com) */ domainName: string; /** * IP address to restrict ALB access to (CIDR format, e.g., "1.2.3.4/32") * If not provided, ALB is open to the internet */ allowedIp?: string; /** * Frontend URL for CORS configuration */ frontendUrl: string; } export class EcsServiceStack extends cdk.Stack { /** * The ECS cluster */ public readonly cluster: ecs.Cluster; /** * The Application Load Balancer */ public readonly alb: elbv2.ApplicationLoadBalancer; /** * Backend ECS service */ public readonly backendService: ecs.FargateService; /** * Frontend ECS service */ public readonly frontendService: ecs.FargateService; constructor(scope: Construct, id: string, props: EcsServiceStackProps) { super(scope, id, props); const { environment, vpc, certificate, backendRepository, frontendRepository, databaseSecret, appSecret, databaseSecurityGroup, dbEndpoint, dbPort, dbName, domainName, allowedIp, frontendUrl, } = props; // ECS Cluster with Container Insights this.cluster = new ecs.Cluster(this, "Cluster", { clusterName: `rentall-cluster-${environment}`, vpc, containerInsights: true, }); // ALB Security Group const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", { vpc, securityGroupName: `rentall-alb-sg-${environment}`, description: `ALB security group for rentall ${environment}`, allowAllOutbound: true, }); // Configure ALB access based on allowedIp if (allowedIp) { // Restrict to specific IP (dev environment) albSecurityGroup.addIngressRule( ec2.Peer.ipv4(allowedIp), ec2.Port.tcp(443), `Allow HTTPS from ${allowedIp}` ); albSecurityGroup.addIngressRule( ec2.Peer.ipv4(allowedIp), ec2.Port.tcp(80), `Allow HTTP from ${allowedIp} (for redirect)` ); } else { // Open to the internet (staging/prod) albSecurityGroup.addIngressRule( ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "Allow HTTPS from anywhere" ); albSecurityGroup.addIngressRule( ec2.Peer.anyIpv4(), ec2.Port.tcp(80), "Allow HTTP from anywhere (for redirect)" ); } // Application Load Balancer this.alb = new elbv2.ApplicationLoadBalancer(this, "Alb", { loadBalancerName: `rentall-alb-${environment}`, vpc, internetFacing: true, securityGroup: albSecurityGroup, vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, }); // HTTPS Listener (port 443) const httpsListener = this.alb.addListener("HttpsListener", { port: 443, protocol: elbv2.ApplicationProtocol.HTTPS, certificates: [certificate], sslPolicy: elbv2.SslPolicy.TLS12, }); // HTTP Listener (port 80) - Redirect to HTTPS this.alb.addListener("HttpListener", { port: 80, protocol: elbv2.ApplicationProtocol.HTTP, defaultAction: elbv2.ListenerAction.redirect({ protocol: "HTTPS", port: "443", permanent: true, }), }); // Backend Security Group const backendSecurityGroup = new ec2.SecurityGroup( this, "BackendSecurityGroup", { vpc, securityGroupName: `rentall-backend-sg-${environment}`, description: `Backend service security group (${environment})`, allowAllOutbound: true, } ); // Allow ALB to reach backend backendSecurityGroup.addIngressRule( albSecurityGroup, ec2.Port.tcp(5000), "Allow traffic from ALB" ); // Allow backend to reach database databaseSecurityGroup.addIngressRule( backendSecurityGroup, ec2.Port.tcp(dbPort), "Allow traffic from backend ECS" ); // Frontend Security Group const frontendSecurityGroup = new ec2.SecurityGroup( this, "FrontendSecurityGroup", { vpc, securityGroupName: `rentall-frontend-sg-${environment}`, description: `Frontend service security group (${environment})`, allowAllOutbound: true, } ); // Allow ALB to reach frontend frontendSecurityGroup.addIngressRule( albSecurityGroup, ec2.Port.tcp(80), "Allow traffic from ALB" ); // CloudWatch Log Groups const backendLogGroup = new logs.LogGroup(this, "BackendLogGroup", { logGroupName: `/ecs/rentall-backend-${environment}`, retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const frontendLogGroup = new logs.LogGroup(this, "FrontendLogGroup", { logGroupName: `/ecs/rentall-frontend-${environment}`, retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Backend Task Definition const backendTaskDef = new ecs.FargateTaskDefinition( this, "BackendTaskDef", { family: `rentall-backend-${environment}`, cpu: 512, // 0.5 vCPU memoryLimitMiB: 1024, // 1 GB } ); // Grant secrets access to backend task databaseSecret.grantRead(backendTaskDef.taskRole); appSecret.grantRead(backendTaskDef.taskRole); // Backend container const backendContainer = backendTaskDef.addContainer("backend", { containerName: "backend", image: ecs.ContainerImage.fromEcrRepository(backendRepository, "latest"), logging: ecs.LogDriver.awsLogs({ logGroup: backendLogGroup, streamPrefix: "backend", }), environment: { NODE_ENV: environment === "prod" ? "production" : "development", PORT: "5000", DB_HOST: dbEndpoint, DB_PORT: dbPort.toString(), DB_NAME: dbName, FRONTEND_URL: frontendUrl, CORS_ORIGIN: frontendUrl, }, secrets: { DB_USER: ecs.Secret.fromSecretsManager(databaseSecret, "username"), DB_PASSWORD: ecs.Secret.fromSecretsManager(databaseSecret, "password"), JWT_SECRET: ecs.Secret.fromSecretsManager(appSecret, "jwtSecret"), }, portMappings: [ { containerPort: 5000, protocol: ecs.Protocol.TCP, }, ], healthCheck: { command: [ "CMD-SHELL", "curl -f http://localhost:5000/api/health || exit 1", ], interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), retries: 3, startPeriod: cdk.Duration.seconds(60), }, }); // Backend Service this.backendService = new ecs.FargateService(this, "BackendService", { serviceName: `backend-${environment}`, cluster: this.cluster, taskDefinition: backendTaskDef, desiredCount: 1, securityGroups: [backendSecurityGroup], vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, enableExecuteCommand: true, // Enable ECS Exec for debugging/migrations circuitBreaker: { rollback: true }, minHealthyPercent: 100, maxHealthyPercent: 200, }); // Frontend Task Definition (Fargate Spot for cost savings) const frontendTaskDef = new ecs.FargateTaskDefinition( this, "FrontendTaskDef", { family: `rentall-frontend-${environment}`, cpu: 256, // 0.25 vCPU memoryLimitMiB: 512, // 512 MB } ); // Frontend container const frontendContainer = frontendTaskDef.addContainer("frontend", { containerName: "frontend", image: ecs.ContainerImage.fromEcrRepository(frontendRepository, "latest"), logging: ecs.LogDriver.awsLogs({ logGroup: frontendLogGroup, streamPrefix: "frontend", }), portMappings: [ { containerPort: 80, protocol: ecs.Protocol.TCP, }, ], healthCheck: { command: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"], interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), retries: 3, startPeriod: cdk.Duration.seconds(30), }, }); // Frontend Service (using Fargate Spot for 70% cost savings) this.frontendService = new ecs.FargateService(this, "FrontendService", { serviceName: `frontend-${environment}`, cluster: this.cluster, taskDefinition: frontendTaskDef, desiredCount: 1, securityGroups: [frontendSecurityGroup], vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, capacityProviderStrategies: [ { capacityProvider: "FARGATE_SPOT", weight: 1, }, ], circuitBreaker: { rollback: true }, minHealthyPercent: 100, maxHealthyPercent: 200, }); // Backend Target Group const backendTargetGroup = new elbv2.ApplicationTargetGroup( this, "BackendTargetGroup", { targetGroupName: `backend-tg-${environment}`, vpc, port: 5000, protocol: elbv2.ApplicationProtocol.HTTP, targetType: elbv2.TargetType.IP, healthCheck: { path: "/api/health", healthyHttpCodes: "200", interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), healthyThresholdCount: 2, unhealthyThresholdCount: 3, }, deregistrationDelay: cdk.Duration.seconds(30), } ); // Register backend service with target group this.backendService.attachToApplicationTargetGroup(backendTargetGroup); // Frontend Target Group const frontendTargetGroup = new elbv2.ApplicationTargetGroup( this, "FrontendTargetGroup", { targetGroupName: `frontend-tg-${environment}`, vpc, port: 80, protocol: elbv2.ApplicationProtocol.HTTP, targetType: elbv2.TargetType.IP, healthCheck: { path: "/", healthyHttpCodes: "200", interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), healthyThresholdCount: 2, unhealthyThresholdCount: 3, }, deregistrationDelay: cdk.Duration.seconds(30), } ); // Register frontend service with target group this.frontendService.attachToApplicationTargetGroup(frontendTargetGroup); // Configure listener rules for path-based routing // /api/* -> backend httpsListener.addTargetGroups("BackendRule", { targetGroups: [backendTargetGroup], priority: 10, conditions: [elbv2.ListenerCondition.pathPatterns(["/api/*"])], }); // /* -> frontend (default) httpsListener.addTargetGroups("FrontendRule", { targetGroups: [frontendTargetGroup], priority: 20, conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])], }); // Outputs new cdk.CfnOutput(this, "ClusterName", { value: this.cluster.clusterName, description: "ECS Cluster name", exportName: `ClusterName-${environment}`, }); new cdk.CfnOutput(this, "AlbDnsName", { value: this.alb.loadBalancerDnsName, description: "ALB DNS name - add CNAME record pointing to this", exportName: `AlbDnsName-${environment}`, }); new cdk.CfnOutput(this, "AlbArn", { value: this.alb.loadBalancerArn, description: "ALB ARN", exportName: `AlbArn-${environment}`, }); new cdk.CfnOutput(this, "ServiceUrl", { value: `https://${domainName}`, description: "Service URL", }); new cdk.CfnOutput(this, "BackendServiceName", { value: this.backendService.serviceName, description: "Backend service name", exportName: `BackendServiceName-${environment}`, }); new cdk.CfnOutput(this, "FrontendServiceName", { value: this.frontendService.serviceName, description: "Frontend service name", exportName: `FrontendServiceName-${environment}`, }); // Instructions for accessing the service new cdk.CfnOutput(this, "DnsInstructions", { value: `Add CNAME record: ${domainName} -> ${this.alb.loadBalancerDnsName}`, description: "DNS configuration instructions", }); } }