Deploy Laravel to AWS (Part 1): Architecture — VPC, Subnets & Security Groups
You've built your Laravel app locally. Now what? Throwing it on a single EC2 instance with everything on one server works for demos, but production demands more: isolated networks, managed databases, CDN, load balancing, and automated deployments.
This series walks through deploying a Laravel application to AWS the right way — the same architecture I use in production.
What We're Building
By the end of this 5-part series, you'll have:
| Component | AWS Service | Purpose |
|---|---|---|
| Network | VPC + Subnets | Isolated network with public/private zones |
| Compute | EC2 (Amazon Linux 2023) | PHP-FPM + Nginx web server |
| Database | RDS (MySQL 8.0) | Managed database in private subnet |
| Storage | S3 | File uploads, backups, assets |
| Load Balancer | ALB | Distribute traffic, SSL termination |
| CDN | CloudFront | Cache static assets globally |
| SSL | ACM | Free SSL certificates |
| DNS | Route 53 | Domain management |
| Deploy | GitHub Actions + Envoy | Zero-downtime deployment |
Architecture Overview
┌───────────────────────────────────────┐
│ Route 53 (DNS) │
└──────────────────┬────────────────────┘
│
┌──────────────────┴────────────────────┐
│ CloudFront (CDN) │
│ Static assets: /build/*, /img/* │
└──────────────────┬────────────────────┘
│
┌──────────────────┴────────────────────┐
│ ALB (Application Load Balancer) │
│ SSL termination (ACM Certificate) │
│ Port 443 → Target Group :80 │
└───┬──────────────────────────────┬────┘
│ VPC 10.0.0.0/16 │
┌───────────┴───────────┐ ┌─────────────┴──────────┐
│ Public Subnet A │ │ Public Subnet C │
│ 10.0.1.0/24 │ │ 10.0.2.0/24 │
│ AZ: ap-northeast-1a │ │ AZ: ap-northeast-1c │
│ │ │ │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ EC2 Instance │ │ │ │ EC2 Instance │ │
│ │ Amazon Linux │ │ │ │ (future scale)│ │
│ │ 2023 │ │ │ │ │ │
│ │ Nginx+PHP-FPM │ │ │ │ │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
└───────────────────────┘ └────────────────────────┘
┌───────────────────────┐ ┌────────────────────────┐
│ Private Subnet A │ │ Private Subnet C │
│ 10.0.11.0/24 │ │ 10.0.12.0/24 │
│ AZ: ap-northeast-1a │ │ AZ: ap-northeast-1c │
│ │ │ │
│ ┌─────────────────┐ │ │ │
│ │ RDS MySQL 8.0 │ │ │ (RDS Standby - │
│ │ Primary │ │ │ Multi-AZ replica) │
│ └─────────────────┘ │ │ │
└───────────────────────┘ └────────────────────────┘
│
┌───┴───────────────────────────────────┐
│ S3 Bucket │
│ uploads / backups / assets │
└───────────────────────────────────────┘
Why This Architecture?
Public subnets hold resources that need internet access (EC2 via ALB, NAT Gateway). Private subnets hold resources that should never be directly accessible from the internet (RDS database).
Two Availability Zones (AZ-a and AZ-c) provide redundancy. If one AZ goes down, the other keeps running. RDS Multi-AZ handles automatic database failover.
Step 1: Create the VPC
Via AWS Console
- Go to VPC → Your VPCs → Create VPC
- Choose VPC and more (creates subnets automatically)
- Configure:
| Setting | Value |
|---|---|
| Name | laravel-production |
| IPv4 CIDR | 10.0.0.0/16 |
| Number of AZs | 2 |
| Public subnets | 2 |
| Private subnets | 2 |
| NAT gateways | None (save cost; add later if needed) |
| VPC endpoints | S3 Gateway |
Via AWS CLI
# Create VPC
aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=laravel-production}]'
# Enable DNS hostnames (required for RDS)
aws ec2 modify-vpc-attribute \
--vpc-id vpc-xxxx \
--enable-dns-hostnames
Subnet Design
VPC: 10.0.0.0/16 (65,536 IPs)
│
├── Public Subnet A: 10.0.1.0/24 (256 IPs) → ap-northeast-1a
├── Public Subnet C: 10.0.2.0/24 (256 IPs) → ap-northeast-1c
├── Private Subnet A: 10.0.11.0/24 (256 IPs) → ap-northeast-1a
└── Private Subnet C: 10.0.12.0/24 (256 IPs) → ap-northeast-1c
# Public Subnet A
aws ec2 create-subnet \
--vpc-id vpc-xxxx \
--cidr-block 10.0.1.0/24 \
--availability-zone ap-northeast-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=laravel-public-a}]'
# Public Subnet C
aws ec2 create-subnet \
--vpc-id vpc-xxxx \
--cidr-block 10.0.2.0/24 \
--availability-zone ap-northeast-1c \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=laravel-public-c}]'
# Private Subnet A
aws ec2 create-subnet \
--vpc-id vpc-xxxx \
--cidr-block 10.0.11.0/24 \
--availability-zone ap-northeast-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=laravel-private-a}]'
# Private Subnet C
aws ec2 create-subnet \
--vpc-id vpc-xxxx \
--cidr-block 10.0.12.0/24 \
--availability-zone ap-northeast-1c \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=laravel-private-c}]'
Step 2: Internet Gateway & Route Tables
Internet Gateway (for public subnets)
# Create and attach Internet Gateway
aws ec2 create-internet-gateway \
--tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=laravel-igw}]'
aws ec2 attach-internet-gateway \
--internet-gateway-id igw-xxxx \
--vpc-id vpc-xxxx
Route Tables
# Public route table — routes to internet via IGW
aws ec2 create-route-table \
--vpc-id vpc-xxxx \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=laravel-public-rt}]'
aws ec2 create-route \
--route-table-id rtb-xxxx \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id igw-xxxx
# Associate public subnets with public route table
aws ec2 associate-route-table --route-table-id rtb-xxxx --subnet-id subnet-public-a
aws ec2 associate-route-table --route-table-id rtb-xxxx --subnet-id subnet-public-c
Private subnets use the main route table (no internet route) — they can only communicate within the VPC.
Step 3: Security Groups
Security groups act as virtual firewalls. We need three:
ALB Security Group
aws ec2 create-security-group \
--group-name laravel-alb-sg \
--description "ALB - Allow HTTP/HTTPS from internet" \
--vpc-id vpc-xxxx
# Allow HTTP and HTTPS from anywhere
aws ec2 authorize-security-group-ingress \
--group-id sg-alb \
--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}]'
EC2 Security Group
aws ec2 create-security-group \
--group-name laravel-ec2-sg \
--description "EC2 - Allow traffic from ALB only" \
--vpc-id vpc-xxxx
# Allow HTTP from ALB only (not from internet!)
aws ec2 authorize-security-group-ingress \
--group-id sg-ec2 \
--protocol tcp --port 80 \
--source-group sg-alb
# Allow SSH from your IP only
aws ec2 authorize-security-group-ingress \
--group-id sg-ec2 \
--protocol tcp --port 22 \
--cidr YOUR_IP/32
RDS Security Group
aws ec2 create-security-group \
--group-name laravel-rds-sg \
--description "RDS - Allow MySQL from EC2 only" \
--vpc-id vpc-xxxx
# Allow MySQL from EC2 security group only
aws ec2 authorize-security-group-ingress \
--group-id sg-rds \
--protocol tcp --port 3306 \
--source-group sg-ec2
Security Group Flow
Internet → [sg-alb :80/:443] → ALB → [sg-ec2 :80] → EC2 → [sg-rds :3306] → RDS
↘
S3 (via VPC endpoint)
The key principle: each layer only accepts traffic from the layer above it. RDS never sees internet traffic. EC2 never receives direct HTTP from the internet.
Step 4: IAM Role for EC2
Instead of storing AWS credentials on the server, create an IAM role that EC2 assumes automatically:
# Create the role
aws iam create-role \
--role-name laravel-ec2-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
# Attach S3 access policy
aws iam put-role-policy \
--role-name laravel-ec2-role \
--policy-name s3-access \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::your-laravel-bucket/*"
}, {
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::your-laravel-bucket"
}]
}'
# Create instance profile and attach role
aws iam create-instance-profile --instance-profile-name laravel-ec2-profile
aws iam add-role-to-instance-profile \
--instance-profile-name laravel-ec2-profile \
--role-name laravel-ec2-role
Security: Never hardcode
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYin your.envfile. The IAM role provides credentials automatically to the EC2 instance. Laravel's S3 driver picks them up via the SDK's default credential chain.
Step 5: SSH Key Pair
aws ec2 create-key-pair \
--key-name laravel-production \
--query 'KeyMaterial' \
--output text > laravel-production.pem
chmod 400 laravel-production.pem
Keep this .pem file safe — it's the only way to SSH into your EC2 instances.
Cost Estimation
For a small-to-medium Laravel SaaS:
| Service | Spec | Monthly Cost (Tokyo) |
|---|---|---|
| EC2 | t3.small (2 vCPU, 2GB) | ~$19 |
| RDS | db.t3.micro (1 vCPU, 1GB) | ~$16 |
| ALB | Fixed + traffic | ~$18 |
| S3 | 10GB storage | ~$0.25 |
| CloudFront | 50GB transfer | ~$4 |
| Route 53 | 1 hosted zone | $0.50 |
| ACM | SSL certificate | Free |
| Total | ~$58/month |
This is a starting point. You can scale up later.
Checklist Before Moving On
- VPC created with CIDR
10.0.0.0/16 - 2 public subnets (different AZs)
- 2 private subnets (different AZs)
- Internet Gateway attached to VPC
- Public route table with
0.0.0.0/0 → IGW - 3 security groups (ALB, EC2, RDS)
- IAM role with S3 access
- SSH key pair downloaded
What's Next
In Part 2, we'll launch an EC2 instance with Amazon Linux 2023, install Nginx + PHP-FPM 8.4, configure the server from scratch, and deploy our first Laravel app manually.
Series Navigation:
- ← Part 0: Prerequisites
- Part 1: Architecture & VPC (You are here)
- Part 2: EC2 & Amazon Linux 2023 →
- Part 3: RDS, S3 & ElastiCache →
- Part 4: ALB, CloudFront & SSL →
- Part 5: CI/CD & Zero-Downtime Deploy →