How to Configure PHP-FPM with NGINX on Ubuntu 24.04
A step-by-step tutorial that installs PHP-FPM on Ubuntu 24.04, configures a dedicated pool, connects it to NGINX with fastcgi_pass, and shows how to test the stack, fix 502 errors, and tune...
Most tutorials that mention PHP and NGINX together skip the part that actually trips people up: PHP is not a language NGINX understands natively. Apache can execute PHP directly through a module called mod_php, but NGINX has no equivalent. Instead, NGINX hands .php requests off to a completely separate program called PHP-FPM (FastCGI Process Manager), which does the actual work of running your PHP code and sending the result back. When that hand-off is misconfigured, the most common symptom is a 502 Bad Gateway error, and the two most common causes (a mismatched socket path, or a PHP-FPM service that is not running) are exactly what this tutorial teaches you to diagnose and avoid.
Table Of Content
- What PHP-FPM does, and why NGINX needs it
- What you will need
- Step 1: Confirm your Ubuntu version and install NGINX
- Step 2: Install PHP-FPM
- Step 3: Configure the PHP-FPM pool
- Understanding what a pool is
- Step 4: Connect NGINX to PHP-FPM
- Create a server block
- Enable the site and test the configuration
- Step 5: Test the stack end to end
- Step 6: Fix the errors you will actually run into
- 502 Bad Gateway
- 403 Forbidden, or a blank white page
- Step 7: Size pm.max_children instead of guessing
- Step 8: Turn on OPcache
- Common mistakes and gotchas
- How to verify everything works end to end
- Next steps
By the end of this tutorial you will have installed PHP-FPM on a fresh Ubuntu 24.04 LTS server, configured a dedicated PHP-FPM pool, wired it to NGINX with a working server block, tested the connection end to end, and learned how to read the two most common error states so you are not guessing the next time something breaks. Every command and configuration directive below was checked against the official PHP manual, the official NGINX documentation, and Ubuntu’s own package archive before publishing.
What PHP-FPM does, and why NGINX needs it
PHP-FPM is what PHP’s own manual calls a FastCGI Process Manager: a background service that starts a master process, which in turn starts and supervises a pool of worker processes. Each worker is a fully loaded PHP interpreter, sitting idle and ready to execute a script the instant a request arrives. FastCGI itself is just the wire protocol the web server and PHP-FPM use to talk to each other, normally over a Unix domain socket (a special file on disk that two local processes can read and write through) rather than a network port, since both programs are usually running on the same machine.
The division of labor looks like this:
- NGINX terminates the HTTP connection, serves static files (images, CSS, JavaScript) directly and quickly, and recognizes which requests are for a .php file.
- The PHP-FPM master process reads its configuration, opens the listening socket, and starts, stops, and replaces worker processes according to rules you set.
- PHP-FPM worker processes each execute one request at a time: they run your index.php or other script and hand the generated HTML (or JSON, or whatever the script outputs) back to NGINX, which forwards it to the visitor’s browser.
This separation is the whole reason PHP-FPM plus NGINX became the standard modern pairing in place of Apache plus mod_php: because PHP runs in its own separate processes instead of inside the web server’s own worker threads, a slow or crashing PHP script cannot take the web server down with it, and you can restart PHP-FPM to pick up a code or configuration change without touching NGINX at all.
What you will need
- A server or virtual machine running Ubuntu 24.04 LTS (Noble Numbat) with SSH access as a user who can run
sudo. The same steps work on Ubuntu 22.04 LTS; just substitutephp8.1-fpmforphp8.3-fpmthroughout, since 22.04 ships PHP 8.1 by default instead of 8.3. Confirm which release you are on withlsb_release -rsbefore you start. - Comfort running basic commands over SSH and editing a file with a terminal text editor such as
nano. No prior PHP-FPM or NGINX experience is assumed; every directive used below is explained the first time it appears. - Root or sudo privileges, since installing packages and editing files under
/etcrequires them. - Either a domain name pointed at the server, or just the server’s IP address; either works for testing. This tutorial uses the placeholder
example.comthroughout. Replace it with your own domain or IP wherever it appears. - No existing PHP application is required. You will create a small test script to prove the stack works, and the “Next steps” section at the end points you toward running a real application.
Step 1: Confirm your Ubuntu version and install NGINX
Before installing anything, confirm exactly which Ubuntu release and architecture you are working with, since package names and file paths in this tutorial assume Ubuntu 24.04:
# Context: any fresh Ubuntu 24.04 LTS server, connected over SSH.
# Purpose: confirm the exact OS release before installing version-specific packages.
lsb_release -a
Expected output includes a line reading Release: 24.04 and Codename: noble. If you see a different release number, adjust the PHP package name in Step 2 to match (for example, php8.1-fpm on 22.04).
If NGINX is not already installed, install it now:
# Context: Ubuntu 24.04 LTS, sudo access confirmed.
# Purpose: install NGINX from Ubuntu's official repositories and confirm it is running.
sudo apt update
sudo apt install -y nginx
systemctl status nginx --no-pager
Expected output: the status command should show Active: active (running) in green. Ubuntu’s NGINX package starts and enables the service automatically on install, so you should not need to start it manually. If you already had NGINX installed, running nginx -v instead will print the installed version, which is enough to confirm it is present.
Step 2: Install PHP-FPM
Ubuntu 24.04 LTS ships PHP 8.3 in its default repositories, packaged as php8.3-fpm. Install it along with the command-line interpreter, which is useful for testing PHP scripts directly without going through NGINX at all:
# Context: Ubuntu 24.04 LTS, immediately after confirming NGINX is installed.
# Purpose: install PHP-FPM 8.3 and the PHP command-line interpreter.
sudo apt install -y php8.3-fpm php8.3-cli
Confirm the service is running and check which PHP version you actually have:
# Context: same server, right after installation finishes.
# Purpose: verify PHP-FPM started successfully and confirm the exact version installed.
systemctl status php8.3-fpm --no-pager
php -v
Expected output: the status command shows Active: active (running), and php -v reports a line starting with PHP 8.3. followed by a specific patch version. Also confirm the socket file PHP-FPM listens on was created, since you will point NGINX at it in Step 4:
# Context: same server, PHP-FPM confirmed active.
# Purpose: confirm the Unix socket PHP-FPM is listening on actually exists.
ls -l /run/php/php8.3-fpm.sock
Expected output is a line starting with srw-rw---- (the leading s means “this is a socket, not a regular file”). If the file does not exist, PHP-FPM failed to start; check its status with systemctl status php8.3-fpm and its logs with sudo journalctl -u php8.3-fpm --no-pager -n 50 before continuing.
Step 3: Configure the PHP-FPM pool
Understanding what a pool is
PHP-FPM organizes its worker processes into one or more pools, each defined by its own configuration block and each capable of running as a different system user, with different resource limits, for a different site. Ubuntu’s package ships one default pool named www, defined in /etc/php/8.3/fpm/pool.d/www.conf. For a single-site server, the default pool is fine to reuse; if you plan to host multiple independent sites later, you would create one pool file per site instead so a problem in one cannot exhaust resources meant for another.
Open the default pool file to see what you are working with:
# Context: Ubuntu 24.04 LTS, PHP-FPM 8.3 installed.
# Purpose: open the default pool configuration for editing.
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
The directives that matter most for a first setup, all documented in the official PHP manual’s FPM configuration reference, are:
userandgroup: the system account worker processes run as. Ubuntu’s default iswww-data, the same account NGINX runs as, which keeps file permissions simple for a single-site server.listen: where PHP-FPM accepts connections, either a filesystem path to a Unix socket (/run/php/php8.3-fpm.sock, the default) or a TCP address like127.0.0.1:9000. Use the Unix socket whenever NGINX and PHP-FPM run on the same machine; it has less overhead than TCP and is what this tutorial assumes throughout.listen.owner,listen.group,listen.mode: Unix file permissions on the socket itself. NGINX must be able to read and write it, which is why the default owner and group are bothwww-data.pm: the process manager strategy.dynamic(the default, and the right choice for most sites) starts a baseline number of workers and scales up and down within limits as traffic changes.staticalways runs exactlypm.max_childrenworkers.ondemandstarts workers only when a request arrives and is best for low-traffic sites where idle memory matters more than instant response.pm.max_children: the hard ceiling on simultaneous worker processes whenpmisdynamicorondemand, or the fixed worker count whenpmisstatic. This is the single most important number for avoiding server crashes under load; Step 7 shows you how to calculate a safe value instead of guessing.pm.start_servers,pm.min_spare_servers,pm.max_spare_servers: only apply whenpm = dynamic. They control how many workers exist when PHP-FPM starts, and the minimum and maximum number of idle (“spare”) workers it keeps around waiting for the next request.
For a small server just getting started, the following values are a reasonable default; you will revisit pm.max_children specifically in Step 7 once you can measure your server’s actual memory usage:
# File: /etc/php/8.3/fpm/pool.d/www.conf
# Context: default single-site pool on a small Ubuntu 24.04 VPS.
[www]
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 = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 500
Explanation: pm.max_requests = 500 is not a sizing directive like the others; it tells a worker to gracefully restart itself after handling 500 requests, which is a cheap, standard safeguard against slow memory leaks in long-running PHP processes or their extensions.
After saving the file, restart PHP-FPM so the new configuration takes effect, then confirm it came back up clean:
# Context: pool.d/www.conf just edited and saved.
# Purpose: apply the new pool configuration and confirm PHP-FPM restarted without errors.
sudo systemctl restart php8.3-fpm
systemctl status php8.3-fpm --no-pager
Expected output: Active: active (running) again. If it instead shows failed, you almost certainly have a syntax error in the pool file; PHP-FPM refuses to start on a broken config rather than running with bad settings, and sudo journalctl -u php8.3-fpm --no-pager -n 50 will show you the exact line it choked on.
Step 4: Connect NGINX to PHP-FPM
Create a server block
NGINX organizes site configuration into “server blocks,” and Ubuntu’s package convention keeps the available ones in /etc/nginx/sites-available/ and turns individual sites on by symlinking them into /etc/nginx/sites-enabled/. First create the directory your site’s files will live in:
# Context: Ubuntu 24.04 LTS, NGINX and PHP-FPM both installed.
# Purpose: create the web root directory and hand ownership to the user PHP-FPM and NGINX both run as.
sudo mkdir -p /var/www/example.com
sudo chown -R www-data:www-data /var/www/example.com
Now create the server block itself:
# Context: same server.
# Purpose: create a new NGINX server block configuration file for this site.
sudo nano /etc/nginx/sites-available/example.com
Add the following, replacing example.com with your own domain or the server’s IP address in both the server_name line and the file name itself. The directives inside the second location block, fastcgi_pass, fastcgi_index, and fastcgi_param, are documented in full in NGINX’s official ngx_http_fastcgi_module reference:
# File: /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com;
root /var/www/example.com;
index index.php index.html;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}
Explanation, line by line: root and index tell NGINX where site files live and which file to serve when a request does not name one directly. The first location / block handles ordinary requests: try_files looks for the requested path as a real file, then as a directory, and returns a plain 404 if neither exists, rather than silently falling through to PHP for URLs that were never meant to be scripts. The second block is the one that matters most for this tutorial: location ~ \.php$ matches any request ending in .php, and fastcgi_pass is the line that actually sends the request to PHP-FPM over the Unix socket you confirmed exists in Step 2. include fastcgi_params pulls in a standard parameter file that ships with the NGINX package, setting things like REQUEST_METHOD and CONTENT_TYPE that PHP needs but rarely needs customized. That standard file deliberately leaves out one parameter, because it depends on your own site layout: fastcgi_param SCRIPT_FILENAME tells PHP-FPM the full filesystem path of the script to execute, built from $document_root (the root directive above) plus the requested path. Get SCRIPT_FILENAME wrong and PHP-FPM will report it cannot find the file even though NGINX found it just fine, which is why it is set explicitly on its own line rather than left to the include. The final block blocks any request for a dotfile such as .htaccess or .htpasswd, files that have no meaning to NGINX but should never be served to a visitor regardless.
Enable the site and test the configuration
# Context: server block file saved.
# Purpose: enable the new site and remove Ubuntu's placeholder default site.
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
Before reloading NGINX, always test the configuration syntax first. This single habit prevents the most common way people accidentally take a working site offline:
# Context: new site enabled, default site removed.
# Purpose: check NGINX configuration syntax before applying it.
sudo nginx -t
Expected output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Only once you see both “syntax is ok” and “test is successful” should you reload:
# Context: nginx -t reported success.
# Purpose: apply the new configuration without dropping existing connections.
sudo systemctl reload nginx
reload tells NGINX to load the new configuration gracefully, finishing in-flight requests on the old configuration while new requests use the new one, which is why it is preferred over restart for a live server.
Step 5: Test the stack end to end
Create a small test script that proves PHP itself is executing, not just being served as plain text:
# Context: web root created and owned by www-data in Step 4.
# Purpose: create a minimal PHP script to prove PHP-FPM is executing code through NGINX.
echo '<?php phpinfo(); ?>' | sudo tee /var/www/example.com/info.php
sudo chown www-data:www-data /var/www/example.com/info.php
Now request it, either from a browser at http://example.com/info.php (substituting your real domain or IP), or from the command line:
# Context: info.php created, NGINX reloaded with the new server block.
# Purpose: confirm PHP-FPM is actually executing the script rather than NGINX serving raw source.
curl -I http://example.com/info.php
Expected output includes HTTP/1.1 200 OK and a Content-Type: text/html; charset=UTF-8 header. If you open the URL in a browser, you should see PHP’s large, detailed configuration summary page, not your browser offering to download a file. A download prompt, or the raw <?php phpinfo(); ?> text appearing on screen instead of a rendered page, both mean NGINX is serving the file as static text rather than handing it to PHP-FPM; recheck the location ~ \.php$ block from Step 4.
Important: phpinfo() reveals detailed information about your server’s configuration, installed modules, and file paths, which is useful for you right now but also useful to an attacker. Delete this file as soon as the test succeeds:
# Context: phpinfo() page confirmed working in the browser or via curl.
# Purpose: remove the test file now that it has served its purpose.
sudo rm /var/www/example.com/info.php
Step 6: Fix the errors you will actually run into
502 Bad Gateway
This means NGINX successfully matched the location ~ \.php$ block and tried to reach PHP-FPM, but could not. The three causes covered in this tutorial’s setup, in order of likelihood:
- PHP-FPM is not running. Check with
systemctl status php8.3-fpm --no-pager. If it is not active, start it withsudo systemctl start php8.3-fpmand check the logs for why it stopped. - The socket path does not match. The path after
fastcgi_pass unix:in your NGINX server block must be character-for-character identical to thelistenvalue in your PHP-FPM pool file. Compare them directly:grep listen /etc/php/8.3/fpm/pool.d/www.confagainstgrep fastcgi_pass /etc/nginx/sites-available/example.com. - A permissions problem on the socket. Run
ls -l /run/php/php8.3-fpm.sockagain; the owner and group must be readable and writable by the user NGINX runs as (www-databy default), which is exactly what thelisten.owner,listen.group, andlisten.modedirectives from Step 3 control.
NGINX’s own error log almost always names the specific cause; check it whenever you are not sure:
# Context: any time NGINX returns a 502.
# Purpose: read NGINX's own explanation for why it could not reach PHP-FPM.
sudo tail -n 20 /var/log/nginx/error.log
403 Forbidden, or a blank white page
A 403 on a request that should exist is almost always a filesystem permissions problem: either the web root or the requested file is not readable by www-data. Check with ls -la /var/www/example.com/ and fix ownership with the same chown pattern used in Step 4.
A completely blank white page, with a 200 OK status and no visible error, usually means PHP hit an error but has error display turned off, which is the correct default for anything but a local development box. Do not enable display_errors on a public-facing server; instead, read what actually happened from PHP-FPM’s own log. Do not assume its path; ask php-fpm.conf directly, since the exact filename can vary by packaging:
# Context: a blank page where you expected real output.
# Purpose: find the exact path PHP-FPM is configured to log errors to, then read it.
grep -i "error_log" /etc/php/8.3/fpm/php-fpm.conf
Expected output is either an active error_log = ... line giving you the exact path, or the directive left commented out, in which case PHP-FPM falls back to its own compiled-in default (Ubuntu’s package builds this as a version-specific path following the same php8.3-fpm naming used throughout this tutorial). Either way, once you have the real path, tail it, for example sudo tail -n 20 /var/log/php8.3-fpm.log; do not assume the filename from any tutorial, including this one, without confirming it against your own server first.
If that log is quiet and you need more detail from a specific pool, set catch_workers_output = yes in the pool file and restart PHP-FPM; this directive (documented in the official PHP manual) redirects each worker’s own stdout and stderr into the main error log instead of discarding them, which is normally what you want while actively debugging and not otherwise.
Step 7: Size pm.max_children instead of guessing
The pm.max_children = 10 value from Step 3 was a reasonable starting point, not a real answer, because the right number depends entirely on how much memory each of your PHP worker processes actually uses, which varies enormously by application. Measure it directly instead of guessing:
# Context: server has handled at least a little real traffic so workers have actually run code.
# Purpose: list every PHP-FPM worker process and its current memory usage.
ps -C php-fpm8.3 -o pid,rss,cmd
Expected output is one line per PHP-FPM process: one master process, plus one line per worker. The RSS column is resident memory in kilobytes. Ignore the master process line and average the worker lines; on a typical WordPress-style site this often lands somewhere between 30 and 80 MB per worker, but frameworks, plugins, and workload all shift that number, which is exactly why you should measure your own server rather than trust any number in a tutorial, including this one.
Once you have an average worker size, the ceiling is straightforward arithmetic: take the RAM you are willing to dedicate to PHP-FPM (not your total server RAM; leave headroom for NGINX, the OS, and anything else running), and divide by the average worker size. A server with 2 GB of RAM set aside for PHP and an observed average of 50 MB per worker supports roughly 2000 / 50 = 40 workers at the outside; most people then set pm.max_children somewhat below that ceiling to leave a safety margin. Update the value in /etc/php/8.3/fpm/pool.d/www.conf and restart PHP-FPM the same way you did in Step 3.
Step 8: Turn on OPcache
Without OPcache, PHP has to re-parse and recompile every PHP file on every single request. OPcache stores the compiled result in shared memory after the first request and reuses it afterward, which is one of the largest, cheapest performance wins available for any PHP site. The php8.3-fpm package installs the OPcache extension automatically as a dependency; you only need to confirm it is actually enabled:
# Context: PHP-FPM 8.3 installed and running.
# Purpose: confirm the OPcache extension is loaded.
php -m | grep -i opcache
If that prints Zend OPcache, the extension is loaded. Ubuntu’s packaging drops OPcache’s own ini file into /etc/php/8.3/fpm/conf.d/ under a name assigned automatically when the package installed, so ask PHP itself which file to edit rather than guessing the exact filename:
# Context: OPcache extension confirmed loaded, FPM SAPI specifically.
# Purpose: find the exact ini file OPcache's settings live in for the FPM SAPI.
php-fpm8.3 -i | grep opcache.ini
Expected output is a single path, typically something like /etc/php/8.3/fpm/conf.d/10-opcache.ini. Open that exact path and confirm these two lines are present and uncommented:
# File: whatever path the previous command printed.
opcache.enable=1
opcache.memory_consumption=128
Explanation: opcache.memory_consumption is the size, in megabytes, of the shared memory pool used to cache compiled scripts; 128 MB is comfortable for a small to medium site. Restart PHP-FPM once more to apply it, then confirm with php -i | grep opcache.enable, which should now report On.
Common mistakes and gotchas
- Editing the pool file but forgetting to restart PHP-FPM. Unlike NGINX, which supports a graceful
reload, PHP-FPM pool changes require at minimum a restart to take effect. A config test option exists too, documented in the official Ubuntu manual page for php-fpm8.3: runsudo php-fpm8.3 -tbefore restarting if you want to catch a syntax error without any downtime at all. - Copying a socket path from a tutorial written for a different PHP version. The version number is embedded directly in the socket path, the service name, and the config directory (
php8.3-fpm.sock, not a genericphp-fpm.sock). If you installed PHP 8.1 instead of 8.3, every path in this tutorial needs the same substitution. - Running
nginx -tand getting a pass, then still seeing a 502.nginx -tonly validates NGINX’s own syntax; it has no way to know whether the PHP-FPM socket it references actually exists or is listening. Always check PHP-FPM’s own status separately, as covered in Step 6. - Leaving
display_errorson in production “just to debug faster.” It leaks file paths, database details, and other internals to anyone who can trigger the error, which is exactly the information an attacker wants first. Use the log files from Step 6 instead, on every server that is reachable from the public internet. - Forgetting that
pm.max_childrenis a ceiling, not a target. Setting it far higher than your server’s actual RAM allows does not make the site faster; it lets PHP-FPM spawn enough workers to exhaust memory and trigger the Linux out-of-memory killer under a traffic spike, taking down far more than just PHP. Size it from real measurements, as in Step 7.
How to verify everything works end to end
- Run
systemctl status nginx php8.3-fpm --no-pagerand confirm both reportActive: active (running). - Run
sudo nginx -tand confirm both “syntax is ok” and “test is successful.” - Request a fresh
info.php(recreate it briefly if you already deleted it) and confirm you see a rendered PHP info page, not a download prompt and not raw PHP source text, then delete it again immediately afterward. - Confirm the socket permissions are still correct with
ls -l /run/php/php8.3-fpm.sock; owner and group should both bewww-data(or whatever user you set in Step 3). - Check both log files one last time,
sudo tail -n 20 /var/log/nginx/error.logand whichever PHP-FPM log path you confirmed in Step 6, and confirm neither shows fresh errors from your testing. - Confirm the site survives a full reboot:
sudo reboot, then once it comes back, run step 1 again. Both services should already show active, since Ubuntu’s packages enable them on boot by default; if either does not, enable it explicitly withsudo systemctl enable php8.3-fpmorsudo systemctl enable nginx.
Next steps
With PHP-FPM and NGINX talking to each other correctly, the next gap to close is encryption: a plain listen 80 server block like the one in this tutorial serves everything over unencrypted HTTP. Certbot’s official NGINX plugin can obtain and install a free TLS certificate and update this exact server block for you automatically; consult the official Certbot instructions for NGINX on Ubuntu before pointing any real domain at this server. Beyond TLS, this is also the point where you would deploy a real application instead of a test script: a fresh WordPress install, a Laravel project, or anything else that speaks PHP will drop into the /var/www/example.com root this tutorial created, using the exact pool and server block you just configured, usually with a few application-specific additions to the location block that its own documentation will spell out.








No Comment! Be the first one.