#!/usr/bin/env bash # prepare-app.sh — Deploy Notes App to AWS (ECS Fargate + RDS + ALB + ACM HTTPS) # Usage: source .env && ./prepare-app.sh set -euo pipefail # ── Required environment variables (set in .env) ───────────────────────────── : "${AWS_REGION:?Set AWS_REGION in .env}" : "${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID in .env}" : "${DOMAIN_NAME:?Set DOMAIN_NAME in .env}" : "${DB_PASSWORD:?Set DB_PASSWORD in .env}" : "${DB_USERNAME:=appuser}" : "${DB_NAME:=appdb}" APP="notes-app" CLUSTER="${APP}-cluster" ECR_BACKEND="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP}-backend" ECR_FRONTEND="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP}-frontend" log() { echo "▶ $*"; } # ── 1. ECR repositories ─────────────────────────────────────────────────────── log "Creating ECR repositories..." aws ecr describe-repositories --repository-names "${APP}-backend" --region "$AWS_REGION" &>/dev/null \ || aws ecr create-repository --repository-name "${APP}-backend" --region "$AWS_REGION" --output none aws ecr describe-repositories --repository-names "${APP}-frontend" --region "$AWS_REGION" &>/dev/null \ || aws ecr create-repository --repository-name "${APP}-frontend" --region "$AWS_REGION" --output none # ── 2. Build & push images ──────────────────────────────────────────────────── log "Logging in to ECR..." aws ecr get-login-password --region "$AWS_REGION" \ | docker login --username AWS --password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" log "Building and pushing backend..." docker build --platform linux/amd64 -t "${APP}-backend" ./backend docker tag "${APP}-backend:latest" "${ECR_BACKEND}:latest" docker push "${ECR_BACKEND}:latest" log "Building and pushing frontend..." docker build --platform linux/amd64 -t "${APP}-frontend" ./frontend docker tag "${APP}-frontend:latest" "${ECR_FRONTEND}:latest" docker push "${ECR_FRONTEND}:latest" # ── 3. VPC & networking ─────────────────────────────────────────────────────── log "Looking up default VPC..." VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" \ --query "Vpcs[0].VpcId" --output text --region "$AWS_REGION") SUBNET_IDS_RAW=$(aws ec2 describe-subnets \ --filters "Name=vpc-id,Values=${VPC_ID}" "Name=defaultForAz,Values=true" \ --query "Subnets[*].SubnetId" --output text --region "$AWS_REGION") SUBNET1=$(echo "$SUBNET_IDS_RAW" | awk '{print $1}') SUBNET2=$(echo "$SUBNET_IDS_RAW" | awk '{print $2}') # ── 4. Security groups ──────────────────────────────────────────────────────── log "Creating security groups..." get_or_create_sg() { local name="$1" desc="$2" local id id=$(aws ec2 describe-security-groups \ --filters "Name=group-name,Values=${name}" "Name=vpc-id,Values=${VPC_ID}" \ --query "SecurityGroups[0].GroupId" --output text --region "$AWS_REGION" 2>/dev/null) if [ -z "$id" ] || [ "$id" = "None" ]; then id=$(aws ec2 create-security-group \ --group-name "$name" --description "$desc" \ --vpc-id "$VPC_ID" --query "GroupId" --output text --region "$AWS_REGION") fi echo "$id" } ALB_SG=$(get_or_create_sg "${APP}-alb-sg" "ALB SG for ${APP}") ECS_SG=$(get_or_create_sg "${APP}-ecs-sg" "ECS tasks SG for ${APP}") RDS_SG=$(get_or_create_sg "${APP}-rds-sg" "RDS SG for ${APP}") # Add rules (ignore errors if already exist) aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" --region "$AWS_REGION" \ --ip-permissions \ 'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}]' \ 'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]' \ --output none 2>/dev/null || true aws ec2 authorize-security-group-ingress --group-id "$ECS_SG" --region "$AWS_REGION" \ --ip-permissions \ "IpProtocol=tcp,FromPort=80,ToPort=80,UserIdGroupPairs=[{GroupId=${ALB_SG}}]" \ "IpProtocol=tcp,FromPort=5000,ToPort=5000,UserIdGroupPairs=[{GroupId=${ALB_SG}}]" \ --output none 2>/dev/null || true aws ec2 authorize-security-group-ingress --group-id "$RDS_SG" --region "$AWS_REGION" \ --ip-permissions \ "IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=${ECS_SG}}]" \ --output none 2>/dev/null || true # ── 5. RDS PostgreSQL ───────────────────────────────────────────────────────── log "Creating RDS PostgreSQL instance (this takes ~5 min)..." DB_INSTANCE="${APP}-db" DB_STATUS=$(aws rds describe-db-instances \ --db-instance-identifier "$DB_INSTANCE" \ --query "DBInstances[0].DBInstanceStatus" --output text --region "$AWS_REGION" 2>/dev/null || echo "none") if [ "$DB_STATUS" = "none" ]; then aws rds create-db-instance \ --db-instance-identifier "$DB_INSTANCE" \ --db-instance-class db.t3.micro \ --engine postgres \ --engine-version "16" \ --master-username "$DB_USERNAME" \ --master-user-password "$DB_PASSWORD" \ --db-name "$DB_NAME" \ --allocated-storage 20 \ --storage-type gp2 \ --vpc-security-group-ids "$RDS_SG" \ --no-multi-az \ --no-publicly-accessible \ --backup-retention-period 7 \ --region "$AWS_REGION" \ --output none fi log "Waiting for RDS to be available..." aws rds wait db-instance-available \ --db-instance-identifier "$DB_INSTANCE" --region "$AWS_REGION" DB_HOST=$(aws rds describe-db-instances \ --db-instance-identifier "$DB_INSTANCE" \ --query "DBInstances[0].Endpoint.Address" --output text --region "$AWS_REGION") log "RDS endpoint: ${DB_HOST}" DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}" # ── 6. Secrets Manager ─────────────────────────────────────────────────────── log "Storing DB password in Secrets Manager..." SECRET_NAME="${APP}/db-password" aws secretsmanager describe-secret --secret-id "$SECRET_NAME" --region "$AWS_REGION" &>/dev/null \ || aws secretsmanager create-secret \ --name "$SECRET_NAME" \ --secret-string "$DB_PASSWORD" \ --region "$AWS_REGION" --output none # ── 7. IAM role for ECS task execution ─────────────────────────────────────── log "Creating ECS task execution role..." EXEC_ROLE="${APP}-exec-role" aws iam get-role --role-name "$EXEC_ROLE" &>/dev/null || \ aws iam create-role --role-name "$EXEC_ROLE" \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \ --output none aws iam attach-role-policy --role-name "$EXEC_ROLE" \ --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy 2>/dev/null || true EXEC_ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${EXEC_ROLE}" # ── 8. ECS Cluster ──────────────────────────────────────────────────────────── log "Creating ECS cluster..." aws ecs describe-clusters --clusters "$CLUSTER" --region "$AWS_REGION" \ --query "clusters[?status=='ACTIVE'].clusterName" --output text \ | grep -q "$CLUSTER" \ || aws ecs create-cluster --cluster-name "$CLUSTER" --region "$AWS_REGION" --output none # ── 9. CloudWatch log groups ────────────────────────────────────────────────── aws logs create-log-group --log-group-name "/ecs/${APP}/backend" --region "$AWS_REGION" 2>/dev/null || true aws logs create-log-group --log-group-name "/ecs/${APP}/frontend" --region "$AWS_REGION" 2>/dev/null || true # ── 10. ALB ─────────────────────────────────────────────────────────────────── log "Creating Application Load Balancer..." ALB_ARN=$(aws elbv2 describe-load-balancers --names "${APP}-alb" \ --query "LoadBalancers[0].LoadBalancerArn" --output text --region "$AWS_REGION" 2>/dev/null || echo "") if [ -z "$ALB_ARN" ] || [ "$ALB_ARN" = "None" ]; then ALB_ARN=$(aws elbv2 create-load-balancer \ --name "${APP}-alb" \ --subnets "$SUBNET1" "$SUBNET2" \ --security-groups "$ALB_SG" \ --scheme internet-facing \ --type application \ --query "LoadBalancers[0].LoadBalancerArn" --output text --region "$AWS_REGION") fi ALB_DNS=$(aws elbv2 describe-load-balancers \ --load-balancer-arns "$ALB_ARN" \ --query "LoadBalancers[0].DNSName" --output text --region "$AWS_REGION") log "ALB DNS: ${ALB_DNS}" # ── 11. Target groups ───────────────────────────────────────────────────────── log "Creating target groups..." get_or_create_tg() { local name="$1" port="$2" health_path="$3" local arn arn=$(aws elbv2 describe-target-groups --names "$name" \ --query "TargetGroups[0].TargetGroupArn" --output text --region "$AWS_REGION" 2>/dev/null || echo "") if [ -z "$arn" ] || [ "$arn" = "None" ]; then arn=$(aws elbv2 create-target-group \ --name "$name" --protocol HTTP --port "$port" \ --vpc-id "$VPC_ID" --target-type ip \ --health-check-path "$health_path" \ --query "TargetGroups[0].TargetGroupArn" --output text --region "$AWS_REGION") fi echo "$arn" } FRONTEND_TG=$(get_or_create_tg "${APP}-frontend-tg" 80 "/") BACKEND_TG=$(get_or_create_tg "${APP}-backend-tg" 5000 "/health") # ── 12. ACM Certificate ─────────────────────────────────────────────────────── log "Requesting ACM certificate for ${DOMAIN_NAME}..." CERT_ARN=$(aws acm list-certificates \ --query "CertificateSummaryList[?DomainName=='${DOMAIN_NAME}'].CertificateArn | [0]" \ --output text --region "$AWS_REGION") if [ -z "$CERT_ARN" ] || [ "$CERT_ARN" = "None" ]; then CERT_ARN=$(aws acm request-certificate \ --domain-name "$DOMAIN_NAME" \ --validation-method DNS \ --query "CertificateArn" --output text --region "$AWS_REGION") # Print the CNAME record needed for validation sleep 5 CNAME_NAME=$(aws acm describe-certificate --certificate-arn "$CERT_ARN" --region "$AWS_REGION" \ --query "Certificate.DomainValidationOptions[0].ResourceRecord.Name" --output text) CNAME_VALUE=$(aws acm describe-certificate --certificate-arn "$CERT_ARN" --region "$AWS_REGION" \ --query "Certificate.DomainValidationOptions[0].ResourceRecord.Value" --output text) echo "" echo "════════════════════════════════════════════════════════════" echo " ACTION REQUIRED: Add these DNS records to your domain:" echo "" echo " 1. Certificate validation CNAME:" echo " Name: ${CNAME_NAME}" echo " Value: ${CNAME_VALUE}" echo "" echo " 2. Point your domain to the ALB:" echo " ${DOMAIN_NAME} CNAME ${ALB_DNS}" echo "════════════════════════════════════════════════════════════" echo "" echo "Waiting for certificate validation (up to 30 min)..." aws acm wait certificate-validated --certificate-arn "$CERT_ARN" --region "$AWS_REGION" fi # ── 13. ALB Listeners ───────────────────────────────────────────────────────── log "Creating ALB listeners..." # HTTP → redirect to HTTPS HTTP_LISTENER=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" \ --query "Listeners[?Port==\`80\`].ListenerArn | [0]" \ --output text --region "$AWS_REGION" 2>/dev/null || echo "") if [ -z "$HTTP_LISTENER" ] || [ "$HTTP_LISTENER" = "None" ]; then aws elbv2 create-listener \ --load-balancer-arn "$ALB_ARN" \ --protocol HTTP --port 80 \ --default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}' \ --region "$AWS_REGION" --output none fi # HTTPS listener → default to frontend HTTPS_LISTENER=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" \ --query "Listeners[?Port==\`443\`].ListenerArn | [0]" \ --output text --region "$AWS_REGION" 2>/dev/null || echo "") if [ -z "$HTTPS_LISTENER" ] || [ "$HTTPS_LISTENER" = "None" ]; then HTTPS_LISTENER=$(aws elbv2 create-listener \ --load-balancer-arn "$ALB_ARN" \ --protocol HTTPS --port 443 \ --certificates "CertificateArn=${CERT_ARN}" \ --default-actions "Type=forward,TargetGroupArn=${FRONTEND_TG}" \ --query "Listeners[0].ListenerArn" --output text --region "$AWS_REGION") fi # Route /api/* to backend (priority 10) RULE_EXISTS=$(aws elbv2 describe-rules --listener-arn "$HTTPS_LISTENER" --region "$AWS_REGION" \ --query "Rules[?Conditions[?Values[?contains(@,'/api/*')]]].RuleArn | [0]" --output text 2>/dev/null || echo "") if [ -z "$RULE_EXISTS" ] || [ "$RULE_EXISTS" = "None" ]; then aws elbv2 create-rule \ --listener-arn "$HTTPS_LISTENER" \ --priority 10 \ --conditions 'Field=path-pattern,Values=[/api/*]' \ --actions "Type=forward,TargetGroupArn=${BACKEND_TG}" \ --region "$AWS_REGION" --output none fi # ── 14. ECS Task Definitions ────────────────────────────────────────────────── log "Registering ECS task definitions..." aws ecs register-task-definition \ --family "${APP}-backend" \ --network-mode awsvpc \ --requires-compatibilities FARGATE \ --cpu "256" --memory "512" \ --execution-role-arn "$EXEC_ROLE_ARN" \ --container-definitions "[ { \"name\": \"backend\", \"image\": \"${ECR_BACKEND}:latest\", \"portMappings\": [{\"containerPort\": 5000, \"protocol\": \"tcp\"}], \"environment\": [{\"name\": \"DATABASE_URL\", \"value\": \"${DATABASE_URL}\"}], \"logConfiguration\": { \"logDriver\": \"awslogs\", \"options\": { \"awslogs-group\": \"/ecs/${APP}/backend\", \"awslogs-region\": \"${AWS_REGION}\", \"awslogs-stream-prefix\": \"backend\" } }, \"healthCheck\": { \"command\": [\"CMD-SHELL\", \"curl -f http://localhost:5000/health || exit 1\"], \"interval\": 30, \"timeout\": 5, \"retries\": 3, \"startPeriod\": 10 } } ]" \ --region "$AWS_REGION" --output none aws ecs register-task-definition \ --family "${APP}-frontend" \ --network-mode awsvpc \ --requires-compatibilities FARGATE \ --cpu "256" --memory "512" \ --execution-role-arn "$EXEC_ROLE_ARN" \ --container-definitions "[ { \"name\": \"frontend\", \"image\": \"${ECR_FRONTEND}:latest\", \"portMappings\": [{\"containerPort\": 80, \"protocol\": \"tcp\"}], \"logConfiguration\": { \"logDriver\": \"awslogs\", \"options\": { \"awslogs-group\": \"/ecs/${APP}/frontend\", \"awslogs-region\": \"${AWS_REGION}\", \"awslogs-stream-prefix\": \"frontend\" } } } ]" \ --region "$AWS_REGION" --output none # ── 15. ECS Services ────────────────────────────────────────────────────────── log "Creating ECS services..." SUBNETS_JSON="[\"${SUBNET1}\",\"${SUBNET2}\"]" ECS_SG_JSON="[\"${ECS_SG}\"]" svc_exists() { aws ecs describe-services --cluster "$CLUSTER" --services "$1" --region "$AWS_REGION" \ --query "services[?status=='ACTIVE'].serviceName" --output text 2>/dev/null | grep -q "$1" } svc_exists "${APP}-backend" || \ aws ecs create-service \ --cluster "$CLUSTER" \ --service-name "${APP}-backend" \ --task-definition "${APP}-backend" \ --desired-count 1 \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=${SUBNETS_JSON},securityGroups=${ECS_SG_JSON},assignPublicIp=ENABLED}" \ --load-balancers "targetGroupArn=${BACKEND_TG},containerName=backend,containerPort=5000" \ --health-check-grace-period-seconds 60 \ --region "$AWS_REGION" --output none svc_exists "${APP}-frontend" || \ aws ecs create-service \ --cluster "$CLUSTER" \ --service-name "${APP}-frontend" \ --task-definition "${APP}-frontend" \ --desired-count 1 \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=${SUBNETS_JSON},securityGroups=${ECS_SG_JSON},assignPublicIp=ENABLED}" \ --load-balancers "targetGroupArn=${FRONTEND_TG},containerName=frontend,containerPort=80" \ --health-check-grace-period-seconds 60 \ --region "$AWS_REGION" --output none # ── 16. Wait for services to stabilize ─────────────────────────────────────── log "Waiting for ECS services to stabilize..." aws ecs wait services-stable \ --cluster "$CLUSTER" \ --services "${APP}-backend" "${APP}-frontend" \ --region "$AWS_REGION" # ── 17. Initialize database ─────────────────────────────────────────────────── log "Initializing database schema..." # Run a one-shot ECS task that executes init.sql via the backend image INIT_SQL=$(cat db/init.sql | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") aws ecs run-task \ --cluster "$CLUSTER" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[\"${SUBNET1}\"],securityGroups=[\"${ECS_SG}\"],assignPublicIp=ENABLED}" \ --task-definition "${APP}-backend" \ --overrides "{ \"containerOverrides\": [{ \"name\": \"backend\", \"command\": [\"python\", \"-c\", \"import psycopg2,os; conn=psycopg2.connect(os.environ['DATABASE_URL']); cur=conn.cursor(); cur.execute(${INIT_SQL}); conn.commit(); print('DB initialized')\"] }] }" \ --region "$AWS_REGION" --output none echo "" echo "════════════════════════════════════════════════════════════" echo " ✅ Deployment complete!" echo " App URL : https://${DOMAIN_NAME}" echo " ALB DNS : ${ALB_DNS}" echo "" echo " View logs:" echo " aws logs tail /ecs/${APP}/frontend --follow --region ${AWS_REGION}" echo " aws logs tail /ecs/${APP}/backend --follow --region ${AWS_REGION}" echo "════════════════════════════════════════════════════════════"