Hostinger VPS for Laravel: Production Setup Guide with Real Benchmarks (2026)

Published 5 September 2026. Every benchmark below was executed on a live 2 vCPU KVM VPS running Laravel 13.30.1 the same day. The measurement methodology is stated in full in section 5.

1. The Architecture Blueprint & Executive Verdict

A Laravel application serves roughly 190 requests per second on a 2 vCPU KVM VPS costing about $9 per month, on a route that runs a database query and renders a Blade view. That figure assumes two things are configured. Turn OPcache off and the same route collapses to 16.4 requests per second — a factor of 11.6. Skip the artisan cache commands and you lose a further 29%.

The hardware is not what separates a fast Laravel deployment from a slow one. Two configuration decisions, both free, account for an order of magnitude. Buying a larger instance to compensate for a disabled opcode cache is the most expensive mistake available on this stack.

One number reframes where the remaining time goes: the SQL query on our benchmark route completed in 0.86 ms, inside a request with a 49 ms median. The database accounted for 1.7% of the response. Framework bootstrap, autoloading and view rendering consumed the rest. If your Laravel page is slow, the query log is usually the wrong place to look first. For how this fits a wider self-hosted stack, see our complete self-hosted SaaS architecture guide.

Hardware & Deployment Decision Matrix

ParameterMinimum viableProduction recommendedEvidence from measurement
vCPU12Load average reached 3.47 at concurrency 50; 1 vCPU has no headroom for queue workers plus web traffic
RAM2 GB8 GBMeasured 33.1 MB RSS per PHP-FPM worker; 8 workers = 265 MB, leaving room for MySQL buffer pool and Redis
Storage40 GB NVMe100 GB NVMeMeasured 1.7 GB/s sequential write, 1,575 IOPS at 4 KiB synchronous
OPcacheMandatory, not optional16.4 req/s without it, 190 req/s with it
RedisoptionalrequiredDatabase-backed queues and sessions add write pressure to the same disk serving your data
Swap1 GB2 GBZero swap consumed across every run; it exists to survive a composer install, not to serve traffic
Est. monthly cost~$5~$9KVM2-class tier at 24-month pricing

2. Total Cost of Ownership: Managed Laravel Platforms vs. Self-Managed VPS

Laravel’s managed ecosystem splits into two shapes. Provisioning services such as Forge charge a monthly fee and then configure a server you still rent and still own operationally. Serverless platforms such as Vapor remove the server but bill per invocation and per resource. Both sit against a plain VPS you configure yourself, which is what the guide in section 3 produces.

Cost lineManaged provisioningServerlessSelf-managed VPS
Platform fee$12 / month$39 / month$0
Compute$9 / month (you still rent it)usage-based, ~$25 / month at modest traffic$9 / month
Managed databaseincluded on your own server$15 / month$0 (on-box MySQL)
Off-site backup, 50 GB$1.50 / monthincluded$1.50 / month
Setup labour~15 min~2 h first deploy~90 min first deploy
Ongoing maintenance~30 min / month~15 min / month~1 h / month
Year 1$270$948$126
Year 3 cumulative$810$2,844$378

Managed provisioning carries no upfront cost, so there is no classic breakeven crossing: the self-managed path is cheaper from month one and the gap widens linearly. The correct question is not when self-managing pays back, but what the monthly difference buys. Let D be the monthly saving and H your hourly rate:

D = platform_fee + managed_extras
  = 12 + 0 = $12 / month  (versus managed provisioning)

Hours of your time that saving covers each month:
  H_break = D / your_hourly_rate

At $50/hour:  12 / 50 = 0.24 hours = 14 minutes per month.

If self-managing costs you more than fourteen minutes a month over the managed alternative, the managed alternative is cheaper in real terms. Our measured maintenance load is closer to one hour, which means a single Laravel application on a single server does not justify hand-rolling the stack on cost alone. It justifies it on control, on running several applications on the same box, and on not having your deploy pipeline depend on a third party’s uptime. Be honest about which of those you actually want.

The cost lines that spreadsheets omit

Transactional email. Laravel sends password resets, verification links and notifications out of the box. A fresh VPS IP has no sending reputation and its mail lands in spam. Budget $10 to $15 per month for a relay once volume passes a few thousand messages.

Zero-downtime deploys. A naive git pull followed by composer install serves a broken application for the duration of the install. Atomic releases with a symlink swap are the fix, and building that yourself is an afternoon you should price in once.

Framework upgrades. Laravel ships a major version annually with roughly 18 months of security support. Budget half a day per major version, more if your dependency tree includes packages that lag the release.

3. Step-by-Step Production Provisioning & Deployment Guide

Target: Ubuntu 24.04.4 LTS, kernel 6.8. Every command below ran on the benchmark machine.

Server provisioning & OS hardening

# On your workstation, never on the server
ssh-keygen -t ed25519 -a 100 -C "deploy@laravel-prod" -f ~/.ssh/id_ed25519_laravel
ssh-copy-id -i ~/.ssh/id_ed25519_laravel.pub root@YOUR_SERVER_IP
adduser --gecos "" deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF

sshd -t && systemctl reload ssh

The sshd -t guard is what stops a typo from locking you out: it exits non-zero on a parse error, so the && never reaches the reload. Confirm the new login in a second terminal before closing the first.

ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

apt-get update && apt-get install -y unattended-upgrades fail2ban
systemctl enable --now fail2ban unattended-upgrades

Note what is absent: MySQL’s 3306 and Redis’s 6379 are never opened. Both bind to 127.0.0.1 and are reached over the loopback. A Redis instance exposed to the internet without authentication is compromised within hours, and the attack requires no exploit — CONFIG SET dir is a documented feature.

Runtime environment stack

apt-get install -y nginx \
  php8.3-fpm php8.3-cli php8.3-bcmath php8.3-mbstring php8.3-xml \
  php8.3-curl php8.3-zip php8.3-mysql php8.3-gd php8.3-intl \
  php8.3-opcache php8.3-redis \
  mysql-server redis-server certbot python3-certbot-nginx unzip git

systemctl enable --now nginx php8.3-fpm mysql redis-server
mysql_secure_installation

curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
composer --version

Every extension in that list is a Laravel requirement or a near-universal dependency. bcmath and intl are the two most often omitted, and both fail at runtime rather than at install, typically inside a payment or localisation package weeks after deployment.

Dedicated PHP-FPM pool

cat > /etc/php/8.3/fpm/pool.d/laravel.conf <<'EOF'
[laravel]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-laravel.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500

php_admin_value[error_log] = /var/log/php8.3-fpm-laravel.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 68M
php_admin_value[max_execution_time] = 60
php_admin_flag[expose_php] = off

php_admin_value[opcache.enable] = 1
php_admin_value[opcache.memory_consumption] = 192
php_admin_value[opcache.interned_strings_buffer] = 16
php_admin_value[opcache.max_accelerated_files] = 20000
php_admin_value[opcache.validate_timestamps] = 0
php_admin_value[opcache.jit_buffer_size] = 64M
php_admin_value[opcache.jit] = tracing
EOF

php-fpm8.3 -t && systemctl reload php8.3-fpm

Two of those values carry consequences. opcache.validate_timestamps = 0 stops PHP checking file modification times on every request, which is where a measurable share of the gain in section 5 comes from — and it means deployed code changes have no effect until systemctl reload php8.3-fpm runs. Put that reload in your deploy script or you will spend an afternoon debugging a fix that is already on disk.

pm.max_children = 8 follows from measurement, not preference. We recorded 33.1 MB average RSS per worker. Memory would permit far more, but 2 vCPU cannot execute more than a handful concurrently, and a larger pool converts a queue into a longer queue with the same throughput and worse latency.

nginx server block

# /etc/nginx/sites-available/laravel.conf
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name app.example.com;

    root /var/www/laravel/current/public;
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    client_max_body_size 64M;
    fastcgi_buffers 16 16k;
    fastcgi_buffer_size 32k;

    charset utf-8;
    access_log /var/log/nginx/laravel-access.log;
    error_log  /var/log/nginx/laravel-error.log;

    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; }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm-laravel.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|woff2)$ {
        expires 30d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

    location ~ /\.(?!well-known).* { deny all; }
    location ~ ^/(storage|bootstrap)/.*\.php$ { deny all; }
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;
    return 301 https://app.example.com$request_uri;
}

$realpath_root rather than $document_root is the detail that matters for atomic deploys. With a symlinked current directory, $document_root resolves to the symlink path and OPcache keys its cached opcodes against it, so a release swap serves stale code from the previous release. $realpath_root resolves the symlink, which makes each release a distinct cache key.

ln -s /etc/nginx/sites-available/laravel.conf /etc/nginx/sites-enabled/laravel.conf
nginx -t && systemctl reload nginx
certbot --nginx -d app.example.com --redirect --agree-tos -m you@example.com --non-interactive

Database, Redis & queue workers

DBPASS=$(openssl rand -base64 24)
mysql -e "CREATE DATABASE laravel_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER 'laravel'@'localhost' IDENTIFIED BY '${DBPASS}';"
mysql -e "GRANT ALL PRIVILEGES ON laravel_prod.* TO 'laravel'@'localhost';"
mysql -e "FLUSH PRIVILEGES;"
echo "Store this in .env now: ${DBPASS}"
unset DBPASS

Grant on laravel_prod.*, never *.*. A scoped user cannot read another application’s schema if the credentials leak, and it is also why your mysqldump will need --no-tablespaces: the tablespace query requires a global PROCESS privilege the application has no reason to hold.

redis-cli ping                                 # expect: PONG
redis-cli config set maxmemory 512mb
redis-cli config set maxmemory-policy allkeys-lru
redis-cli config rewrite

# .env, production values
# APP_ENV=production
# APP_DEBUG=false
# CACHE_STORE=redis
# SESSION_DRIVER=redis
# QUEUE_CONNECTION=redis
# REDIS_CLIENT=phpredis

allkeys-lru matters when Redis holds both cache and queue data. Under the default noeviction policy a full instance starts refusing writes, and the first symptom is jobs failing to enqueue rather than a cache miss.

# /etc/systemd/system/laravel-worker@.service
[Unit]
Description=Laravel queue worker %i
After=network.target mysql.service redis-server.service
Requires=redis-server.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/laravel/current
ExecStart=/usr/bin/php8.3 artisan queue:work redis --queue=high,default --sleep=3 --tries=3 --max-time=3600 --backoff=10
Restart=always
RestartSec=5
StandardOutput=append:/var/log/laravel-worker.log
StandardError=append:/var/log/laravel-worker.log

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now laravel-worker@1.service laravel-worker@2.service
systemctl status 'laravel-worker@*' --no-pager

# Scheduler: one cron entry drives everything in routes/console.php
( crontab -l -u www-data 2>/dev/null; \
  echo "* * * * * cd /var/www/laravel/current && /usr/bin/php8.3 artisan schedule:run >> /dev/null 2>&1" ) \
  | crontab -u www-data -

The templated unit name lets you scale workers by starting another instance rather than editing a file. Two workers is the right starting point on 2 vCPU: they compete with PHP-FPM for the same cores, and a third buys queue throughput at the cost of web latency. --max-time=3600 recycles each worker hourly, because long-lived PHP processes accumulate memory and restarting on a timer is cheaper than finding out why.

Atomic zero-downtime deploy

#!/bin/bash
# /usr/local/bin/laravel-deploy
set -euo pipefail

APP=/var/www/laravel
REPO=git@github.com:you/your-app.git
BRANCH=main
RELEASE="$APP/releases/$(date +%Y%m%d-%H%M%S)"
KEEP=5

install -d -o www-data -g www-data "$APP/releases" "$APP/shared/storage"

sudo -u www-data git clone --depth 1 --branch "$BRANCH" "$REPO" "$RELEASE"

ln -sfn "$APP/shared/.env"    "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -sfn "$APP/shared/storage" "$RELEASE/storage"

cd "$RELEASE"
sudo -u www-data composer install --no-dev --optimize-autoloader --no-interaction --quiet
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan config:cache
sudo -u www-data php artisan route:cache
sudo -u www-data php artisan view:cache
sudo -u www-data php artisan event:cache

ln -sfn "$RELEASE" "$APP/current"

systemctl reload php8.3-fpm          # required: opcache.validate_timestamps = 0
systemctl restart 'laravel-worker@*' # workers hold the old code in memory

cd "$APP/releases" && ls -1dt */ | tail -n +$((KEEP+1)) | xargs -r rm -rf
echo "$(date -Is) deployed $RELEASE"

Two lines in that script are the ones people omit and then rediscover in production. The PHP-FPM reload is mandatory with validate_timestamps = 0. The worker restart is mandatory always: a queue worker loads your code once at boot and keeps running it, so without the restart your new deploy is live on the web and absent from the queue.

For the firewall, fail2ban and certificate renewal controls in isolation, see our Ubuntu 24.04 server hardening walkthrough.

4. Laravel Production Security Audit

Laravel ships secure defaults, and a production deployment routinely undoes several of them. Run this before the domain resolves publicly.

# 1. Debug mode off, environment correct. APP_DEBUG=true leaks the full
#    environment, including database credentials, on any unhandled exception.
grep -E "^(APP_ENV|APP_DEBUG|APP_KEY)=" /var/www/laravel/shared/.env
# Expect: APP_ENV=production, APP_DEBUG=false, APP_KEY set to a base64: value

# 2. .env must not be reachable over HTTP
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/.env
# Expect: 404. A 200 means your document root is the project root, not public/.

# 3. Document root points at public/, not the project root
grep -n "root " /etc/nginx/sites-available/laravel.conf
# Must end in /public

# 4. Storage and cache writable by www-data, and nothing else
find /var/www/laravel/shared/storage -type d ! -user www-data | head
stat -c '%a %U:%G %n' /var/www/laravel/shared/storage
# Expect: 775 www-data:www-data. 777 means someone fixed a permission error
# with a hammer and left a world-writable directory in production.

# 5. No PHP execution inside writable directories
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/storage/test.php

# 6. Dependency vulnerabilities
cd /var/www/laravel/current && composer audit

# 7. Dev dependencies must not be installed in production
composer show --installed 2>/dev/null | grep -E "phpunit|faker|ignition|telescope"
# Any hit means composer install ran without --no-dev

Item 7 deserves emphasis. Debug packages left in a production install have historically been the most reliable route into a Laravel application: they expose an execution surface that assumes it is running on a developer’s laptop. --no-dev in the deploy script is what prevents this, and the check above is what proves the script did what you believe.

If your application also runs third-party commercial code alongside Laravel, the audit protocol differs — see our review of production-ready CodeCanyon SaaS scripts and the full CodeCanyon deployment and security audit guide, which covers scanning purchased code for obfuscated backdoors and unindexed queries.

Encrypted off-site backup

#!/bin/bash
# /usr/local/bin/laravel-backup
set -euo pipefail

STAMP=$(date +%Y%m%d-%H%M)
WORKDIR=/var/backups/laravel
BUCKET=s3://your-bucket/laravel
RETENTION_DAYS=14
ENVFILE=/var/www/laravel/shared/.env

install -d -m 700 "$WORKDIR"

DB_NAME=$(grep -oP '^DB_DATABASE=\K.*' "$ENVFILE")
DB_USER=$(grep -oP '^DB_USERNAME=\K.*' "$ENVFILE")
DB_PASS=$(grep -oP '^DB_PASSWORD=\K.*' "$ENVFILE")

mysqldump --no-tablespaces --single-transaction --quick \
          -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
  | gzip -9 \
  | gpg --batch --yes --symmetric --cipher-algo AES256 \
        --passphrase-file /root/.backup-passphrase \
  > "$WORKDIR/db-$STAMP.sql.gz.gpg"
unset DB_PASS

tar -czf - -C /var/www/laravel/shared storage .env \
  | gpg --batch --yes --symmetric --cipher-algo AES256 \
        --passphrase-file /root/.backup-passphrase \
  > "$WORKDIR/files-$STAMP.tar.gz.gpg"

aws s3 cp "$WORKDIR/db-$STAMP.sql.gz.gpg"    "$BUCKET/" --storage-class STANDARD_IA
aws s3 cp "$WORKDIR/files-$STAMP.tar.gz.gpg" "$BUCKET/" --storage-class STANDARD_IA

find "$WORKDIR" -name '*.gpg' -mtime +$RETENTION_DAYS -delete
echo "$(date -Is) OK db=$(stat -c%s "$WORKDIR/db-$STAMP.sql.gz.gpg")o" >> "$WORKDIR/journal.log"

The archive includes .env, which holds APP_KEY. Without that key every encrypted column and every signed cookie in the restored database is unreadable, so a database-only backup is not a recoverable backup. This is also precisely why the archive is encrypted before it leaves the machine.

5. Performance Benchmarks: Production Load Test Results

Methodology. Laravel 13.30.1 on PHP 8.3.6 FPM, nginx 1.24.0, MySQL 8.0.46, Redis 7.0.15, Ubuntu 24.04.4 LTS. Hardware: AMD EPYC 9354P, 2 vCPU, 7.8 GiB RAM. Load generator: ApacheBench 2.3 with keep-alive, running on the same host, which consumes CPU that would otherwise serve requests and makes every figure below a conservative floor. Two routes were measured:

  • /bench/boot — returns a 2-byte response. Measures framework bootstrap, routing and middleware with no I/O.
  • /bench/db — queries the 25 most recent rows from a 5,000-row users table and renders a Blade view. Response: 2,182 bytes. This is the realistic profile of an application page.

Configuration impact, concurrency 10

Configuration/bench/boot req/sP50/bench/db req/sP50P95
OPcache disabled18.85512 ms16.39589 ms789 ms
OPcache default, no artisan caches171.4055 ms130.2270 ms133 ms
OPcache default + artisan caches191.7142 ms167.8852 ms107 ms
OPcache tuned + artisan caches268.9436 ms190.7849 ms~105 ms

Tuned figures are the median of three consecutive runs after warm-up (boot: 218.16 / 268.94 / 289.68; db: 178.77 / 190.78 / 193.87). Zero failed requests in every run.

ChangeEffect on /bench/db
Enabling OPcache at all7.9x throughput (16.39 to 130.22 req/s)
Adding config/route/view/event caches+28.9% (130.22 to 167.88 req/s)
Tuning OPcache and enabling JIT+13.6% (167.88 to 190.78 req/s)
All three combined11.6x (16.39 to 190.78 req/s)

Scaling and resource footprint

MetricValue
/bench/db at concurrency 50175.12 req/s, P50 270 ms, P95 327 ms, P99 354 ms, 0 failed
Load average at concurrency 503.47 on 2 vCPU
PHP-FPM RSS per worker33.1 MB average across 11 live workers
Swap consumed, peak0 MB
Single-request TTFB, warm10.5 ms median
SQL query in isolation0.86 ms
Disk, sequential write1.7 GB/s
Disk, 4 KiB synchronous write6.3 MB/s ≈ 1,575 IOPS

Four findings are worth extracting.

OPcache is the entire game. Without it, Laravel parses and compiles thousands of PHP files on every single request. The measured penalty was 7.9x on the database route. No amount of query optimisation recovers that, and no larger instance is a substitute.

The database is not your bottleneck. The query took 0.86 ms of a 49 ms median response, or 1.7%. Framework bootstrap, autoloading and Blade rendering account for the remainder. Teams routinely spend a sprint adding indexes to a page whose cost lies almost entirely elsewhere.

JIT needs warm-up, and the first measurement lies. Immediately after a PHP-FPM reload, /bench/db measured 156.33 req/s. The same configuration, after warm-up, measured 190.78. Benchmarking a JIT-enabled PHP-FPM pool straight after a reload understates it by roughly 18%. Run a few hundred warm-up requests before recording anything.

Nothing failed under saturation. At concurrency 50 with load average 3.47 on 2 vCPU, every request still returned 200; throughput held at 175 req/s while P50 latency rose from 49 ms to 270 ms. A saturated PHP-FPM pool queues rather than erroring, so an error-rate dashboard shows green while users wait. Alert on P95 latency, not on 5xx counts.

In practical terms, 190 req/s sustained is roughly 684,000 page renders per hour if traffic arrives evenly. Most applications this size never approach it. If you are weighing this against a managed platform that handles the tuning for you, our Cloudways vs. Hostinger VPS infrastructure comparison works through where each model wins.

Verify OPcache is genuinely active in the FPM pool, not merely enabled in the CLI binary, which is a different SAPI with different settings:

cat > /var/www/laravel/current/public/opcache-check.php <<'EOF'
<?php
$s = opcache_get_status(false);
header('Content-Type: text/plain');
echo 'enabled: ', var_export($s['opcache_enabled'], true), PHP_EOL;
echo 'cached scripts: ', $s['opcache_statistics']['num_cached_scripts'], PHP_EOL;
echo 'hit rate: ', round($s['opcache_statistics']['opcache_hit_rate'], 2), '%', PHP_EOL;
echo 'memory used: ', round($s['memory_usage']['used_memory'] / 1048576, 1), ' MB', PHP_EOL;
EOF

curl -s https://app.example.com/opcache-check.php
rm /var/www/laravel/current/public/opcache-check.php   # delete it immediately

A hit rate below 95% on a warm production server means opcache.max_accelerated_files is too low for your dependency tree and files are being evicted.

6. Real-World Troubleshooting & Failure Modes

502 Bad Gateway

2026/09/05 09:14:51 [crit] 1183#1183: *2201 connect() to
unix:/run/php/php8.3-fpm-laravel.sock failed (13: Permission denied)
while connecting to upstream, client: 203.0.113.44,
server: app.example.com, request: "GET / HTTP/1.1"

Read the errno before changing anything. Error 13 is a socket permission problem; error 2 (No such file or directory) means the pool never started at all.

systemctl status php8.3-fpm --no-pager
ls -l /run/php/php8.3-fpm-laravel.sock
# Expect: srw-rw---- 1 www-data www-data

journalctl -u php8.3-fpm -n 50 --no-pager
tail -50 /var/log/php8.3-fpm-laravel.log

# Error 13: the pool is missing its ownership directives
grep -E "listen\.(owner|group|mode)" /etc/php/8.3/fpm/pool.d/laravel.conf
php-fpm8.3 -t && systemctl restart php8.3-fpm

A pool that exits immediately on start is usually failing on an opcache.jit_buffer_size the host cannot allocate, or a php_admin_value[error_log] pointing at a directory that does not exist. Both are reported in the journal, never in the nginx log.

504 Gateway Time-out

2026/09/05 14:22:07 [error] 1183#1183: *4412 upstream timed out
(110: Connection timed out) while reading response header from upstream,
request: "POST /admin/export HTTP/1.1",
upstream: "fastcgi://unix:/run/php/php8.3-fpm-laravel.sock:"

Two independent timers must both be raised, and raising only one reproduces the error. nginx gives up after fastcgi_read_timeout (60 s by default); PHP gives up after max_execution_time. Set nginx higher so the script fails with a stack trace rather than a severed connection:

# In the location ~ \.php$ block
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;

# /etc/php/8.3/fpm/pool.d/laravel.conf
php_admin_value[max_execution_time] = 240
php_admin_value[max_input_time] = 240

nginx -t && systemctl reload nginx
php-fpm8.3 -t && systemctl reload php8.3-fpm

Raising both on every route hides the real problem. An export that needs four minutes belongs in the queue built in section 3, dispatched with ExportJob::dispatch() and delivered by email or download link.

419 Page Expired

Laravel’s own status code for a CSRF token mismatch, and almost never an actual attack. On a freshly provisioned server it has three usual causes, in descending order of frequency.

# 1. Sessions cannot be written. Check the driver and the destination.
grep -E "^(SESSION_DRIVER|SESSION_LIFETIME|SESSION_DOMAIN)=" /var/www/laravel/shared/.env
redis-cli ping                                   # if SESSION_DRIVER=redis
ls -ld /var/www/laravel/shared/storage/framework/sessions   # if =file
# Must be writable by www-data.

# 2. APP_KEY changed or is missing: every existing session becomes undecryptable.
grep '^APP_KEY=' /var/www/laravel/shared/.env
# Empty output means every form on the site returns 419.

# 3. Stale config cache still holds the previous session configuration.
cd /var/www/laravel/current
sudo -u www-data php artisan config:clear && sudo -u www-data php artisan config:cache

The third case is the one that surprises people mid-deploy: config:cache freezes the values read from .env at build time, so editing .env afterwards changes nothing until the cache is rebuilt. In production, env() outside a config file returns null for exactly this reason.

Queued jobs never run

Jobs accumulate in Redis and nothing processes them, with no error anywhere. Diagnose in this order:

# Is anything actually consuming the queue?
systemctl status 'laravel-worker@*' --no-pager
redis-cli llen queues:default

# Is the worker running code from a previous release?
systemctl show laravel-worker@1 -p ExecMainStartTimestamp
# If that predates your last deploy, the worker is executing stale code.
systemctl restart 'laravel-worker@*'

# Are jobs failing and being retried into oblivion?
cd /var/www/laravel/current
sudo -u www-data php artisan queue:failed
tail -50 /var/log/laravel-worker.log

# Is the worker connected to the same Redis database the app writes to?
grep -E "^(QUEUE_CONNECTION|REDIS_HOST|REDIS_DB|REDIS_QUEUE)=" /var/www/laravel/shared/.env

The single most common cause is the second check. A queue worker loads the application once and holds it in memory for its entire lifetime, so a deploy that does not restart the workers leaves them running the previous release indefinitely — dispatching new job classes they have never heard of, which then fail silently into failed_jobs.

Too many connections

SQLSTATE[HY000] [1040] Too many connections

Raising max_connections is the wrong first move, because each connection reserves per-thread buffers and the real ceiling is memory. Measure first:

mysql -e "SHOW STATUS LIKE 'Threads_connected';
          SHOW STATUS LIKE 'Max_used_connections';
          SHOW VARIABLES LIKE 'max_connections';"

mysql -e "SELECT USER, COMMAND, TIME, STATE, LEFT(INFO,60) AS query
          FROM information_schema.PROCESSLIST ORDER BY TIME DESC LIMIT 20;"

With 8 PHP-FPM workers and 2 queue workers, legitimate demand is around 10 connections. If Max_used_connections sits in the hundreds, something is opening connections in a loop — commonly a job that instantiates a new connection per iteration instead of reusing the resolved one.

# /etc/mysql/mysql.conf.d/60-laravel.cnf
[mysqld]
max_connections = 100
wait_timeout = 120
interactive_timeout = 120
innodb_buffer_pool_size = 2G
innodb_log_file_size = 512M
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 2
thread_cache_size = 16
slow_query_log = 1
long_query_time = 0.5

systemctl restart mysql

innodb_flush_log_at_trx_commit = 2 flushes the redo log once per second instead of on every commit. Against the 1,575 IOPS measured on this disk, that is the difference between keeping up and not, at the cost of losing up to one second of committed transactions in a hard power loss. Acceptable for most applications. Leave it at 1 if you process payments.

7. Frequently Asked Questions

How much RAM does a Laravel app need on a VPS?

Budget 8 GB for production. We measured 33.1 MB RSS per PHP-FPM worker, so eight workers consume 265 MB, leaving room for a 2 GB InnoDB buffer pool, Redis and two queue workers. A 2 GB instance runs Laravel but leaves nothing for a composer install running alongside live traffic.

Is a $9 VPS fast enough for Laravel in production?

Yes. Our 2 vCPU KVM VPS sustained 190 requests per second on a route querying the database and rendering Blade, with a 49 ms median. That is roughly 684,000 page renders per hour. The constraint is configuration, not hardware: the same box managed only 16.4 requests per second with OPcache disabled.

Do I need Laravel Forge or can I configure the server myself?

You can configure it yourself in about 90 minutes using the steps above. Forge costs $12 monthly, which buys roughly fourteen minutes of your time at a $50 hourly rate. If self-managing costs you more than that each month, Forge is cheaper in real terms. The honest case for doing it manually is control and running several applications per server.

Why is my Laravel site slow after deploying?

Check OPcache first: disabling it cost us 7.9x throughput. Then confirm config:cache, route:cache and view:cache ran, worth a further 29%. If opcache.validate_timestamps is 0, your deploy must reload PHP-FPM or the server keeps executing the previous release. The database is rarely the cause: our query took 0.86 ms of a 49 ms request.

Should I run Laravel queue workers with systemd or Supervisor?

Use systemd. It is already installed on Ubuntu 24.04, so Supervisor adds a second process manager for no benefit. A templated unit lets you scale by starting laravel-worker@2 rather than editing configuration. Two workers is right on 2 vCPU; they compete with PHP-FPM for the same cores.

8. Final Production Checklist

Run each line and read the output. A checklist ticked without execution is a list of assumptions.

# DNS resolves here, asked of an authoritative nameserver rather than a cache
dig +short app.example.com @$(dig +short NS example.com | head -1)
dig +short A app.example.com | wc -l          # expect: 1, not a round-robin pair

# TLS valid and renewal proven, not assumed
certbot renew --dry-run
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates

# Firewall enforcing; 3306 and 6379 must NOT appear
ufw status verbose
ss -tlnp | grep -E ':(3306|6379)'             # expect: 127.0.0.1 only

# SSH refuses passwords
sshd -T | grep -E "^(permitrootlogin|passwordauthentication|pubkeyauthentication)"

# Laravel production posture
grep -E "^(APP_ENV|APP_DEBUG)=" /var/www/laravel/shared/.env
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/.env   # expect: 404

# OPcache genuinely active in the FPM pool
cd /var/www/laravel/current && sudo -u www-data php artisan about | grep -i cache

# Workers and scheduler survive a reboot
systemctl is-enabled laravel-worker@1.service   # expect: enabled
crontab -l -u www-data | grep schedule:run

# Security headers present
curl -sI https://app.example.com/ | grep -iE "strict-transport|x-frame|x-content-type|referrer-policy"

# Dependency audit clean, no dev packages in production
composer audit
composer show --installed | grep -E "phpunit|faker|ignition"   # expect: no output

# The backup restores. This is the only test that counts.
/usr/local/bin/laravel-backup
gpg --batch --quiet --passphrase-file /root/.backup-passphrase \
    -d /var/backups/laravel/db-$(date +%Y%m%d)-*.sql.gz.gpg | gunzip \
  | mysql restore_test_db
mysql restore_test_db -e "SHOW TABLES;" | head
mysql -e "DROP DATABASE restore_test_db;"

The two lines most often skipped are the restore test and certbot renew --dry-run. A backup never restored and a certificate never renewed are both untested code paths, and both fail at the least convenient moment. Run the restore quarterly and let the dry run go monthly from cron.

If your measured numbers differ from ours on comparable hardware, the gap is worth chasing rather than dismissing. Send the ab output and your pool configuration and we will publish the comparison.

Similar Posts