Deploy Laravel to AWS (Part 1): Architecture — VPC, Subnets & Security Groups

· 6 min read

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

  1. Go to VPC → Your VPCs → Create VPC
  2. Choose VPC and more (creates subnets automatically)
  3. 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_ID and AWS_SECRET_ACCESS_KEY in your .env file. 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:

Comments