Deploy a Secure Temp AWS Stack
Spring Boot + React + Redis
A complete, step-by-step guide to spinning up a temporary cloud environment using Terraform, AWS VPC, EC2, ElastiCache, and S3/CloudFront — with secure API-to-database networking and interview prep.
What We're Building
We're setting up a temporary, cost-effective AWS environment that mirrors a real production stack. This guide is beginner-friendly — every command is explained. Here's how the pieces connect:
SSL Termination
React Static Files
Port 8080 → EC2
Spring Boot :8080
Port 6379 only
Tools You Need Installed
Install all of these before starting. Click the links to go to official installation pages.
| Tool | Version | Purpose | Install Link |
|---|---|---|---|
| AWS CLI | v2.x | Talk to AWS from terminal | docs.aws.amazon.com |
| Terraform | v1.7+ | Infrastructure as Code | hashicorp.com/terraform |
| Docker | v24+ | Containerize Spring Boot | docs.docker.com |
| Java JDK 17 | LTS | Build Spring Boot app | adoptium.net |
| Node.js | v20 LTS | Build React app | nodejs.org |
| Git | latest | Version control | git-scm.com |
# Run each line and confirm you see version numbers aws --version # aws-cli/2.x.x terraform --version # Terraform v1.x.x docker --version # Docker version 24.x java -version # openjdk 17 node --version # v20.x.x git --version # git version 2.x
Setting Up AWS Access
Create an IAM User (AWS Console)
- Log in to AWS IAM Console
- Go to Users → Create user
- Name it:
terraform-deploy - Select Attach policies directly
- Attach:
AdministratorAccess(for temp env — restrict in prod) - Go to the user → Security credentials → Create access key
- Choose CLI → Download the CSV file
Configure AWS CLI
aws configure # You will be prompted: AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY Default region name [None]: us-east-1 Default output format [None]: json # Test it works: aws sts get-caller-identity
get-caller-identity calls the AWS Security Token Service (STS) to verify your credentials. If you see your Account ID and ARN, you're authenticated!
{
"UserId": "AIDAIOSFODNN7EXAMPLE",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/terraform-deploy"
}
Infrastructure as Code Foundation
Terraform lets you define your entire AWS infrastructure in code files. This means you can create everything with one command and destroy it just as easily — perfect for temp environments.
Project Folder Structure
aws-temp-env/ ├── terraform/ │ ├── main.tf # Root config & providers │ ├── variables.tf # Input variables │ ├── outputs.tf # Output values (IPs, URLs) │ ├── vpc.tf # VPC, subnets, gateways │ ├── security.tf # Security groups │ ├── ec2.tf # EC2 for Spring Boot │ ├── redis.tf # ElastiCache Redis │ ├── s3.tf # S3 + CloudFront for React │ └── terraform.tfvars # Your actual values (don't commit!) ├── spring-api/ │ ├── src/ │ └── Dockerfile └── react-app/ ├── src/ └── build/
# main.tf — Tells Terraform we're using AWS terraform { required_version = ">= 1.7.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } # This tells Terraform which AWS region to use provider "aws" { region = var.aws_region # Tag every resource so we know it's temp default_tags { tags = { Project = "temp-env" Environment = "temporary" ManagedBy = "terraform" Owner = var.owner_name } } }
variable "aws_region" { description = "AWS region to deploy to" type = string default = "us-east-1" } variable "owner_name" { description = "Your name — for resource tagging" type = string } variable "instance_type" { description = "EC2 size (t3.small is cheap for testing)" type = string default = "t3.small" } variable "redis_node_type" { description = "ElastiCache Redis node size" type = string default = "cache.t3.micro" }
# terraform.tfvars — Fill in your actual values # IMPORTANT: Add this file to .gitignore! aws_region = "us-east-1" owner_name = "your-name-here" instance_type = "t3.small"
Initialize Terraform
# Navigate into the terraform directory cd aws-temp-env/terraform # Download the AWS provider plugin terraform init # Expected output: # Initializing provider plugins... # - Installing hashicorp/aws v5.x.x... # Terraform has been successfully initialized!
terraform init downloads the AWS provider plugin — think of it like npm install for infrastructure. It creates a .terraform/ folder. You only need to run this once per project.
Building Your Private Network
A VPC (Virtual Private Cloud) is your isolated section of AWS. We create two subnets: a public one for internet-facing resources, and a private one for the API and Redis that should never be directly accessible.
- Application Load Balancer
- NAT Gateway (for private subnet's outbound)
- Has direct internet access
- EC2 (Spring Boot API)
- ElastiCache (Redis)
- No direct inbound internet
# ── VPC ────────────────────────────────────────── resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true # Required for RDS/ElastiCache enable_dns_support = true tags = { Name = "temp-vpc" } } # ── PUBLIC SUBNET (Load Balancer lives here) ───── resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "us-east-1a" map_public_ip_on_launch = true # EC2 here gets public IP tags = { Name = "temp-public-subnet" } } # ── PRIVATE SUBNET (API + Redis live here) ─────── resource "aws_subnet" "private" { vpc_id = aws_vpc.main.id cidr_block = "10.0.2.0/24" availability_zone = "us-east-1a" tags = { Name = "temp-private-subnet" } } # Need a second AZ subnet for ElastiCache subnet group resource "aws_subnet" "private_b" { vpc_id = aws_vpc.main.id cidr_block = "10.0.3.0/24" availability_zone = "us-east-1b" tags = { Name = "temp-private-subnet-b" } } # ── INTERNET GATEWAY (door to the internet) ────── resource "aws_internet_gateway" "igw" { vpc_id = aws_vpc.main.id tags = { Name = "temp-igw" } } # ── NAT GATEWAY (private subnet → internet, 1-way) resource "aws_eip" "nat" { domain = "vpc" } resource "aws_nat_gateway" "nat" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public.id # NAT lives in PUBLIC tags = { Name = "temp-nat" } } # ── ROUTE TABLES ───────────────────────────────── # Public: 0.0.0.0/0 → Internet Gateway resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.igw.id } tags = { Name = "temp-public-rt" } } resource "aws_route_table_association" "public" { subnet_id = aws_subnet.public.id route_table_id = aws_route_table.public.id } # Private: 0.0.0.0/0 → NAT Gateway (outbound only) resource "aws_route_table" "private" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.nat.id } tags = { Name = "temp-private-rt" } } resource "aws_route_table_association" "private" { subnet_id = aws_subnet.private.id route_table_id = aws_route_table.private.id }
Firewall Rules Between Services
Security Groups are virtual firewalls. The key principle: only allow the minimum required traffic. Redis should only be reachable from Spring Boot. Spring Boot should only be reachable from the Load Balancer.
| Security Group | Inbound From | Port | Why |
|---|---|---|---|
| sg-alb | Internet (0.0.0.0/0) | 443, 80 | Public web traffic to load balancer |
| sg-api | sg-alb only | 8080 | ALB forwards traffic to Spring Boot |
| sg-redis | sg-api only | 6379 | Only Spring Boot API can reach Redis |
# ── ALB SECURITY GROUP (Internet → Load Balancer) ─ resource "aws_security_group" "alb" { name = "temp-sg-alb" description = "Allow HTTP/HTTPS from internet to ALB" vpc_id = aws_vpc.main.id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTP from anywhere" } ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTPS from anywhere" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "temp-sg-alb" } } # ── API SECURITY GROUP (ALB → Spring Boot EC2) ─── resource "aws_security_group" "api" { name = "temp-sg-api" description = "Allow port 8080 only from ALB" vpc_id = aws_vpc.main.id ingress { from_port = 8080 to_port = 8080 protocol = "tcp" # Only from ALB security group — NOT from internet! security_groups = [aws_security_group.alb.id] description = "Spring Boot from ALB only" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "temp-sg-api" } } # ── REDIS SECURITY GROUP (API → Redis only) ────── resource "aws_security_group" "redis" { name = "temp-sg-redis" description = "Allow Redis port only from API security group" vpc_id = aws_vpc.main.id ingress { from_port = 6379 to_port = 6379 protocol = "tcp" # This is the KEY security rule — only EC2 API can talk to Redis security_groups = [aws_security_group.api.id] description = "Redis from API EC2 only" } # No egress needed for Redis tags = { Name = "temp-sg-redis" } }
security_groups instead of cidr_blocks, you're saying "traffic from instances with THIS security group" — not a fixed IP. This is more dynamic and secure.
Deploying the Java API Server
# Get the latest Amazon Linux 2023 AMI data "aws_ami" "amazon_linux" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-*-x86_64"] } } # IAM Role — allows EC2 to pull from ECR resource "aws_iam_role" "ec2_role" { name = "temp-ec2-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "ecr" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" } resource "aws_iam_instance_profile" "ec2" { name = "temp-ec2-profile" role = aws_iam_role.ec2_role.name } # EC2 Instance in PRIVATE subnet resource "aws_instance" "api" { ami = data.aws_ami.amazon_linux.id instance_type = var.instance_type subnet_id = aws_subnet.private.id vpc_security_group_ids = [aws_security_group.api.id] iam_instance_profile = aws_iam_instance_profile.ec2.name # Bootstrap script — runs on first boot user_data = templatefile("${path.module}/userdata.sh", { redis_endpoint = aws_elasticache_cluster.redis.cache_nodes[0].address ecr_image = var.ecr_image_uri aws_region = var.aws_region }) tags = { Name = "temp-spring-api" } }
# Stage 1: Build the JAR using Maven FROM eclipse-temurin:17-jdk-alpine AS builder WORKDIR /app # Copy Maven wrapper and pom first (layer caching) COPY mvnw . COPY .mvn .mvn COPY pom.xml . RUN ./mvnw dependency:go-offline -B # Now copy source and build COPY src src RUN ./mvnw package -DskipTests # ───────────────────────────────────────────────── # Stage 2: Lean runtime image (no JDK, just JRE) FROM eclipse-temurin:17-jre-alpine WORKDIR /app # Create non-root user for security RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "-Xmx512m", "app.jar"]
spring: application: name: temp-api # Redis connection — reads from environment variable data: redis: host: ${REDIS_HOST} # injected at runtime port: 6379 timeout: 2000 connect-timeout: 2000 server: port: 8080 # Health check endpoint for ALB management: endpoints: web: exposure: include: health,info endpoint: health: show-details: always
#!/bin/bash # This script runs automatically when EC2 boots for the first time # Update and install Docker yum update -y yum install -y docker systemctl start docker systemctl enable docker # Log in to ECR (AWS container registry) aws ecr get-login-password --region ${aws_region} | \ docker login --username AWS --password-stdin \ ${ecr_image} # Pull and run the Spring Boot container docker pull ${ecr_image} docker run -d \ --name spring-api \ --restart always \ -p 8080:8080 \ -e REDIS_HOST=${redis_endpoint} \ -e SPRING_PROFILES_ACTIVE=prod \ ${ecr_image} # Confirm it's running docker ps
Setting Up the Cache Layer
AWS ElastiCache is a managed Redis service. No server to patch, no Redis config to write. We place it in the private subnet — only accessible from the EC2 API via the security group rule we created earlier.
# ElastiCache needs a subnet group (min 2 AZs) resource "aws_elasticache_subnet_group" "redis" { name = "temp-redis-subnet-group" subnet_ids = [ aws_subnet.private.id, aws_subnet.private_b.id ] } # Redis cluster — single node for temp environment resource "aws_elasticache_cluster" "redis" { cluster_id = "temp-redis" engine = "redis" node_type = var.redis_node_type # cache.t3.micro num_cache_nodes = 1 parameter_group_name = "default.redis7" engine_version = "7.1" port = 6379 subnet_group_name = aws_elasticache_subnet_group.redis.name security_group_ids = [aws_security_group.redis.id] # Disable backups for temp env (saves cost) snapshot_retention_limit = 0 tags = { Name = "temp-redis" } }
output "redis_endpoint" { description = "ElastiCache Redis hostname" value = aws_elasticache_cluster.redis.cache_nodes[0].address sensitive = false } output "alb_dns_name" { description = "Load balancer URL for Spring Boot API" value = aws_lb.api.dns_name } output "cloudfront_url" { description = "React app CloudFront URL" value = aws_cloudfront_distribution.react.domain_name }
terraform destroy when done to stop all charges!
S3 + CloudFront Static Hosting
React apps are just static files after you run npm run build. We upload them to S3 and serve them globally via CloudFront CDN — no web server needed, and it scales automatically.
cd react-app # Install dependencies npm install # Set the API URL as an env variable # Create a .env file: echo "REACT_APP_API_URL=https://your-alb-dns.amazonaws.com" > .env # Build for production npm run build # The build/ folder is now ready to upload to S3 ls build/
# S3 bucket for React static files resource "aws_s3_bucket" "react" { bucket = "temp-react-app-${random_id.suffix.hex}" tags = { Name = "temp-react-frontend" } } resource "aws_random_id" "suffix" { byte_length = 4 } # Block all public access — CloudFront uses OAC instead resource "aws_s3_bucket_public_access_block" "react" { bucket = aws_s3_bucket.react.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # CloudFront Origin Access Control resource "aws_cloudfront_origin_access_control" "react" { name = "temp-react-oac" origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } # CloudFront Distribution resource "aws_cloudfront_distribution" "react" { enabled = true default_root_object = "index.html" price_class = "PriceClass_100" # US/EU only — cheapest origin { domain_name = aws_s3_bucket.react.bucket_regional_domain_name origin_id = "S3-React" origin_access_control_id = aws_cloudfront_origin_access_control.react.id } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "S3-React" viewer_protocol_policy = "redirect-to-https" forwarded_values { query_string = false cookies { forward = "none" } } } # SPA routing — serve index.html for 404s custom_error_response { error_code = 404 response_code = 200 response_page_path = "/index.html" } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { cloudfront_default_certificate = true } }
# After terraform apply, get the bucket name: BUCKET=$(terraform output -raw react_bucket_name) # Sync the React build folder to S3 aws s3 sync react-app/build/ s3://$BUCKET \ --delete \ --cache-control "max-age=31536000,immutable" # Always upload index.html with no-cache aws s3 cp react-app/build/index.html s3://$BUCKET/index.html \ --cache-control "no-cache, no-store, must-revalidate" # Invalidate CloudFront cache to serve fresh files CF_ID=$(terraform output -raw cloudfront_id) aws cloudfront create-invalidation \ --distribution-id $CF_ID \ --paths "/*"
Native AWS Infrastructure as Code
CloudFormation is AWS's built-in IaC tool (no extra install needed). While we use Terraform in this guide, here's an equivalent snippet so you understand both approaches.
- Multi-cloud environments
- Team already knows HCL
- Better state management
- Richer ecosystem of modules
- AWS-only environment
- Native AWS integration
- No extra tools to install
- StackSets for multi-account
AWSTemplateFormatVersion: '2010-09-09' Description: 'Temp environment VPC - Spring Boot + Redis' Parameters: OwnerName: Type: String Description: Your name for tagging Resources: # VPC MainVPC: Type: AWS::EC2::VPC Properties: CidrBlock: 10.0.0.0/16 EnableDnsHostnames: true Tags: - Key: Name Value: temp-vpc - Key: Owner Value: !Ref OwnerName # Public Subnet PublicSubnet: Type: AWS::EC2::Subnet Properties: VpcId: !Ref MainVPC CidrBlock: 10.0.1.0/24 AvailabilityZone: !Select [0, !GetAZs ''] MapPublicIpOnLaunch: true # Redis Security Group RedisSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Redis access from API only VpcId: !Ref MainVPC SecurityGroupIngress: - IpProtocol: tcp FromPort: 6379 ToPort: 6379 SourceSecurityGroupId: !Ref ApiSecurityGroup Outputs: VpcId: Value: !Ref MainVPC Export: Name: TempEnv-VpcId
aws cloudformation deploy \ --template-file cloudformation/vpc-template.yaml \ --stack-name temp-env-vpc \ --parameter-overrides OwnerName=YourName \ --capabilities CAPABILITY_IAM # Check stack status: aws cloudformation describe-stacks \ --stack-name temp-env-vpc \ --query 'Stacks[0].StackStatus' # Delete when done: aws cloudformation delete-stack --stack-name temp-env-vpc
Containerize & Push to AWS Registry
# ── 1. Set variables ─────────────────────────────── AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) AWS_REGION=us-east-1 ECR_REPO=temp-spring-api # ── 2. Create ECR repository ─────────────────────── aws ecr create-repository \ --repository-name $ECR_REPO \ --region $AWS_REGION # ── 3. Build the Docker image ────────────────────── cd spring-api docker build -t $ECR_REPO:latest . # ── 4. Tag for ECR ──────────────────────────────── ECR_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECR_REPO docker tag $ECR_REPO:latest $ECR_URI:latest # ── 5. Log 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 # ── 6. Push to ECR ──────────────────────────────── docker push $ECR_URI:latest # ── 7. Confirm it uploaded ──────────────────────── aws ecr list-images --repository-name $ECR_REPO
Apply Terraform & Go Live
cd terraform/ # Preview what will be created (always do this first!) terraform plan -out=tfplan # Review the plan output, then apply: terraform apply tfplan # ── This will create: ───────────────────────────── # + aws_vpc.main # + aws_subnet.public # + aws_subnet.private (x2) # + aws_internet_gateway.igw # + aws_nat_gateway.nat # + aws_security_group.alb / api / redis # + aws_instance.api (EC2) # + aws_elasticache_cluster.redis # + aws_s3_bucket.react # + aws_cloudfront_distribution.react # ─ ~15-20 minutes total ────────────────────────── # After apply, get your URLs: terraform output alb_dns_name terraform output cloudfront_url terraform output redis_endpoint
🧪 Verify Everything Works
# 1. Check the Spring Boot health endpoint ALB=$(terraform output -raw alb_dns_name) curl http://$ALB/actuator/health # Expected: {"status":"UP","components":{"redis":{"status":"UP"}}} # 2. Confirm Redis is reachable from API (in health response) curl http://$ALB/actuator/health | python3 -m json.tool # 3. Check React is loading CF=$(terraform output -raw cloudfront_url) curl -I https://$CF # Expected: HTTP/2 200 # 4. Test CORS: React → API curl -H "Origin: https://$CF" \ -H "Access-Control-Request-Method: GET" \ -X OPTIONS http://$ALB/api/test -v
Destroying the Temp Environment
# 1. Empty the S3 bucket first (Terraform can't delete non-empty buckets) BUCKET=$(terraform output -raw react_bucket_name) aws s3 rm s3://$BUCKET --recursive # 2. Destroy ALL infrastructure with one command cd terraform/ terraform destroy # Type 'yes' when prompted # This removes everything: VPC, EC2, Redis, S3, CloudFront... # 3. Delete the ECR repository and images aws ecr delete-repository \ --repository-name temp-spring-api \ --force # 4. Confirm nothing is running (should be empty) aws ec2 describe-instances \ --filters "Name=tag:Environment,Values=temporary" \ --query 'Reservations[].Instances[].InstanceId'
| NAT Gateway | ~$0.045/hr | Highest cost item |
| EC2 t3.small | ~$0.023/hr | Linux on-demand |
| ElastiCache t3.micro | ~$0.017/hr | Redis node |
| CloudFront + S3 | ~$0.001/hr | Pay-per-request |
Q&A — Prove You Understand the Setup
Click each question to reveal a strong, detailed answer. These cover common interview topics around AWS networking, security, and infrastructure decisions.
Redis is a cache/data store — it should never be directly accessible from the internet. Placing it in a private subnet means it has no public IP and no direct internet route. The only way to reach it is from within the VPC.
We then use a Security Group rule that only allows port 6379 from the EC2 API's security group. This means even other resources inside the VPC can't reach Redis unless they have that specific security group attached.
This follows the principle of least privilege and defense in depth — two core security concepts. If the EC2 instance were somehow compromised, Redis would still be an additional layer behind the security group.
| Feature | Security Group | Network ACL |
|---|---|---|
| Level | Instance level | Subnet level |
| Stateful? | ✅ Yes — return traffic auto-allowed | ❌ No — must allow both directions |
| Rule type | Allow only | Allow + Deny |
| Use case | Fine-grained per-resource control | Block IP ranges at subnet level |
In this setup we use Security Groups because they're stateful (easier to manage) and allow us to reference other security groups as sources — a powerful pattern not available in NACLs. NACLs are useful as an additional layer to block known-bad IP ranges.
Several key reasons:
- Reproducibility: Run
terraform applyin any AWS account/region and get an identical environment. No "it works on my setup" problems. - Version control: Infrastructure changes are tracked in Git with commit history, code reviews, and rollback capability.
- Speed: Creating 15+ resources manually in the console takes 45+ minutes. Terraform does it in one command.
- Drift detection:
terraform planshows you if someone manually changed something in the console — it detects configuration drift. - Clean teardown:
terraform destroyremoves everything it created — no orphaned resources, no forgotten NAT Gateways racking up charges.
Our Spring Boot EC2 instance lives in a private subnet with no public IP. This is secure — attackers can't directly reach it. But the server still needs to reach out to the internet: to pull Docker images from ECR, download OS updates, call external APIs, etc.
A NAT (Network Address Translation) Gateway solves this: it sits in the public subnet and acts as a one-way door. Private resources can send traffic out through it, but no inbound connections can originate from the internet.
Analogy: It's like a hotel concierge. Guests in private rooms (private subnet) can ask the concierge to go get things from outside (outbound traffic), but strangers can't walk directly into the private rooms (no inbound).
React is a static site after npm run build — just HTML, CSS, and JavaScript files. There's no reason to waste an EC2 instance serving static files.
- Cost: S3 + CloudFront costs pennies vs. ~$20/month for EC2
- Scale: CloudFront has 400+ edge locations globally. Zero configuration needed to handle millions of users.
- Security: No servers to patch or harden — the S3 bucket is private, only CloudFront can access it via Origin Access Control (OAC).
- HTTPS: CloudFront provides SSL/TLS certificates automatically via ACM.
- SPA routing: We configure CloudFront to return
index.htmlon 404s, which is how React Router works.
The connection is secured at multiple layers:
- Network isolation: Both EC2 and Redis are in the same private subnet — no traffic leaves AWS.
- Security Group enforcement: Redis port 6379 only accepts connections from the EC2's security group ID — not even other EC2 instances in the same subnet can connect unless they have that security group.
- Private DNS: Spring Boot connects using the ElastiCache internal DNS name (e.g.,
temp-redis.xyz.cache.amazonaws.com), which only resolves inside the VPC. - No credentials in code: The Redis hostname is injected via the
REDIS_HOSTenvironment variable, set in the EC2 userdata script. Never hardcoded.
For production, you'd add TLS encryption on the Redis connection and store the endpoint in AWS Secrets Manager instead of environment variables.
An Application Load Balancer (ALB) operates at Layer 7 (HTTP/HTTPS) and serves multiple purposes here:
- Bridge between subnets: The ALB lives in the public subnet and forwards traffic to EC2 in the private subnet — our EC2 never needs a public IP.
- Health checks: ALB pings
/actuator/healthevery 30 seconds. If EC2 becomes unhealthy, ALB stops routing traffic to it. - SSL termination: You attach an ACM certificate to the ALB. HTTPS decryption happens at the ALB; EC2 only handles plain HTTP internally.
- Routing rules: You can route
/api/*to one target group and/admin/*to another — all from one endpoint. - Horizontal scaling: When you add more EC2 instances, the ALB automatically distributes traffic across all of them.
Several improvements for production:
- Enable Redis AUTH + TLS
- Store secrets in AWS Secrets Manager
- Enable VPC Flow Logs
- Enable GuardDuty threat detection
- Remove AdministratorAccess — use least-privilege IAM
- Multi-AZ for EC2 (Auto Scaling Group)
- Redis Replication Group (multi-AZ)
- Terraform remote state in S3 + DynamoDB lock
- CI/CD pipeline (GitHub Actions / CodePipeline)
- CloudWatch alarms + SNS alerts
Terraform keeps a state file (terraform.tfstate) that maps your configuration to real AWS resources. Without it, Terraform wouldn't know that the aws_instance.api in your code corresponds to instance ID i-0abc123 in AWS.
Problems with local state (default):
- If you lose the file, Terraform loses track of your infrastructure
- Team members can't share it safely
- Concurrent applies can corrupt the file
Solution — Remote State in S3:
terraform { backend "s3" { bucket = "my-terraform-state-bucket" key = "temp-env/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-state-lock" # prevents concurrent apply encrypt = true } }
- User types the CloudFront URL in their browser
- DNS resolves to the nearest CloudFront edge node (global CDN)
- CloudFront serves cached React files from S3 via OAC (no public S3 access)
- Browser loads React app, which renders the UI
- User triggers an action → React calls
REACT_APP_API_URL/api/data - Request hits the ALB in the public subnet (port 443)
- ALB checks the security group — allows traffic from internet
- ALB health-checks and routes to EC2 (port 8080) in the private subnet
- Spring Boot checks Redis for cached data — connects via internal DNS to ElastiCache
- If cache hit: returns data immediately. If miss: queries DB, stores in Redis, returns data
- Response flows back: EC2 → ALB → User
The entire path from browser to Redis never exposes Redis to the internet. All internal communication stays within the VPC on the private subnet.
Official Documentation Links
terraform apply to spin up your stack. Run terraform destroy when done. Never leave temp environments running unattended.