Deploy Laravel to AWS (Part 2): EC2 with Amazon Linux 2023 — Nginx + PHP-FPM from Scratch

· 7 min read

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

  1. Go to EC2 → Launch Instance
  2. 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/config for convenience:

Host laravel-prod
    HostName <PUBLIC_IP>
    User ec2-user
    IdentityFile ~/.ssh/laravel-production.pem

Then 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 = 0 means PHP won't check if files changed. This is a huge performance boost but means you must run php artisan opcache:clear or 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:

Comments