Deploy Laravel to AWS (Part 3): RDS MySQL, S3 Storage & ElastiCache Redis

· 7 min read

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

  1. Go to RDS → Create database
  2. Choose Standard create → MySQL 8.0
  3. Template: Production (or Free tier for testing)
  4. Settings:
    • DB instance identifier: laravel-mysql
    • Master username: admin
    • Master password: <strong password>
  5. Instance: db.t3.micro
  6. Storage: 20 GB gp3, enable auto-scaling up to 100 GB
  7. Connectivity:
    • VPC: laravel-production
    • Subnet group: laravel-db-subnet
    • Public access: No
    • Security group: laravel-rds-sg
  8. Additional:
    • Initial database name: laravel
    • Backup retention: 7 days
    • Encryption: Enable
    • Monitoring: Enable Enhanced Monitoring (free tier covers basic)

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

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');

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:

Comments