Deploy Laravel to AWS (Part 3): RDS MySQL, S3 Storage & ElastiCache Redis
In Part 2, we set up the EC2 server with Nginx and PHP-FPM. Now let's add the data layer: managed MySQL, object storage, and Redis cache.
Why Managed Services?
| Self-hosted on EC2 | AWS Managed | |
|---|---|---|
| Backups | You configure cron + mysqldump | Automated daily snapshots |
| Failover | You build replication | Multi-AZ automatic failover |
| Updates | You patch manually | One-click minor version updates |
| Monitoring | You set up tools | Built-in CloudWatch metrics |
| Cost | Cheaper upfront | Worth the time you save |
RDS MySQL 8.0
Create a DB Subnet Group
RDS needs to know which subnets it can use. We'll use our private subnets (the whole point of private subnets):
aws rds create-db-subnet-group \
--db-subnet-group-name laravel-db-subnet \
--db-subnet-group-description "Private subnets for Laravel RDS" \
--subnet-ids subnet-private-a subnet-private-c
Launch RDS Instance
aws rds create-db-instance \
--db-instance-identifier laravel-mysql \
--db-instance-class db.t3.micro \
--engine mysql \
--engine-version "8.0" \
--master-username admin \
--master-user-password "YOUR_STRONG_PASSWORD" \
--allocated-storage 20 \
--storage-type gp3 \
--db-subnet-group-name laravel-db-subnet \
--vpc-security-group-ids sg-rds \
--backup-retention-period 7 \
--preferred-backup-window "18:00-19:00" \
--preferred-maintenance-window "sun:19:00-sun:20:00" \
--no-publicly-accessible \
--storage-encrypted \
--multi-az \
--tags Key=Name,Value=laravel-mysql
Key options explained:
| Option | Value | Why |
|---|---|---|
--no-publicly-accessible |
N/A | RDS lives in private subnet, no internet access |
--storage-encrypted |
N/A | Encrypt data at rest (free, no performance impact) |
--multi-az |
N/A | Standby replica in AZ-c for automatic failover |
--backup-retention-period 7 |
7 days | Automated daily backups kept for 7 days |
--db-instance-class db.t3.micro |
1 vCPU/1GB | Good starting point, scale up later |
Via AWS Console
- Go to RDS → Create database
- Choose Standard create → MySQL 8.0
- Template: Production (or Free tier for testing)
- Settings:
- DB instance identifier:
laravel-mysql - Master username:
admin - Master password:
<strong password>
- DB instance identifier:
- Instance:
db.t3.micro - Storage: 20 GB gp3, enable auto-scaling up to 100 GB
- Connectivity:
- VPC:
laravel-production - Subnet group:
laravel-db-subnet - Public access: No
- Security group:
laravel-rds-sg
- VPC:
- Additional:
- Initial database name:
laravel - Backup retention: 7 days
- Encryption: Enable
- Monitoring: Enable Enhanced Monitoring (free tier covers basic)
- Initial database name:
Wait 5-10 minutes for the instance to be available. Note the Endpoint (something like laravel-mysql.xxxx.ap-northeast-1.rds.amazonaws.com).
Configure Laravel .env
SSH into EC2 and edit the shared .env:
vim /var/www/laravel/shared/.env
DB_CONNECTION=mysql
DB_HOST=laravel-mysql.xxxx.ap-northeast-1.rds.amazonaws.com
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=admin
DB_PASSWORD=YOUR_STRONG_PASSWORD
Test Connection from EC2
# Install MySQL client
sudo dnf install -y mariadb105
# Test connection
mysql -h laravel-mysql.xxxx.ap-northeast-1.rds.amazonaws.com \
-u admin -p \
-e "SELECT VERSION();"
If it connects, your security groups are configured correctly (EC2-sg → RDS-sg on port 3306).
Run Migrations
cd /var/www/laravel/current
php artisan migrate --force
RDS Parameter Group (Optional but Recommended)
Create a custom parameter group for performance tuning:
aws rds create-db-parameter-group \
--db-parameter-group-name laravel-mysql-params \
--db-parameter-group-family mysql8.0 \
--description "Laravel optimized MySQL 8.0 parameters"
aws rds modify-db-parameter-group \
--db-parameter-group-name laravel-mysql-params \
--parameters \
"ParameterName=character_set_server,ParameterValue=utf8mb4,ApplyMethod=pending-reboot" \
"ParameterName=collation_server,ParameterValue=utf8mb4_unicode_ci,ApplyMethod=pending-reboot" \
"ParameterName=max_connections,ParameterValue=200,ApplyMethod=pending-reboot" \
"ParameterName=slow_query_log,ParameterValue=1,ApplyMethod=immediate" \
"ParameterName=long_query_time,ParameterValue=2,ApplyMethod=immediate"
S3 Bucket
Create the Bucket
aws s3api create-bucket \
--bucket your-app-laravel-storage \
--region ap-northeast-1 \
--create-bucket-configuration LocationConstraint=ap-northeast-1
# Block all public access (private by default)
aws s3api put-public-access-block \
--bucket your-app-laravel-storage \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# Enable versioning (protect against accidental deletes)
aws s3api put-bucket-versioning \
--bucket your-app-laravel-storage \
--versioning-configuration Status=Enabled
# Enable server-side encryption
aws s3api put-bucket-encryption \
--bucket your-app-laravel-storage \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}'
Lifecycle Policy (Clean Up Old Versions)
aws s3api put-bucket-lifecycle-configuration \
--bucket your-app-laravel-storage \
--lifecycle-configuration '{
"Rules": [{
"ID": "cleanup-old-versions",
"Status": "Enabled",
"NoncurrentVersionExpiration": {"NoncurrentDays": 30},
"Filter": {"Prefix": ""}
}]
}'
CORS Configuration (for Direct Upload)
If your frontend uploads directly to S3:
aws s3api put-bucket-cors \
--bucket your-app-laravel-storage \
--cors-configuration '{
"CORSRules": [{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["https://your-domain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}'
Configure Laravel for S3
Install the S3 driver:
cd /var/www/laravel/current
composer require league/flysystem-aws-s3-v3
Update .env:
FILESYSTEM_DISK=s3
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=your-app-laravel-storage
AWS_USE_PATH_STYLE_ENDPOINT=false
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed!
# The IAM role from Part 1 provides credentials automatically.
Security reminder: Because we attached an IAM role to EC2 in Part 1, the AWS SDK automatically picks up credentials. No access keys needed in
.env.
Usage in Laravel
// Upload
Storage::disk('s3')->put('avatars/user-1.jpg', $file);
// Get URL (temporary signed URL for private files)
$url = Storage::disk('s3')->temporaryUrl('avatars/user-1.jpg', now()->addMinutes(5));
// Download
$contents = Storage::disk('s3')->get('reports/monthly.pdf');
ElastiCache Redis (Optional but Recommended)
Redis dramatically improves Laravel performance for sessions, cache, and queues.
Create a Cache Subnet Group
aws elasticache create-cache-subnet-group \
--cache-subnet-group-name laravel-redis-subnet \
--cache-subnet-group-description "Private subnets for Laravel Redis" \
--subnet-ids subnet-private-a subnet-private-c
Redis Security Group
aws ec2 create-security-group \
--group-name laravel-redis-sg \
--description "Redis - Allow from EC2 only" \
--vpc-id vpc-xxxx
aws ec2 authorize-security-group-ingress \
--group-id sg-redis \
--protocol tcp --port 6379 \
--source-group sg-ec2
Create ElastiCache Cluster
aws elasticache create-cache-cluster \
--cache-cluster-id laravel-redis \
--cache-node-type cache.t3.micro \
--engine redis \
--num-cache-nodes 1 \
--cache-subnet-group-name laravel-redis-subnet \
--security-group-ids sg-redis \
--tags Key=Name,Value=laravel-redis
Wait a few minutes, then get the endpoint:
aws elasticache describe-cache-clusters \
--cache-cluster-id laravel-redis \
--show-cache-node-info \
--query 'CacheClusters[0].CacheNodes[0].Endpoint'
Configure Laravel for Redis
Install phpredis (already installed in Part 2). Update .env:
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=laravel-redis.xxxx.cache.amazonaws.com
REDIS_PASSWORD=null
REDIS_PORT=6379
Benefits Comparison
| Feature | File/DB | Redis |
|---|---|---|
| Session read | ~5ms (disk/query) | ~0.2ms |
| Cache read | ~5ms | ~0.2ms |
| Queue dispatch | ~10ms (DB poll) | ~1ms |
Database Backups to S3
Set up automated backups beyond RDS snapshots:
# Create a backup script
sudo vim /opt/scripts/backup-db.sh
#!/bin/bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/tmp/laravel_backup_${TIMESTAMP}.sql.gz"
S3_PATH="s3://your-app-laravel-storage/backups/db/"
# Dump database and compress
mysqldump \
-h laravel-mysql.xxxx.ap-northeast-1.rds.amazonaws.com \
-u admin \
-p"${DB_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
laravel | gzip > "${BACKUP_FILE}"
# Upload to S3
aws s3 cp "${BACKUP_FILE}" "${S3_PATH}"
# Clean up local file
rm -f "${BACKUP_FILE}"
echo "Backup completed: ${TIMESTAMP}"
sudo chmod +x /opt/scripts/backup-db.sh
# Schedule daily at 3 AM
echo "0 3 * * * /opt/scripts/backup-db.sh >> /var/log/db-backup.log 2>&1" | crontab -
Updated Architecture
EC2 (laravel-web-01)
├── Nginx → serves static files
├── PHP-FPM → executes Laravel
├── Supervisor → queue workers
└── Cron → scheduler + backups
│
├── → RDS MySQL 8.0 (private subnet)
│ - DB_HOST=laravel-mysql.xxxx.rds.amazonaws.com
│
├── → ElastiCache Redis (private subnet)
│ - REDIS_HOST=laravel-redis.xxxx.cache.amazonaws.com
│
└── → S3 Bucket (via IAM role)
- FILESYSTEM_DISK=s3
Verify Everything
cd /var/www/laravel/current
# Test database
php artisan migrate:status
# Test Redis
php artisan tinker
>>> Cache::put('test', 'hello', 60);
>>> Cache::get('test');
// "hello"
# Test S3
php artisan tinker
>>> Storage::disk('s3')->put('test.txt', 'Hello from EC2!');
>>> Storage::disk('s3')->get('test.txt');
// "Hello from EC2!"
What's Next
In Part 4, we'll set up the Application Load Balancer with SSL termination via ACM, configure CloudFront as CDN for static assets, and set up Route 53 for custom domain management.
Series Navigation:
- ← Part 0: Prerequisites
- ← Part 1: Architecture & VPC
- ← Part 2: EC2 & Amazon Linux 2023
- Part 3: RDS, S3 & ElastiCache (You are here)
- Part 4: ALB, CloudFront & SSL →
- Part 5: CI/CD & Zero-Downtime Deploy →