Nginx + PHP-FPM for Laravel: The Correct Server Block
In part one of this series, your server got its foundation sorted: a deploy user, key-only SSH login, a live firewall, and swap in place. Now comes the part that causes the most frustration on a first Laravel deploy: opening the domain and getting a 404, a 502, or — worse — a downloadable file listing. Almost always, the cause is not your Laravel code. It is a server block pointing at the wrong place, or PHP-FPM not connected properly.
This article installs Nginx and PHP-FPM in line with Laravel's deployment documentation: the root points at the public directory, every request that is not a static file goes to index.php, and hidden files like .env cannot be reached from outside. At the end, HTTPS from Let's Encrypt goes live too. This article assumes part one's foundation is done and your domain already points at the server's IP — without correct DNS, Certbot cannot verify domain ownership in the final step.
Choose the PHP version first, before installing anything
Laravel's deployment documentation requires PHP 8.2 at minimum, plus the Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer, and XML extensions. On Ubuntu 24.04 LTS, the default repositories ship PHP 8.3 — above that floor, with no third-party repository needed. That is why this article uses php8.3 in every package name and path. If you run a different Ubuntu release, swap 8.3 for the PHP version it ships, but do not go below 8.2: Composer will refuse to install a Laravel version that requires something newer, and the error often only appears midway through a deploy.
Update packages before installing, just like in part one. Besides keeping the package lists fresh, this makes sure you get the PHP 8.3 build carrying the October 2026 security fix USN-8948-1 for the PHP2Shell flaws, not a still-vulnerable one:
apt update && apt upgrade -y
Step 1: Install Nginx, PHP-FPM, and the Laravel extensions
One command installs everything. The php8.3-fpm package is the PHP process that takes requests from Nginx; php8.3-cli is command-line PHP for Artisan and Composer later; the rest are the extensions Laravel needs plus a few nearly every real app uses (ZIP for archives, BCMath for precise math, Intl for locale formatting, and database drivers):
apt install -y nginx php8.3-fpm php8.3-cli php8.3-common php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-bcmath php8.3-intl php8.3-mysql php8.3-sqlite3
You can drop either php8.3-mysql or php8.3-sqlite3 once you are sure you only use one database type — but installing both up front is cheaper than a failed deploy over a missing driver. Verify the install:
php -v php -m | grep -E 'mbstring|curl|xml|zip|bcmath|intl' systemctl status php8.3-fpm --no-pager
Expected result: php -v shows PHP 8.3.x, the extension list includes the names above, and the FPM status is active (running). If FPM is not running, read journalctl -u php8.3-fpm --no-pager -n 50 before changing any configuration — early failures are usually about the socket or permissions, not the app.
Also check where the FPM socket actually listens, because that path must match exactly what you write in the server block later:
ls /run/php/
Expected result: a file named php8.3-fpm.sock. On Ubuntu, the canonical path is /run/php/php8.3-fpm.sock (Laravel's docs example uses /var/run/php/php8.2-fpm.sock — /var/run is a symlink to /run, so both point to the same place, but writing the /run/php/ path explicitly leaves nothing to guess).
Step 2: Configure the FPM pool for a single app
The FPM pool configuration lives in /etc/php/8.3/fpm/pool.d/www.conf. For one app on one server, the default www pool is enough — what you need to align is three things: who runs the processes, which socket is used, and how many workers are allowed. Open the file:
nano /etc/php/8.3/fpm/pool.d/www.conf
Make sure these lines read as follows (several are already Ubuntu defaults — check them rather than assuming):
user = www-data group = www-data listen = /run/php/php8.3-fpm.sock listen.owner = www-data listen.group = www-data listen.mode = 0660 pm = dynamic pm.max_children = 20 pm.start_servers = 4 pm.min_spare_servers = 2 pm.max_spare_servers = 6
What each choice means in short: pm = dynamic lets FPM grow and shrink workers with load, so the server does not sit idle with 20 workers when quiet, nor run out of workers when busy. pm.max_children = 20 is a sane starting point for a 4 GB VPS — each Laravel PHP worker typically uses 30–60 MB, so 20 workers mean roughly 0.6–1.2 GB at peak load, leaving room for Nginx, the database, and queue workers. If workers keep hitting the ceiling later, raise the number while watching RAM; if RAM hits the ceiling first, lower it.
One decision deliberately left unchanged here: FPM keeps running as www-data, not as deploy. That ties into the app directory permissions covered in the deploy article later in this series: app files are owned by deploy:www-data, and only storage and bootstrap/cache get group write permission. That way the web process can write where it genuinely needs to write, but cannot modify app code.
After editing, test and reload FPM:
php-fpm8.3 -t systemctl reload php8.3-fpm
Expected result: the config test reports success and the reload finishes without errors. Note that php-fpm8.3 -t uses the version-specific binary — do not confuse it with php -t, which does not exist.
Step 3: Write the server block with its root at public
This is the core of the article, and also where the most expensive mistakes hide. Laravel's documentation is firm: the web server must point at the app's public directory, and the index.php inside it is the front controller for all incoming HTTP requests. Never point the root at the project folder, and never move index.php out of public — both expose sensitive files like .env to the public internet.
Create the server block at /etc/nginx/sites-available/laravel.conf. Replace app.example.com with your domain and /var/www/app with your project path:
nano /etc/nginx/sites-available/laravel.conf
server {
listen 80;
listen [::]:80;
server_name app.example.com www.app.example.com;
root /var/www/app/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
client_max_body_size 32M;
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$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.(?!well-known).* {
deny all;
}
}Let us walk through the lines that matter most. root /var/www/app/public is the security decision: only the contents of public are publicly reachable, not the whole project. try_files $uri $uri/ /index.php?$query_string is the routing decision: Nginx serves static files directly (CSS, images, built JS), and everything else goes to index.php with its query string — exactly what Laravel's router expects. Without this line, every route except the homepage returns 404.
In the PHP block, try_files $uri =404 is an important guard: a PHP file that does not exist must not be handed to FPM, since that opens the door to arbitrary file execution tricks. fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name tells FPM which file to execute, with the path already resolved (this matters once your project path uses symlinks, as in the release-based deploy strategy in part five of this series). Finally, the location ~ /\.(?!well-known).* block refuses all requests for hidden files — .env, .git, .htaccess — except /.well-known/, which must stay reachable because Certbot uses it for domain verification in the final step.
The client_max_body_size 32M value sets the upload limit on Nginx's side. It must match the PHP settings in /etc/php/8.3/fpm/php.ini — open that file and align them:
upload_max_filesize = 32M post_max_size = 32M
If they do not match, the smaller value wins, and users get a 413 error (from Nginx) or a silently failed upload (from PHP) with no clear message. After changing php.ini, reload FPM again: systemctl reload php8.3-fpm.
Enable the site and test the configuration:
ln -s /etc/nginx/sites-available/laravel.conf /etc/nginx/sites-enabled/ rm /etc/nginx/sites-enabled/default nginx -t systemctl reload nginx
Removing default matters: otherwise Nginx keeps answering any domain with the stock welcome page, and you will wonder why your domain shows the wrong page when the server block looks right. Expected result from nginx -t: syntax is ok and test is successful.
At this point, your Laravel app may not exist yet at /var/www/app — and that is fine. To prove Nginx and FPM are connected, create a temporary /var/www/app/public/index.php containing <?php phpinfo(); and open your domain in a browser. If the PHP info page appears, the wiring is right. Delete that file immediately after testing — a phpinfo() page leaks server version and configuration details to anyone who opens it.
Step 4: Switch HTTPS on with Certbot
Once the site answers over HTTP and the domain's DNS points at the server, install Certbot with its Nginx plugin:
apt install -y certbot python3-certbot-nginx certbot --version
Then request the certificate:
certbot --nginx -d app.example.com -d www.app.example.com
Certbot asks for an email address (for renewal notices) and whether HTTP should redirect to HTTPS — choose the redirect so all old traffic gets encrypted too. When it finishes, Certbot has edited your server block itself: adding the listen 443 ssl block and the certificate paths under /etc/letsencrypt/live/app.example.com/. Do not hand-edit that Certbot-generated block unless you know exactly what you are changing; if the server block needs changes later, re-run certbot --nginx or edit carefully and run nginx -t after.
Open the HTTPS port in the firewall — until now UFW has only allowed SSH, and Nginx installed later was never permitted:
ufw allow 'Nginx Full' ufw status verbose
Expected result: a Nginx Full rule listed as ALLOW, and https://app.example.com opening with a valid padlock. Finally, test automatic renewal — Let's Encrypt certificates last only 90 days, and Ubuntu runs the renewal check through a systemd timer, so you do not need a cron job of your own:
certbot renew --dry-run systemctl is-active certbot.timer
Expected result: the dry-run renewal finishes without errors and the timer reports active. If the dry run fails, do not wait 90 days to find out — check systemctl status certbot.timer and the logs in /var/log/letsencrypt/ right now.
If you hit an error, read this table first
| Symptom | Likely cause | Command to check |
|---|---|---|
| 502 Bad Gateway | FPM not running, or the socket in the server block does not match the FPM socket | systemctl status php8.3-fpm, ls /run/php/, tail -n 50 /var/log/nginx/error.log |
| 404 on every route except the homepage | try_files not passing to index.php, or root not public | Check root and the location / block in /etc/nginx/sites-available/laravel.conf |
| PHP files download instead of executing | The location ~ \.php$ block is missing or FPM is not connected | nginx -T | grep -A 8 'location ~' |
| 413 Request Entity Too Large | Upload exceeds client_max_body_size or upload_max_filesize | Match both in the server block and /etc/php/8.3/fpm/php.ini, then reload Nginx and FPM |
| 403 Forbidden | Directory permissions do not let www-data read public | namei -l /var/www/app/public/index.php, check group and read permissions |
| Blank page / 500 after deploy | APP_DEBUG still true in production, or storage not writable | tail -n 100 /var/www/app/storage/logs/laravel.log, check storage and bootstrap/cache permissions |
| Certbot verification fails | DNS not pointing at the server, or port 80 blocked by the firewall | dig +short app.example.com, ufw status, curl -I http://app.example.com |
General rule for diagnosis: the Nginx log at /var/log/nginx/error.log answers whether the request reached FPM and why it was refused, while the Laravel log at storage/logs/laravel.log answers whether the app ran but failed inside. Checking both before changing configuration saves a round of guessing.
Summary table
| Component | Decision | Key file |
|---|---|---|
| PHP | 8.3 from the Ubuntu 24.04 repositories (meets Laravel's 8.2 minimum) | php8.3-fpm, php8.3-cli, mbstring/xml/curl/zip/bcmath/intl/mysql/sqlite3 extensions |
| FPM pool | www, www-data user, socket /run/php/php8.3-fpm.sock, pm = dynamic, max 20 workers | /etc/php/8.3/fpm/pool.d/www.conf |
| Server block | Root at public, try_files to index.php, hidden files blocked | /etc/nginx/sites-available/laravel.conf |
| Upload limit | 32M in both Nginx and PHP, matched | Server block + /etc/php/8.3/fpm/php.ini |
| HTTPS | Certbot Nginx plugin, HTTP to HTTPS redirect, renewal via timer | /etc/letsencrypt/live/app.example.com/, certbot.timer |
| Firewall | Add Nginx Full once HTTPS is live | ufw allow 'Nginx Full' |
Final checklist
php -vshows PHP 8.3 and the Laravel extensions are installedsystemctl status php8.3-fpmis active, socket/run/php/php8.3-fpm.sockexistsnginx -tpasses, thedefaultsite is removed fromsites-enabled- The domain answers over HTTP, and the temporary
phpinfo()page is deleted after testing https://opens with a valid certificate, HTTP redirects to HTTPScertbot renew --dry-runpasses andcertbot.timeris activeufw statusshowsNginx Fullnext toOpenSSH- The
.envfile cannot be opened from a browser (test:curl -I https://app.example.com/.envmust return 403 or 404, not 200)
The next article covers the deploy itself: pulling code with Git, installing dependencies with Composer, running migrations safely, and linking storage — all on top of the server block you just built. What was the first error you hit when you first set up Nginx for Laravel — a 502, a 404 on every route, or something else? Tell us in the comments, along with the cause you eventually found, so other readers do not walk the same path.
Baca juga: the server foundation from the previous article in {{URL-EN-29}}, how one VPS serves many services through Satu VPS Banyak Layanan: Nginx Reverse Proxy, and the Docker Compose introduction for self-hosting in Docker Compose untuk Self-Hosting dari Nol.


Komentar
Posting Komentar