Deploy Laravel to AWS (Part 2): EC2 with Amazon Linux 2023 — Nginx + PHP-FPM from Scratch
In Part 1, we set up the VPC, subnets, and security groups. Now let's launch an actual server and get Laravel running on it.
Launch the EC2 Instance
Via AWS Console
- Go to EC2 → Launch Instance
- Configure:
| Setting | Value |
|---|---|
| Name | laravel-web-01 |
| AMI | Amazon Linux 2023 (al2023-ami-2023.x) |
| Instance type | t3.small (2 vCPU, 2GB RAM) |
| Key pair | laravel-production |
| VPC | laravel-production |
| Subnet | laravel-public-a |
| Auto-assign public IP | Enable |
| Security group | laravel-ec2-sg |
| IAM instance profile | laravel-ec2-profile |
| Storage | 20 GB gp3 |
Via AWS CLI
aws ec2 run-instances \
--image-id ami-0abcdef1234567890 \
--instance-type t3.small \
--key-name laravel-production \
--subnet-id subnet-public-a \
--security-group-ids sg-ec2 \
--iam-instance-profile Name=laravel-ec2-profile \
--block-device-mappings '[{"DeviceName":"/dev/xvda","Ebs":{"VolumeSize":20,"VolumeType":"gp3"}}]' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=laravel-web-01}]' \
--associate-public-ip-address
SSH Into the Server
ssh -i laravel-production.pem ec2-user@<PUBLIC_IP>
Tip: Add to
~/.ssh/configfor convenience:Host laravel-prod HostName <PUBLIC_IP> User ec2-user IdentityFile ~/.ssh/laravel-production.pemThen just:
ssh laravel-prod
System Setup
Update the system
sudo dnf update -y
Set timezone
sudo timedatectl set-timezone Asia/Tokyo
Install essential tools
sudo dnf install -y git vim htop unzip tar wget jq
Install Nginx
sudo dnf install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
Verify:
curl -s -o /dev/null -w "%{http_code}" http://localhost
# Should return 200
Install PHP 8.4
Amazon Linux 2023 uses dnf and provides PHP via the amazon-linux-extras or direct packages:
# Install PHP 8.4 and required extensions
sudo dnf install -y \
php8.4-fpm \
php8.4-cli \
php8.4-common \
php8.4-mbstring \
php8.4-xml \
php8.4-curl \
php8.4-zip \
php8.4-gd \
php8.4-intl \
php8.4-bcmath \
php8.4-mysqlnd \
php8.4-pdo \
php8.4-opcache \
php8.4-redis \
php8.4-sodium
Note: If PHP 8.4 is not available in the default repo, use the Remi repository:
sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-9.rpm sudo dnf module reset php -y sudo dnf module enable php:remi-8.4 -y sudo dnf install -y php-fpm php-cli php-common php-mbstring php-xml \ php-curl php-zip php-gd php-intl php-bcmath php-mysqlnd php-pdo \ php-opcache php-redis php-sodium
Configure PHP-FPM
sudo vim /etc/php-fpm.d/www.conf
Key settings:
; Run as nginx user
user = nginx
group = nginx
; Use Unix socket (faster than TCP)
listen = /run/php-fpm/www.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
; Process management
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500
Configure php.ini for production
sudo vim /etc/php.ini
; Production settings
memory_limit = 256M
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 60
max_input_time = 60
; Security
expose_php = Off
display_errors = Off
log_errors = On
error_log = /var/log/php-fpm/error.log
; OPcache (critical for performance)
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 0 ; Disable in production!
opcache.revalidate_freq = 0
Important:
opcache.validate_timestamps = 0means PHP won't check if files changed. This is a huge performance boost but means you must runphp artisan opcache:clearor restart PHP-FPM after each deploy. Our deploy script in Part 5 handles this automatically.
Start PHP-FPM
sudo systemctl enable php-fpm
sudo systemctl start php-fpm
# Verify socket exists
ls -la /run/php-fpm/www.sock
Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version
Install Node.js (for Vite builds)
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
sudo dnf install -y nodejs
node --version
npm --version
Configure Nginx for Laravel
Create the site config
sudo vim /etc/nginx/conf.d/laravel.conf
server {
listen 80;
server_name your-domain.com;
root /var/www/laravel/current/public;
index index.php index.html;
charset utf-8;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
# Static files — serve directly, cache aggressively
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Timeouts
fastcgi_read_timeout 60;
fastcgi_send_timeout 60;
}
# Block access to hidden files
location ~ /\.(?!well-known).* {
deny all;
}
}
Test and reload
sudo nginx -t
sudo systemctl reload nginx
Directory Structure for Zero-Downtime Deploy
We'll set up the directory structure now, even though automated deploys come in Part 5:
# Create deployment directories
sudo mkdir -p /var/www/laravel/{releases,shared}
sudo mkdir -p /var/www/laravel/shared/{storage,node_modules}
sudo mkdir -p /var/www/laravel/shared/storage/{app,framework,logs}
sudo mkdir -p /var/www/laravel/shared/storage/framework/{cache,sessions,views}
# Set ownership
sudo chown -R ec2-user:nginx /var/www/laravel
sudo chmod -R 775 /var/www/laravel/shared/storage
The deploy structure:
/var/www/laravel/
├── current → releases/20260326_120000/ (symlink to latest)
├── releases/
│ ├── 20260326_120000/ (current release)
│ ├── 20260325_150000/ (previous release)
│ └── 20260324_090000/ (older release)
└── shared/
├── .env (persistent)
├── storage/ (persistent)
└── node_modules/ (cached)
The current symlink points to the latest release. Rollback = point symlink to previous release. Zero downtime because the switch is atomic.
First Manual Deploy
Let's deploy manually once to verify everything works:
cd /var/www/laravel
# Clone repo into a timestamped release directory
RELEASE=$(date +%Y%m%d_%H%M%S)
git clone git@github.com:your/repo.git releases/$RELEASE
cd releases/$RELEASE
# Install PHP dependencies (production)
composer install --no-dev --optimize-autoloader --no-interaction
# Create .env in shared directory (first time only)
cp .env.example /var/www/laravel/shared/.env
vim /var/www/laravel/shared/.env # Configure database, app key, etc.
# Generate app key (first time only)
php artisan key:generate
# Link shared resources
rm -rf storage
ln -s /var/www/laravel/shared/storage storage
ln -s /var/www/laravel/shared/.env .env
# Build frontend assets
npm ci
npm run build
rm -rf node_modules # Clean up, not needed at runtime
# Laravel optimization
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# Set permissions
chmod -R 775 /var/www/laravel/shared/storage
chown -R ec2-user:nginx /var/www/laravel/shared/storage
# Activate release
cd /var/www/laravel
ln -sfn releases/$RELEASE current
# Restart PHP-FPM to pick up new code
sudo systemctl reload php-fpm
Visit http://<PUBLIC_IP> — you should see your Laravel app!
Install Supervisor (for Queues)
sudo dnf install -y supervisor
sudo systemctl enable supervisord
sudo systemctl start supervisord
Configure Laravel queue worker
sudo vim /etc/supervisord.d/laravel-worker.ini
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=ec2-user
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
Install Laravel Scheduler (Cron)
crontab -e
Add:
* * * * * cd /var/www/laravel/current && php artisan schedule:run >> /dev/null 2>&1
Firewall: Verify Security
Test that the server is properly locked down:
# From your local machine:
# Should work (through ALB later, direct for now)
curl http://<PUBLIC_IP>
# Should timeout (SSH is only allowed from your IP)
# Try from a different IP — it should fail
# MySQL port should NOT be accessible from internet
nmap -p 3306 <PUBLIC_IP>
# Should show: filtered or closed
Server Summary
What we've installed on this EC2 instance:
| Component | Version | Purpose |
|---|---|---|
| Amazon Linux 2023 | Latest | Base OS |
| Nginx | Latest | Web server, static files |
| PHP-FPM | 8.4 | PHP execution |
| Composer | 2.x | PHP dependencies |
| Node.js | 20 LTS | Build frontend assets |
| Supervisor | Latest | Queue workers |
| Git | Latest | Deployment |
What's Next
In Part 3, we'll set up RDS MySQL in the private subnet, create an S3 bucket for file storage, configure Laravel to use both, and optionally add ElastiCache (Redis) for sessions and cache.
Series Navigation:
- ← Part 0: Prerequisites
- ← Part 1: Architecture & VPC
- Part 2: EC2 & Amazon Linux 2023 (You are here)
- Part 3: RDS, S3 & ElastiCache →
- Part 4: ALB, CloudFront & SSL →
- Part 5: CI/CD & Zero-Downtime Deploy →