This guide will help you deploy your Laravel project with Docker on AWS using production-level security practices.
┌──────────────────────────────────────────────────────────────┐
│ CUSTOM VPC: 10.0.0.0/16 │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ PUBLIC SUBNET │ │ PRIVATE SUBNET │ │
│ │ 10.0.1.0/24 │ │ 10.0.11.0/24 │ │
│ │ │ │ │ │
│ │ [IGW] │ │ [EC2 Instance] │ │
│ │ [NAT Gateway] │ │ Your Laravel App │ │
│ │ [ALB] │────────>│ with Docker │ │
│ │ │ │ No Public IP │ │
│ │ [Instance Connect│ │ │ │
│ │ Endpoint] │────────>│ SSH Access │ │
│ │ │ │ │ │
│ └────────────────────┘ └────────┬───────────┘ │
│ ↑ │ │
│ │ │ │
│ │ ↓ │
│ │ ┌────────────────────┐ │
│ │ │ PRIVATE SUBNET │ │
│ │ │ 10.0.12.0/24 │ │
│ │ │ │ │
│ │ │ [RDS MySQL] │ │
│ │ │ No Public IP │ │
│ │ │ │ │
│ │ └────────────────────┘ │
│ │ │
└─────────┼───────────────────────────────────────────────────┘
│
INTERNET (Users)
-
Go to VPC Dashboard → Your VPCs → Create VPC
-
Settings:
Name: laravel-prod-vpc IPv4 CIDR: 10.0.0.0/16 IPv6 CIDR: No IPv6 Tenancy: Default -
Click Create VPC
-
Note the VPC ID:
vpc-xxxxx(you'll need this!)
-
Go to Subnets → Create subnet
-
Settings:
VPC: laravel-prod-vpc Subnet name: public-subnet Availability Zone: us-east-1a (or your preferred AZ) IPv4 CIDR: 10.0.1.0/24 -
Click Create subnet
-
Select the subnet → Actions → Edit subnet settings
- ✅ Enable Auto-assign public IPv4 address
- Click Save
-
Create subnet
-
Settings:
VPC: laravel-prod-vpc Subnet name: private-subnet-app Availability Zone: us-east-1a IPv4 CIDR: 10.0.11.0/24 -
Click Create subnet
-
DO NOT enable auto-assign public IP
-
Create subnet
-
Settings:
VPC: laravel-prod-vpc Subnet name: private-subnet-db Availability Zone: us-east-1b (different AZ for RDS!) IPv4 CIDR: 10.0.12.0/24 -
Click Create subnet
Why 2 AZs for RDS? RDS requires subnets in at least 2 different Availability Zones for high availability.
-
Go to Internet Gateways → Create internet gateway
-
Settings:
Name: laravel-igw -
Click Create internet gateway
-
Select it → Actions → Attach to VPC
- Select: laravel-prod-vpc
- Click Attach internet gateway
-
Go to NAT Gateways → Create NAT gateway
-
Settings:
Name: laravel-nat Subnet: public-subnet (MUST be public!) Connectivity type: Public Elastic IP allocation ID: Click "Allocate Elastic IP" -
Click Create NAT gateway
-
Wait 2-3 minutes for it to become "Available"
Cost Note: NAT Gateway costs ~$32/month + data transfer charges
-
Go to Route Tables → Create route table
-
Settings:
Name: public-rt VPC: laravel-prod-vpc -
Click Create route table
-
Select it → Routes tab → Edit routes
-
Click Add route
Destination: 0.0.0.0/0 Target: Internet Gateway → laravel-igw -
Click Save changes
-
-
Subnet Associations tab → Edit subnet associations
- ✅ Select public-subnet
- Click Save associations
-
Create route table
-
Settings:
Name: private-rt VPC: laravel-prod-vpc -
Click Create route table
-
Select it → Routes tab → Edit routes
-
Click Add route
Destination: 0.0.0.0/0 Target: NAT Gateway → laravel-nat -
Click Save changes
-
-
Subnet Associations tab → Edit subnet associations
- ✅ Select private-subnet-app
- ✅ Select private-subnet-db
- Click Save associations
-
Go to EC2 → Security Groups → Create security group
-
Settings:
Name: alb-sg Description: Security group for Application Load Balancer VPC: laravel-prod-vpc -
Inbound Rules:
Rule 1: Type: HTTP Port: 80 Source: 0.0.0.0/0 Description: Allow HTTP from internet Rule 2: Type: HTTPS Port: 443 Source: 0.0.0.0/0 Description: Allow HTTPS from internet -
Outbound Rules: (default - allow all)
-
Click Create security group
-
Note the Security Group ID:
sg-alb-xxxxx
-
Create security group
-
Settings:
Name: ec2-sg Description: Security group for Laravel EC2 instance VPC: laravel-prod-vpc -
Inbound Rules:
Rule 1: Type: HTTP Port: 80 Source: Custom → sg-alb-xxxxx (ALB security group) Description: Allow HTTP from ALB only Rule 2: Type: Custom TCP Port: 443 Source: Custom → sg-alb-xxxxx Description: Allow HTTPS from ALB only -
Outbound Rules: (default - allow all)
- This allows EC2 to:
- Connect to internet via NAT (for downloading packages)
- Connect to RDS database
- This allows EC2 to:
-
Click Create security group
-
Note the Security Group ID:
sg-ec2-xxxxx
-
Create security group
-
Settings:
Name: endpoint-sg Description: Security group for EC2 Instance Connect Endpoint VPC: laravel-prod-vpc -
Inbound Rules:
Rule 1: Type: SSH Port: 22 Source: My IP (or your specific IP: x.x.x.x/32) Description: Allow SSH from my IP only -
Outbound Rules:
Rule 1: Type: SSH Port: 22 Destination: Custom → sg-ec2-xxxxx (EC2 security group) Description: Allow SSH to EC2 instances -
Click Create security group
-
Go back to ec2-sg security group
-
Edit inbound rules → Add rule
Type: SSH Port: 22 Source: Custom → sg-endpoint-xxxxx (Endpoint security group) Description: Allow SSH from Instance Connect Endpoint -
Click Save rules
-
Create security group
-
Settings:
Name: rds-sg Description: Security group for RDS MySQL database VPC: laravel-prod-vpc -
Inbound Rules:
Rule 1: Type: MYSQL/Aurora Port: 3306 Source: Custom → sg-ec2-xxxxx (EC2 security group) Description: Allow MySQL from EC2 only -
Outbound Rules: (not needed)
-
Click Create security group
-
Go to VPC → Endpoints → Create endpoint
-
Settings:
Name: laravel-connect-endpoint Service category: EC2 Instance Connect Endpoint VPC: laravel-prod-vpc Security groups: endpoint-sg Subnet: public-subnet -
Click Create endpoint
-
Wait for status to become "Available"
-
Go to RDS → Subnet groups → Create DB subnet group
-
Settings:
Name: laravel-db-subnet-group Description: Subnet group for Laravel RDS VPC: laravel-prod-vpc Availability Zones: - us-east-1a - us-east-1b Subnets: - private-subnet-app (10.0.11.0/24) - private-subnet-db (10.0.12.0/24) -
Click Create
-
Go to RDS → Databases → Create database
-
Settings:
Engine: MySQL Version: 8.0.x (latest) Templates: Production (or Dev/Test for lower cost) DB instance identifier: laravel-prod-db Master username: admin Master password: [Create strong password] Instance configuration: - Burstable classes (for lower cost) - db.t3.micro (free tier) or db.t3.small Storage: - Storage type: General Purpose (SSD) - Allocated storage: 20 GB - Storage autoscaling: Enable (max 100 GB) Connectivity: - VPC: laravel-prod-vpc - DB subnet group: laravel-db-subnet-group - Public access: NO ❌ - VPC security group: Choose existing → rds-sg Database authentication: - Password authentication Additional configuration: - Initial database name: laravel - Backup retention: 7 days - Delete protection: Enable (for production) -
Click Create database
-
Wait 5-10 minutes for creation
-
Once available, note:
- Endpoint:
laravel-prod-db.xxxxx.us-east-1.rds.amazonaws.com - Port:
3306
- Endpoint:
-
Go to EC2 → Instances → Launch instances
-
Settings:
Name: laravel-prod-app AMI: Ubuntu Server 24.04 LTS (or Amazon Linux 2023) Instance type: t3.small (recommended minimum for Laravel) Key pair: Create new key pair - Name: laravel-prod-key - Type: RSA - Format: .pem - Download and save securely! Network settings: - VPC: laravel-prod-vpc - Subnet: private-subnet-app - Auto-assign public IP: DISABLE ❌ - Security group: ec2-sg Storage: 20 GB gp3 (or 30 GB if you need more space) -
Advanced details → User data (optional - for basic setup):
#!/bin/bash apt update -y apt upgrade -y # Install basic tools apt install -y git curl wget nano
-
Click Launch instance
-
Note the Instance ID:
i-xxxxx
-
Go to EC2 → Target Groups → Create target group
-
Settings:
Target type: Instances Target group name: laravel-tg Protocol: HTTP Port: 80 VPC: laravel-prod-vpc Health check: - Protocol: HTTP - Path: / (or /health if you have a health endpoint) - Healthy threshold: 2 - Unhealthy threshold: 2 - Timeout: 5 seconds - Interval: 30 seconds -
Click Next
-
Register targets:
- Select your laravel-prod-app instance
- Port: 80
- Click Include as pending below
-
Click Create target group
-
Go to EC2 → Load Balancers → Create load balancer
-
Select Application Load Balancer
-
Settings:
Name: laravel-alb Scheme: Internet-facing IP address type: IPv4 Network mapping: - VPC: laravel-prod-vpc - Mappings: ✅ us-east-1a → public-subnet ✅ us-east-1b → (you need another public subnet in different AZ) Security groups: alb-sg Listeners: - Protocol: HTTP - Port: 80 - Default action: Forward to → laravel-tg -
Click Create load balancer
-
Wait 2-3 minutes for provisioning
-
Once active, note:
- DNS name:
laravel-alb-xxxxx.us-east-1.elb.amazonaws.com
- DNS name:
Important: ALB requires subnets in at least 2 Availability Zones. You'll need to create another public subnet in a different AZ (e.g., us-east-1b).
-
Go to VPC → Subnets → Create subnet
-
Settings:
VPC: laravel-prod-vpc Subnet name: public-subnet-2 Availability Zone: us-east-1b IPv4 CIDR: 10.0.2.0/24 -
Enable Auto-assign public IPv4 address
-
Associate with public-rt route table
-
Go back to ALB settings and add this subnet
Option A: AWS Console (Easiest)
- Go to EC2 → Instances
- Select laravel-prod-app
- Click Connect
- Select EC2 Instance Connect Endpoint
- Choose your endpoint
- Click Connect
Option B: AWS CLI
# Install AWS CLI if not already installed
# Configure AWS credentials
aws configure
# Connect to instance
aws ec2-instance-connect ssh \
--instance-id i-xxxxx \
--connection-type eiceOnce connected to your instance:
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
sudo apt install -y docker.io
sudo systemctl start docker
sudo systemctl enable docker
# Add ubuntu user to docker group
sudo usermod -aG docker $USER
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# Verify installations
docker --version
docker-compose --version
# Log out and back in for group changes to take effect
exitThen reconnect to the instance.
# Install Git
sudo apt install -y git
# Configure Git (optional)
git config --global user.name "Your Name"
git config --global user.email "[email protected]"
# Create project directory
mkdir -p /home/ubuntu/laravel-app
cd /home/ubuntu/laravel-app
# Clone your repository
# Option 1: HTTPS (will need credentials)
git clone https://github.com/yourusername/your-laravel-project.git .
# Option 2: SSH (recommended - set up SSH key first)
# Generate SSH key on EC2
ssh-keygen -t ed25519 -C "[email protected]"
cat ~/.ssh/id_ed25519.pub
# Copy the output and add to GitHub: Settings → SSH Keys
# Then clone
git clone [email protected]:yourusername/your-laravel-project.git .# Copy example env file
cp .env.example .env
# Edit .env file
nano .envUpdate these values:
APP_NAME=Laravel
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://yourdomain.com
DB_CONNECTION=mysql
DB_HOST=laravel-prod-db.xxxxx.us-east-1.rds.amazonaws.com
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=admin
DB_PASSWORD=your_rds_password
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379Save and exit (Ctrl+X, then Y, then Enter)
server {
listen 80;
server_name devopslab.info;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Example docker-compose.yml for Laravel:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: laravel-app
restart: unless-stopped
working_dir: /var/www
volumes:
- .:/var/www
- ./storage:/var/www/storage
- ./bootstrap/cache:/var/www/bootstrap/cache
networks:
- laravel
depends_on:
- redis
nginx:
image: nginx:alpine
container_name: laravel-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- .:/var/www
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
networks:
- laravel
depends_on:
- app
redis:
image: redis:alpine
container_name: laravel-redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- laravel
networks:
laravel:
driver: bridgeMake sure your nginx configuration forwards to your app correctly!
# Make sure you're in the project directory
cd /home/ubuntu/laravel-app
# Build and start containers
docker-compose up -d --build
# Check running containers
docker-compose ps
# View logs
docker-compose logs -f
# If everything looks good, continue# Access app container
docker-compose exec app bash
# Inside the container:
# Install Composer dependencies
composer install --optimize-autoloader --no-dev
# Generate application key
php artisan key:generate
# Run migrations
php artisan migrate --force
# Seed database (if needed)
php artisan db:seed --force
# Clear and cache config
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Set proper permissions
chown -R www-data:www-data /var/www/storage
chown -R www-data:www-data /var/www/bootstrap/cache
chmod -R 775 /var/www/storage
chmod -R 775 /var/www/bootstrap/cache
# Exit container
exit# Test if app is responding on port 80
curl http://localhost
# You should see your Laravel app HTML response-
Go to your domain provider (GoDaddy, Namecheap, Route53, etc.)
-
Add CNAME Record:
Type: CNAME Name: www (or @ if supported) Value: laravel-alb-xxxxx.us-east-1.elb.amazonaws.com TTL: 300 (5 minutes) -
If using Route53:
- Create ALIAS record instead (no CNAME needed for apex domain)
- Type: A Record
- Name: yourdomain.com
- Alias: Yes
- Alias Target: Select your ALB
-
Wait 5-10 minutes for DNS propagation
-
Test:
http://yourdomain.com
-
Go to AWS Certificate Manager → Request certificate
-
Settings:
Certificate type: Public certificate Domain names: - yourdomain.com - *.yourdomain.com (wildcard for subdomains) Validation: DNS validation -
Click Request
-
Add DNS records for validation:
- Certificate Manager will show CNAME records
- Add these to your domain provider's DNS
- Wait for validation (5-30 minutes)
-
Once validated, go to EC2 → Load Balancers → Select your ALB
-
Listeners tab → Add listener
Protocol: HTTPS Port: 443 Default action: Forward to → laravel-tg Security policy: ELBSecurityPolicy-TLS13-1-2-2021-06 (recommended) SSL certificate: Select your certificate -
Click Save
-
Update HTTP listener to redirect to HTTPS:
- Edit HTTP:80 listener
- Change action to: Redirect to HTTPS:443
- Status code: 301 (permanent)
-
Test:
https://yourdomain.com
Create .github/workflows/deploy.yml in your repository:
name: Deploy to AWS
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy to EC2
env:
INSTANCE_ID: ${{ secrets.INSTANCE_ID }}
run: |
# Connect via Instance Connect and deploy
aws ec2-instance-connect send-ssh-public-key \
--instance-id $INSTANCE_ID \
--instance-os-user ubuntu \
--ssh-public-key file://~/.ssh/id_rsa.pub
ssh ubuntu@$INSTANCE_ID << 'EOF'
cd /home/ubuntu/laravel-app
git pull origin main
docker-compose down
docker-compose up -d --build
docker-compose exec -T app composer install --optimize-autoloader --no-dev
docker-compose exec -T app php artisan migrate --force
docker-compose exec -T app php artisan config:cache
docker-compose exec -T app php artisan route:cache
docker-compose exec -T app php artisan view:cache
EOFGitHub Secrets to add:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYINSTANCE_ID(your EC2 instance ID)
- ✅ Custom VPC created
- ✅ Public and private subnets separated
- ✅ Internet Gateway for public access
- ✅ NAT Gateway for private instances
- ✅ Route tables properly configured
- ✅ Instance in private subnet
- ✅ No public IP assigned
- ✅ Security group allows only ALB traffic
- ✅ SSH only via Instance Connect Endpoint
- ✅ Instance Connect Endpoint only from your IP
- ✅ Database in private subnet
- ✅ No public access
- ✅ Security group allows only EC2
- ✅ Strong master password
- ✅ Automated backups enabled
- ✅ Delete protection enabled
- ✅
.envfile not in Git - ✅ APP_DEBUG=false in production
- ✅ Strong APP_KEY generated
- ✅ Database credentials secured
- ✅ HTTPS enforced via ALB
- ✅ Security headers configured
- ✅ SSL certificate configured
- ✅ HTTP redirects to HTTPS
- ✅ Security group allows only HTTP/HTTPS
- ✅ Health checks configured
| Service | Configuration | Cost |
|---|---|---|
| EC2 t3.small | On-Demand, 24/7 | ~$15 |
| RDS db.t3.micro | 20GB storage | ~$13 |
| NAT Gateway | + data transfer | ~$32 |
| ALB | + LCU hours | ~$16 |
| Data Transfer | ~100GB | ~$9 |
| EBS Storage | 20GB gp3 | ~$2 |
| Total | ~$87/month |
Ways to reduce costs:
- Use Savings Plans for EC2/RDS (save 30-50%)
- Stop instances during off-hours (dev only)
- Use Reserved Instances for production
- Optimize NAT Gateway usage
- Consider using VPC endpoints for AWS services
Check:
- Endpoint security group allows SSH from your IP
- Endpoint is in public subnet
- Endpoint outbound allows SSH to EC2 security group
- EC2 security group allows SSH from endpoint security group
Fix:
# Verify endpoint exists
aws ec2 describe-instance-connect-endpoints
# Check your IP
curl ifconfig.meCheck:
- Target group health checks passing
- EC2 instance docker containers running
- Nginx listening on port 80
- Security group allows ALB → EC2 traffic
Fix:
# Check target health
# AWS Console: EC2 → Target Groups → laravel-tg → Targets tab
# On EC2, check if port 80 is listening
sudo netstat -tlnp | grep :80
# Check docker containers
docker-compose ps
# Check nginx logs
docker-compose logs nginx
# Test locally
curl http://localhostCheck:
- RDS security group allows EC2 security group
- RDS endpoint is correct in .env
- Database credentials are correct
- RDS is in available state
Fix:
# Test connection from EC2
mysql -h laravel-prod-db.xxxxx.us-east-1.rds.amazonaws.com \
-P 3306 \
-u admin \
-p
# If mysql command not found:
sudo apt install mysql-client -y
# Check .env file
cat .env | grep DB_Check:
# View container logs
docker-compose logs app
docker-compose logs nginx
# Check container status
docker-compose ps
# Restart containers
docker-compose down
docker-compose up -d
# Rebuild if needed
docker-compose up -d --build- Monitor CloudWatch metrics
- Check application logs
- Review security group rules
- Review RDS backups
- Check disk space usage
- Update Docker images
- Review access logs
- Update system packages
- Review AWS costs
- Rotate credentials
- Test backup restoration
- Review security patches
# Connect via Instance Connect
aws ec2-instance-connect ssh --instance-id i-xxxxx
# Check system resources
htop
df -h
free -h
# View docker containers
docker-compose ps
docker-compose logs -f
# Restart application
docker-compose restart
# Update application
cd /home/ubuntu/laravel-app
git pull
docker-compose down
docker-compose up -d --build# Access app container
docker-compose exec app bash
# Clear caches
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear
# Run migrations
php artisan migrate
# Check queue workers
php artisan queue:work --daemon
# View logs
tail -f storage/logs/laravel.log-
Setup Monitoring:
- Enable CloudWatch logs
- Setup alerts for CPU/memory
- Configure application monitoring
-
Setup Backups:
- Configure automated RDS snapshots
- Setup EBS snapshots for EC2
- Test backup restoration
-
Optimize Performance:
- Enable Redis for caching
- Configure queue workers
- Setup CDN (CloudFront)
- Enable PHP OPcache
-
Enhance Security:
- Setup WAF (Web Application Firewall)
- Configure AWS Shield
- Enable GuardDuty
- Setup AWS Config
-
Setup Monitoring & Logging:
- CloudWatch Logs for application logs
- CloudWatch Metrics for performance
- SNS alerts for critical events
You've successfully deployed a production-level Laravel application on AWS with:
✅ Secure VPC with public/private subnets ✅ Private EC2 instance (no direct internet access) ✅ RDS MySQL in private subnet ✅ Application Load Balancer for public access ✅ Instance Connect Endpoint for secure SSH ✅ Docker containerization ✅ HTTPS with SSL certificate ✅ Proper security groups for all components
Your application is now:
- Secure (private instances, proper security groups)
- Scalable (can add more EC2 instances to ALB)
- Highly available (multi-AZ RDS, ALB health checks)
- Production-ready (HTTPS, proper monitoring)
🎉 Congratulations on your production deployment!