# LEMP Field Notes — full text > LEMP Field Notes is an independent editorial publication for people who run their own web servers. We publish step-by-step LEMP stack tutorials, Nginx configuration guides, and server security walkthroughs where every command is stated exactly, every directive is explained, and version-specific behavior is called out rather than glossed over. # How to Install a LEMP Stack on Ubuntu 24.04 (Step by Step) > To install a LEMP stack on Ubuntu 24.04, run sudo apt update, then install each component from the default repositories: sudo apt install nginx, sudo apt install mysql-server, and sudo apt install php8.3-fpm php8.3-mysql. Secure MySQL with mysql_secure_installation, then configure an Nginx server block that passes .php requests to PHP-FPM via the Unix socket /run/php/php8.3-fpm.sock. Test with sudo nginx -t, reload Nginx, and verify with a phpinfo() page — then delete that test file. Source: https://lemp.io/install-lemp-stack-ubuntu-24-04/ Publisher: LEMP Field Notes Published: 2026-07-19 Updated: 2026-08-15 ## How to Install a LEMP Stack on Ubuntu 24.04 To install a LEMP stack on Ubuntu 24.04, you install Nginx, MySQL, and PHP-FPM from the default Ubuntu repositories with `apt`, then configure Nginx to pass PHP requests to PHP-FPM through a Unix socket. The whole process takes four commands to get the software on disk and one short config file to wire it together. Ubuntu 24.04 LTS (Noble Numbat) ships current, well-tested versions of all three components, so you don't need any third-party PPAs for a standard setup. This guide walks through every step on a fresh Ubuntu 24.04 server: installing each component, securing MySQL, connecting Nginx to PHP-FPM, and verifying the stack end to end with a test PHP page. Every command is shown exactly as you should type it, and we explain what each one changes on your system. **What you need before starting:** - A server (VPS or local VM) running Ubuntu 24.04 LTS - A non-root user with `sudo` privileges - SSH access to the server If you're still deciding between web servers, our comparison of [Nginx vs Apache](/nginx-vs-apache/) covers the trade-offs — the short version is that Nginx's event-driven model and lighter memory footprint make it the usual choice for a new stack, which is exactly why the "E" in LEMP replaced the "A" in LAMP. ## What Is a LEMP Stack? A LEMP stack is a set of open-source software for serving dynamic websites: **L**inux (the operating system), **E**ngine-X — Nginx (the web server), **M**ySQL (the database), and **P**HP (the application language). Nginx accepts HTTP requests, hands PHP files to the PHP-FPM process manager for execution, and PHP talks to MySQL for data. It's the stack behind a huge share of WordPress, Laravel, and custom PHP deployments. On Ubuntu 24.04, the default repositories provide: | Component | Package | Version in Ubuntu 24.04 repos | |-----------|---------|-------------------------------| | Web server | `nginx` | 1.24.x | | Database | `mysql-server` | MySQL 8.0.x | | Language | `php8.3-fpm` | PHP 8.3 | These versions are maintained with security patches by Ubuntu for the life of the LTS release, which is why we recommend the default repos over third-party sources unless you specifically need a newer PHP. ## Step 1 — Update the System and Install Nginx Start by refreshing the package index and applying any pending updates: ```bash sudo apt update sudo apt upgrade -y ``` Then install Nginx: ```bash sudo apt install nginx -y ``` Ubuntu starts and enables the Nginx service automatically on install. Confirm it's running: ```bash systemctl status nginx ``` You should see `active (running)`. Press `q` to exit the status view. ### Open the Firewall Ubuntu 24.04 ships with `ufw` available (and on many cloud images, enabled). Allow HTTP traffic — and SSH, so you don't lock yourself out: ```bash sudo ufw allow OpenSSH sudo ufw allow 'Nginx HTTP' sudo ufw enable ``` If `ufw` was already enabled, skip the last command. The `'Nginx HTTP'` profile opens port 80 only; you'll open 443 later when you [add HTTPS with Let's Encrypt](/nginx-lets-encrypt-certbot/). Now visit your server's IP address in a browser (`http://your_server_ip`). You should see the default "Welcome to nginx!" page. If you don't know the IP, `ip addr` on the server or your provider's dashboard will show it. ## Step 2 — Install MySQL Install the MySQL server package: ```bash sudo apt install mysql-server -y ``` The service starts automatically. Verify: ```bash systemctl status mysql ``` ### Secure the MySQL Installation MySQL ships with a helper script that tightens the default configuration: ```bash sudo mysql_secure_installation ``` The script asks a series of yes/no questions: - **VALIDATE PASSWORD component** — optional; it enforces password strength rules for MySQL accounts. Fine to enable on a production box. - **Remove anonymous users** — yes. - **Disallow root login remotely** — yes. - **Remove test database** — yes. - **Reload privilege tables** — yes. One Ubuntu-specific detail worth knowing: on Ubuntu, the MySQL `root` account authenticates via the `auth_socket` plugin by default, meaning you log in with `sudo mysql` rather than a password. This is more secure for local administration than a password, and we recommend leaving it that way. If the secure-installation script prompts you to set a root password, you can do so, but the socket-based login continues to work for the root system user. ### Create an Application Database and User Don't let your applications connect as root. Log in and create a dedicated database and user (replace the names and choose a strong password): ```bash sudo mysql ``` ```sql CREATE DATABASE example_db; CREATE USER 'example_user'@'localhost' IDENTIFIED BY 'choose_a_strong_password'; GRANT ALL PRIVILEGES ON example_db.* TO 'example_user'@'localhost'; FLUSH PRIVILEGES; EXIT; ``` Test the new account: ```bash mysql -u example_user -p ``` Enter the password when prompted, then `EXIT;` to leave. ## Step 3 — Install PHP-FPM With Apache, PHP typically runs inside the web server process. Nginx works differently: it doesn't execute PHP itself, so you install **PHP-FPM** (FastCGI Process Manager), a separate service that runs PHP and communicates with Nginx over a socket. Install PHP-FPM plus the MySQL extension so PHP can talk to your database: ```bash sudo apt install php8.3-fpm php8.3-mysql -y ``` On Ubuntu 24.04, `php-fpm` resolves to PHP 8.3; we name the version explicitly so the commands below (which reference the versioned socket path) match exactly. Check the service: ```bash systemctl status php8.3-fpm ``` Most real applications need a few more extensions. Common ones for WordPress, Laravel, and similar apps: ```bash sudo apt install php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip -y ``` You can always add extensions later; after installing one, restart PHP-FPM with `sudo systemctl restart php8.3-fpm`. ## Step 4 — Configure Nginx to Use PHP-FPM This is the step that turns three separate services into a stack. You'll create an Nginx **server block** — the Nginx equivalent of an Apache virtual host — that serves files from a directory and passes anything ending in `.php` to PHP-FPM. First, create a web root for your site and give your user ownership: ```bash sudo mkdir -p /var/www/example sudo chown -R $USER:$USER /var/www/example ``` Now create the server block file: ```bash sudo nano /etc/nginx/sites-available/example ``` Paste this configuration, replacing `example.com` with your domain (or your server's IP if you don't have a domain yet): ```nginx server { listen 80; server_name example.com www.example.com; root /var/www/example; index index.php index.html; location / { try_files $uri $uri/ =404; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } location ~ /\.ht { deny all; } } ``` What each part does: - **`root`** — the directory Nginx serves files from. - **`index index.php index.html;`** — try `index.php` first when a directory is requested, which is what PHP applications expect. - **`try_files $uri $uri/ =404;`** — serve the file if it exists, otherwise return 404 instead of guessing. - **`location ~ \.php$`** — matches requests for `.php` files. The included `snippets/fastcgi-php.conf` (shipped with Ubuntu's Nginx package) sets the standard FastCGI parameters, and `fastcgi_pass` sends the request to PHP-FPM's Unix socket at `/run/php/php8.3-fpm.sock`. - **`location ~ /\.ht`** — blocks access to `.htaccess`-style files. Nginx never uses them, but if you migrate files from Apache they shouldn't be downloadable. Enable the site by symlinking it into `sites-enabled`, and remove the default site so it doesn't conflict: ```bash sudo ln -s /etc/nginx/sites-available/example /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default ``` **Always test before reloading.** This habit will save you at some point: ```bash sudo nginx -t ``` If you see `syntax is ok` and `test is successful`, apply the change: ```bash sudo systemctl reload nginx ``` If the test fails, it prints the file and line number of the problem — fix it and test again. Nginx keeps running with the old config until a reload succeeds, so a failed test never takes your site down. This single-site setup is all you need today, but the same mechanism scales to many sites on one server — see our full guide to [Nginx server blocks](/nginx-server-blocks/) for hosting multiple domains, choosing `server_name` values, and how `sites-available` and `sites-enabled` relate. ## Step 5 — Test the Stack End to End Create a PHP file in your web root: ```bash nano /var/www/example/info.php ``` Add: ```php connect_error) { die("Connection failed: " . $mysqli->connect_error); } echo "Connected to MySQL successfully."; ``` Reload the page. "Connected to MySQL successfully." means all three layers are talking. **Now delete the test file.** A `phpinfo()` page exposes detailed server configuration to anyone who finds it, and a file containing database credentials should never sit in a web root: ```bash rm /var/www/example/info.php ``` ## Troubleshooting Common Issues - **502 Bad Gateway** — Nginx can't reach PHP-FPM. Check that the service is running (`systemctl status php8.3-fpm`) and that the socket path in `fastcgi_pass` matches your PHP version exactly. A config written for `php8.1-fpm.sock` on a system running PHP 8.3 produces exactly this error. - **Browser downloads the .php file instead of running it** — the PHP `location` block isn't matching, usually because the server block being used isn't the one you edited. Confirm your symlink exists in `sites-enabled` and the default site is removed, then `sudo nginx -t && sudo systemctl reload nginx`. - **403 Forbidden** — Nginx can't read the files, or no index file exists in the directory. Check file permissions and that `index.php` or `index.html` is present. - **Site unreachable from outside** — firewall. Run `sudo ufw status` and confirm `Nginx HTTP` is allowed; on cloud providers, also check the provider-level firewall or security group. ## Next Steps You have a working LEMP stack, but a production server needs two more things before it hosts anything real: 1. **HTTPS.** Serving over plain HTTP is no longer acceptable for anything with logins or forms. Follow our [Let's Encrypt with Certbot tutorial](/nginx-lets-encrypt-certbot/) to get a free, auto-renewing TLS certificate — it takes about ten minutes on the stack you just built, and Certbot will edit this same server block for you. 2. **Deploy an application.** Point the `root` directive at your application's public directory, install any PHP extensions it requires, and give it the database credentials from Step 2. The stack you've built — Nginx 1.24, MySQL 8.0, PHP 8.3 on Ubuntu 24.04 LTS — is supported with security updates through the standard LTS window, so it's a foundation you can leave in place for years. Keep it patched with a periodic `sudo apt update && sudo apt upgrade`, and test config changes with `nginx -t` before every reload. Those two habits prevent most of the outages that beginner-run servers suffer. ## Frequently asked questions **What versions of Nginx, MySQL, and PHP does Ubuntu 24.04 include?** Ubuntu 24.04 LTS ships Nginx 1.24.x, MySQL 8.0.x, and PHP 8.3 in its default repositories. These versions receive security patches from Ubuntu for the life of the LTS release, so for a standard LEMP setup you don't need third-party PPAs. Only add an external repository if your application specifically requires a PHP version newer than 8.3. **What is the difference between LEMP and LAMP?** The only difference is the web server: LAMP uses Apache, while LEMP uses Nginx (pronounced "Engine-X," hence the E). Both stacks run on Linux with MySQL and PHP. Because Nginx doesn't execute PHP internally the way Apache commonly does, a LEMP stack runs PHP through PHP-FPM, a separate process manager that Nginx communicates with over a Unix socket. **Why doesn't MySQL ask for a root password on Ubuntu?** On Ubuntu, MySQL's root account uses the auth_socket authentication plugin by default. That means you log in with sudo mysql as the system root/sudo user rather than typing a database password. This is considered more secure for local administration and is safe to leave in place. For applications, create a dedicated MySQL user with a password and grant it privileges on only its own database. **How do I fix a 502 Bad Gateway error on a LEMP stack?** A 502 means Nginx couldn't reach PHP-FPM. First check the service is running with systemctl status php8.3-fpm. Then confirm the fastcgi_pass line in your server block points to the correct socket — on Ubuntu 24.04 that's unix:/run/php/php8.3-fpm.sock. A socket path written for a different PHP version is the most common cause. After fixing it, run sudo nginx -t and reload Nginx. **Do I need to install phpMyAdmin with a LEMP stack?** No — it's optional. You can fully administer MySQL from the command line with sudo mysql, which is what this guide uses. phpMyAdmin adds a convenient web interface but also expands your attack surface, since it's a frequently scanned target. If you install it, restrict access (for example by IP allowlist or HTTP authentication) and always serve it over HTTPS. **How do I add HTTPS after installing the LEMP stack?** Use Certbot with Let's Encrypt. Once your domain's DNS points at the server and your Nginx server block's server_name matches the domain, Certbot can obtain a free certificate, update the server block for you, and set up automatic renewal. You'll also need to open port 443 in your firewall with sudo ufw allow 'Nginx Full'. The process takes about ten minutes on a working stack. --- # How to Install WordPress on a LEMP Stack (Ubuntu 24.04) > To install WordPress on a LEMP stack, install the PHP extensions WordPress needs (mysql, curl, gd, mbstring, xml, zip), create a dedicated MySQL database and user, extract the WordPress files from wordpress.org into /var/www with www-data ownership and 755/644 permissions, then configure an Nginx server block whose location block uses try_files $uri $uri/ /index.php?$args; to route permalinks and passes .php requests to PHP-FPM. Finish the install in the browser, then add HTTPS with Certbot. Source: https://lemp.io/install-wordpress-lemp-ubuntu/ Publisher: LEMP Field Notes Published: 2026-08-27 Updated: 2026-08-27 ## Installing WordPress on a LEMP Stack: What You're Actually Doing Installing WordPress on a LEMP stack means placing the WordPress PHP files in a directory Nginx can serve, creating a MySQL database and user for WordPress to store content in, and writing an Nginx server block that hands `.php` requests to PHP-FPM. There is no installer package: WordPress is a folder of PHP files plus a database, and the "install" is wiring those two things to your existing web server. This guide walks through the entire process on Ubuntu 24.04: downloading WordPress, creating the database from the MySQL command line, configuring Nginx with working permalinks, setting file permissions that don't fight you later, and finishing the browser-based setup. Every command is shown exactly as you'd type it. **Prerequisites.** You need a working LEMP stack before starting — Nginx, MySQL, and PHP-FPM installed and running. If you haven't done that yet, follow our guide to [install a LEMP stack on Ubuntu 24.04](/install-lemp-stack-ubuntu-24-04/) first, then come back. You'll also want a domain (or subdomain) pointed at your server's IP, and a non-root user with `sudo` privileges. Throughout this guide, replace `example.com` with your actual domain. ## Step 1: Install the PHP Extensions WordPress Needs Ubuntu 24.04 ships PHP 8.3 in its default repositories, which WordPress supports well. A base LEMP install usually includes `php8.3-fpm` and `php8.3-mysql`, but WordPress relies on several more extensions for image handling, XML parsing, and internationalization: ```bash sudo apt update sudo apt install php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd \ php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl php8.3-imagick ``` What each one does for WordPress: - **php8.3-mysql** — the database driver; without it WordPress can't connect to MySQL at all - **php8.3-curl** — outbound HTTP requests (plugin updates, REST API calls) - **php8.3-gd** and **php8.3-imagick** — image resizing and thumbnail generation - **php8.3-mbstring** — multibyte string handling for non-Latin content - **php8.3-xml** — RSS feeds, sitemaps, and importers - **php8.3-zip** — installing plugins and themes from uploaded `.zip` files - **php8.3-intl** — locale-aware formatting Restart PHP-FPM so the new extensions load: ```bash sudo systemctl restart php8.3-fpm ``` ## Step 2: Create the WordPress Database and MySQL User WordPress needs its own database and — critically — its own MySQL user with privileges limited to that one database. Never point WordPress at the root account; if a plugin vulnerability exposes the database credentials, you want the blast radius limited to a single database. Open the MySQL shell as root: ```bash sudo mysql ``` Then run these statements, choosing a strong password of your own in place of the placeholder: ```sql CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'use-a-strong-password-here'; GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost'; EXIT; ``` A few details worth understanding: - **`utf8mb4`** is MySQL's full Unicode character set — it handles emoji and the complete range of international characters. WordPress has defaulted to it for years, and creating the database with it up front avoids collation mismatches later. - **`'wpuser'@'localhost'`** restricts this account to connections from the server itself. Since PHP-FPM and MySQL run on the same machine here, nothing else needs access. - **`GRANT ALL PRIVILEGES ON wordpress.*`** scopes the grant to the `wordpress` database only. The user can't touch `mysql.*` system tables or any other database on the server. - **No `FLUSH PRIVILEGES` here.** Nearly every tutorial tacks it on at this point, but MySQL reloads the grant tables automatically after account-management statements like `CREATE USER` and `GRANT`. It is only needed if you modify the `mysql.user` tables directly with `INSERT` or `UPDATE`, which you have no reason to do. Verify the credentials work before moving on: ```bash mysql -u wpuser -p wordpress ``` Enter the password you chose. If you land at a `mysql>` prompt, the database side is done — type `EXIT;` to leave. ## Step 3: Download and Extract WordPress Always download WordPress from wordpress.org, never from a mirror or a bundled "quick installer." Grab the latest release into a temporary location: ```bash cd /tmp curl -O https://wordpress.org/latest.tar.gz tar -xzf latest.tar.gz ``` This extracts a `wordpress/` directory. Move its contents into your web root. We'll use a per-site directory, which keeps things clean if you ever host a second site on the same server: ```bash sudo mkdir -p /var/www/example.com sudo cp -a /tmp/wordpress/. /var/www/example.com/ ``` Two parts of that command do different jobs and are frequently confused. The trailing **`/.`** copies the directory's *contents* into the destination rather than nesting `wordpress/` inside it, and it hands the listing to `cp`, which doesn't care whether a name starts with a dot. The commonly seen `/tmp/wordpress/*` form asks the *shell* to expand a glob instead, and the shell skips dot-prefixed names by default — so `cp -a /tmp/wordpress/* ...` would miss dotfiles exactly the way `cp -r` would. (The wordpress.org tarball ships no dotfiles at its top level today, so both forms happen to move the same files here; `/.` is the form that stays correct when you later copy a live site, which usually does have them.) The **`-a`** flag is unrelated to hidden files. It is shorthand for `-dR --preserve=all`: recurse, and carry ownership, timestamps, and modes across instead of resetting them to the copying user's defaults. Step 4 sets ownership and permissions explicitly in a moment, so here `-a` is mostly buying you the recursion. Then clear out the temporary copies, so you aren't leaving a second copy of your web root in `/tmp`: ```bash rm -rf /tmp/wordpress /tmp/latest.tar.gz ``` ## Step 4: Set File Ownership and Permissions File permissions are where most WordPress-on-Nginx installs go wrong — either too loose (world-writable, a security hole) or too tight (WordPress can't upload media or update itself). Here's the arrangement that works: PHP-FPM on Ubuntu runs as the `www-data` user by default, so WordPress files should be owned by `www-data`: ```bash sudo chown -R www-data:www-data /var/www/example.com ``` Then set directories to `755` (owner can write, everyone can traverse) and files to `644` (owner can write, everyone can read): ```bash sudo find /var/www/example.com/ -type d -exec chmod 755 {} \; sudo find /var/www/example.com/ -type f -exec chmod 644 {} \; ``` With this setup, WordPress can write to its own directories — which is what lets media uploads, plugin installs, and one-click core updates work without FTP credentials — while no file is writable by other users on the system. **Never use `chmod 777`** on anything in your web root; it makes every file writable by any process on the server and is the single most common self-inflicted WordPress security hole. Note that `644` is world-*readable*. That's fine for theme and core files, but in Step 6 you'll create `wp-config.php`, which holds your database password in plain text — that one file gets tightened separately. One trade-off to know about: because `www-data` owns the files, a compromised plugin runs with write access to the whole WordPress tree. Some hardened setups keep files owned by a separate user and grant `www-data` write access only to `wp-content/uploads`, at the cost of doing all updates manually or via WP-CLI. For a typical single-site server, the `www-data`-owned arrangement above is the standard, maintainable choice. ## Step 5: Configure Nginx for WordPress Now the part that makes or breaks the install: the Nginx server block. Create a dedicated config file: ```bash sudo nano /etc/nginx/sites-available/example.com ``` Paste in this configuration, adjusting `server_name` and `root`: ```nginx server { listen 80; listen [::]:80; server_name example.com www.example.com; root /var/www/example.com; index index.php index.html index.htm; client_max_body_size 64m; location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } location ~ /\.ht { deny all; } location = /favicon.ico { log_not_found off; access_log off; } location = /robots.txt { log_not_found off; access_log off; allow all; try_files $uri /index.php?$args; } location ~* \.(css|js|gif|ico|jpeg|jpg|png|svg|webp|woff2?)$ { expires 30d; access_log off; } } ``` ### The line that makes permalinks work The single most important directive for WordPress on Nginx is this one: ```nginx try_files $uri $uri/ /index.php?$args; ``` Apache handles WordPress's "pretty permalinks" (`/2026/08/my-post/`) through the `.htaccess` rewrite rules WordPress writes automatically. Nginx doesn't read `.htaccess` files at all — this `try_files` line is the Nginx equivalent. It tells Nginx: try to serve the request as a real file, then as a directory, and if neither exists, pass the request to `index.php` with the original query string intact so WordPress's router can resolve it. Without it, your homepage works but every individual post returns a 404. If you've followed a generic [Nginx server blocks](/nginx-server-blocks/) setup before, this is the WordPress-specific addition you need. ### The other directives, briefly `location ~ \.php$` hands PHP requests to PHP-FPM over its Unix socket; the `fastcgi-php.conf` snippet shipped with Ubuntu's Nginx package sets the required fastcgi parameters. `location ~ /\.ht` blocks access to any stray `.htaccess` files, which are meaningless to Nginx but shouldn't be readable. The static-asset block sets long cache headers on files that rarely change. The `index` line puts `index.php` first so WordPress answers directory requests, while still allowing a plain `index.html` — a maintenance page, or a stub in an asset directory — to be served if one exists. Dropping the HTML entries, as many WordPress recipes do, quietly breaks that. The `favicon.ico` and `robots.txt` blocks keep routine requests for those two paths out of your logs. The `try_files` line inside the `robots.txt` block is the part worth copying: WordPress and most SEO plugins serve a *virtual* `robots.txt` with no file on disk, and an exact-match `location` with no fallback would 404 silently instead of letting `index.php` generate it. The favicon block needs no fallback, since WordPress doesn't generate one at that path. ### Upload size limits `client_max_body_size 64m;` raises Nginx's cap on request bodies. The default is **1 MB** — leave it alone and any photo larger than that fails with `413 Request Entity Too Large` before PHP ever sees the upload. PHP enforces its own separate limits, and Ubuntu's defaults are also low: `upload_max_filesize = 2M` and `post_max_size = 8M`. Raise them to match in `/etc/php/8.3/fpm/php.ini`: ```ini upload_max_filesize = 64M post_max_size = 64M ``` Then `sudo systemctl restart php8.3-fpm`. Your effective ceiling is the smallest of the three values, so all three have to move together. ### Enable the site and reload Link the config into `sites-enabled`, test it, and reload: ```bash sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` Never skip `nginx -t`. A syntax error in a reloaded config can take every site on the server down; the test catches it before the reload does. If Ubuntu's default site is still enabled and catching requests, disable it: ```bash sudo rm /etc/nginx/sites-enabled/default sudo systemctl reload nginx ``` ## Step 6: Create wp-config.php WordPress reads its database credentials from `wp-config.php`. The browser installer can generate this file for you, but creating it manually from the shell is more reliable when file permissions are involved, and it lets you set the security keys properly. Start from the sample: ```bash cd /var/www/example.com sudo -u www-data cp wp-config-sample.php wp-config.php sudo -u www-data nano wp-config.php ``` Edit the database section to match Step 2: ```php define( 'DB_NAME', 'wordpress' ); define( 'DB_USER', 'wpuser' ); define( 'DB_PASSWORD', 'use-a-strong-password-here' ); define( 'DB_HOST', 'localhost' ); ``` Then replace the block of placeholder authentication keys and salts. WordPress provides a generator that returns a fresh, random set — fetch it and paste the output over the existing `define( 'AUTH_KEY', ... );` lines: ```bash curl -s https://api.wordpress.org/secret-key/1.1/salt/ ``` These salts sign login cookies. Leaving the placeholder values in makes session cookies forgeable, so don't skip this. Running the copy and edit as `www-data` (via `sudo -u www-data`) keeps the ownership from Step 4 intact — a plain `sudo cp` would create the file owned by root, and WordPress wouldn't be able to update it. ### Lock down the credentials file The file inherited mode `644` from `wp-config-sample.php`, which means every local account on the server can read your database password. Tighten this one file: ```bash sudo -u www-data chmod 640 /var/www/example.com/wp-config.php ``` `640` leaves it readable and writable by `www-data` — which is what PHP-FPM runs as, so WordPress is unaffected — readable by the `www-data` group, and unreadable by everyone else. If you ever re-run the bulk `find ... -type f -exec chmod 644` from Step 4, it will reset this file along with the rest; re-apply the `640` afterward. ## Step 7: Run the Browser Setup Visit `http://example.com` in your browser. Because `wp-config.php` already exists with valid credentials, WordPress skips the database prompts and takes you straight to the five-minute install screen: site title, admin username, admin password, and email. Two choices here matter for security: - **Don't use "admin" as the username.** Automated brute-force bots try it constantly. Any other name removes half of the guessing problem for free. - **Use the generated strong password** or one from your password manager. This account can install plugins, which means it can effectively execute code on your server. Click **Install WordPress**, log in at `/wp-admin/`, and you have a working site. Immediately after logging in, go to **Settings → Permalinks** and choose "Post name" (or your preferred structure), then save. Then open any post at its pretty URL. That one click is your verification of the Step 5 config: if the post loads, the server block is correct; if you get a 404, recheck the `location /` block and reload Nginx. ## Step 8: Add HTTPS Before You Publish The site is currently serving over plain HTTP, which means login credentials cross the network unencrypted. Fix that before you do anything else with the site. Our [Let's Encrypt with Certbot guide](/nginx-lets-encrypt-certbot/) covers obtaining a free certificate and letting Certbot rewrite this server block for HTTPS — it takes about five minutes on the config you just built. After HTTPS is live, update **Settings → General** so both the WordPress Address and Site Address use `https://`, ensuring WordPress generates secure URLs everywhere. ## Quick Troubleshooting Reference | Symptom | Likely cause | Fix | |---|---|---| | Homepage loads, posts return 404 | Missing `try_files ... /index.php?$args;` | Recheck the `location /` block, reload Nginx | | "Error establishing a database connection" | Wrong credentials in `wp-config.php` | Test with `mysql -u wpuser -p wordpress` | | 502 Bad Gateway | PHP-FPM down or wrong socket path | `sudo systemctl status php8.3-fpm`; confirm socket path in the PHP location block | | Browser downloads a `.php` file | PHP location block missing or config not reloaded | Verify the `\.php$` block, run `nginx -t`, reload | | Uploads fail with "413 Request Entity Too Large" | `client_max_body_size` (Nginx default 1 MB) or PHP's `upload_max_filesize`/`post_max_size` too low | Raise all three as in Step 5; reload Nginx and restart `php8.3-fpm` | | Uploads fail with a permissions or "unable to write" error | Ownership or permissions wrong | Re-run the `chown` and `find`/`chmod` commands from Step 4 | | White screen after plugin install | PHP fatal error | Check `/var/log/nginx/error.log` and PHP-FPM logs | ## Where to Go From Here You now have WordPress running the way it should on a LEMP stack: a scoped database user, correct `www-data` ownership with 755/644 permissions and a locked-down `wp-config.php`, and an Nginx server block you can read line by line rather than one pasted blind. Sensible next steps, roughly in order: finish the [HTTPS setup](/nginx-lets-encrypt-certbot/) if you haven't, confirm `ufw` is allowing only SSH and Nginx, set up automated database backups (`mysqldump` in a cron job is a fine start), and keep WordPress core and plugins updated — outdated plugins are the most common way WordPress sites get compromised. If you plan to host more than one site on this server, the same pattern repeats: one directory, one database user, one server block per site, as covered in the broader [stack guides](/stack-guides/) collection. The differences in how Nginx and Apache handle rewrites and per-directory config are worth understanding too, and our [Nginx vs Apache comparison](/nginx-vs-apache/) goes deeper on both. ## Frequently asked questions **Why do WordPress permalinks return 404 errors on Nginx?** Nginx does not read the .htaccess rewrite rules WordPress writes for Apache, so pretty permalinks fail unless your server block handles them. The fix is the directive try_files $uri $uri/ /index.php?$args; inside the location / block. It tells Nginx to serve real files and directories directly, and to pass everything else to index.php with the query string intact so WordPress's router can resolve the URL. Reload Nginx after adding it. **What file permissions should WordPress have on an Nginx server?** The standard arrangement on Ubuntu is ownership by www-data (the user PHP-FPM runs as), directories set to 755, and files set to 644. That lets WordPress upload media, install plugins, and self-update while keeping files unwritable by other system users. The one exception is wp-config.php, which holds the database password in plain text — set it to 640 so other local accounts cannot read it. Never use chmod 777 anywhere in the web root; it makes files writable by any process on the server and is a common cause of compromised sites. **How do I create a WordPress database from the MySQL command line?** Open the shell with sudo mysql, then run: CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'a-strong-password'; GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost'; This scopes the account to one database from localhost only, so a leaked credential can't reach anything else on the server. You do not need FLUSH PRIVILEGES afterward — MySQL reloads the grant tables automatically for CREATE USER and GRANT; it is only required after editing the mysql.user tables directly. Verify with mysql -u wpuser -p wordpress before continuing. **Which PHP extensions does WordPress need on Ubuntu 24.04?** Beyond php8.3-fpm itself, install php8.3-mysql (database driver), php8.3-curl (HTTP requests for updates and APIs), php8.3-gd and php8.3-imagick (image thumbnails), php8.3-mbstring (multibyte strings), php8.3-xml (feeds and sitemaps), php8.3-zip (plugin uploads), and php8.3-intl (locale formatting). Install them with a single apt command and restart PHP-FPM afterward so the new extensions load. WordPress runs without some of these, but features like image resizing quietly break. **Do I need a wp-config.php file before running the WordPress installer?** No — the browser installer can create it — but writing wp-config.php manually first is more reliable on Nginx and lets you set proper security salts. Copy wp-config-sample.php to wp-config.php as the www-data user, fill in the database name, user, password, and localhost host, then replace the placeholder authentication keys with fresh values from the api.wordpress.org salt generator. Finish by running chmod 640 on the file so the plaintext database password is not world-readable. With the file in place, the installer skips straight to the site-setup screen. **What causes a 502 Bad Gateway error on a new WordPress LEMP install?** A 502 means Nginx accepted the request but could not reach PHP-FPM. Check that the service is running with sudo systemctl status php8.3-fpm, and confirm the fastcgi_pass path in your server block matches the actual socket — on Ubuntu 24.04 that is unix:/run/php/php8.3-fpm.sock. A version mismatch in the socket filename after a PHP upgrade is the most common cause. Fix the path, run nginx -t, then reload Nginx. --- # LEMP Stack Production Setup: Pre-Deploy Checklist > A production-ready LEMP stack needs more than running services. Before deployment, verify a recoverable backup, restricted network exposure, active security updates, explicit Nginx host routing, valid TLS renewal, production PHP error handling, least-privilege database access, durable logs, monitoring, and a tested rollback. Record installed versions and effective configuration instead of trusting a tutorial's defaults. This checklist gives each gate an owner and evidence; it deliberately contains no untested commands. Source: https://lemp.io/lemp-production-checklist/ Publisher: LEMP Field Notes Published: 2026-09-05 Updated: 2026-09-07 ## What makes a LEMP stack ready for production? A production-ready LEMP stack is a system you can inventory, restrict, observe, recover, and roll back. Running Nginx, PHP-FPM, and MySQL or MariaDB proves only that the components start. Before real traffic arrives, require evidence for recovery, network exposure, updates, host routing, TLS renewal, PHP behavior, database access, logging, monitoring, and rollback. This checklist deliberately contains no shell commands. No Ubuntu 24.04 test host was available for testing commands as written, so publishing copy-and-paste steps would be guesswork. The linked implementation guides carry the procedures; this page defines the production gates those procedures must satisfy. | Gate | Evidence to keep | Failure it prevents | |---|---|---| | Inventory | OS release, package origins, installed versions, active configuration | Tuning the wrong service or file | | Recovery | A completed restore rehearsal | Discovering that a backup cannot be restored | | Exposure | Listener and firewall review | Publishing an administrative service | | Updates | Update history and maintenance owner | Leaving known fixes unapplied | | Nginx | Host-routing and configuration-validation results | Serving the wrong site or invalid configuration | | TLS | Renewal test and expiry alert | An avoidable certificate outage | | PHP-FPM | Pool, error, queue, and capacity evidence | Data disclosure or worker exhaustion | | Database | Account, privilege, listener, and restore review | Excessive access or unrecoverable data | | Deployment | Release identifier and rollback rehearsal | A one-way release | | Monitoring | External checks and routed alerts | A quiet failure | ## Start with the effective system, not remembered defaults Record the operating-system release, installed package versions, package sources, enabled services, and listening network sockets. Then record the effective Nginx configuration, the PHP-FPM pools and configuration inputs, and the exact database product and edition. “LEMP” does not identify those facts. Do not infer a PHP socket name, service name, configuration path, process-manager mode, database bind address, or package version from a tutorial. Nginx itself documents several possible configuration locations because installation methods differ. MySQL documents how to inspect the values used by the running server. The relevant evidence is what this server loaded, not what a sample file contains. If the machine is not installed yet, the [Ubuntu 24.04 LEMP installation guide](/install-lemp-stack-ubuntu-24-04/) covers the installation sequence. Return here after installation and inventory the resulting system. ## Prove recovery before changing production settings A backup job reporting success is not recovery evidence. Define what must be recoverable: application data, database contents, uploaded files, configuration, secrets, and any metadata needed to resume service. Run a restoration rehearsal into an isolated target that cannot overwrite production. Confirm the application can read the restored data, not merely that an archive can be opened. Record the restore duration you actually observed instead of promising a recovery time you have not measured. MySQL’s security documentation explicitly calls for an appropriate recovery solution and a test that information can be recovered from backups. Choose the documented procedure for the installed database product and version. Do not assume a procedure for one engine applies to another. ## Reduce exposure at every network boundary Compare listening sockets with both the provider firewall and the host firewall. Every reachable listener needs a reason, an owner, and an approved source range. Ubuntu documents UFW as initially disabled; verify the machine’s current state. Protect administrative access before changing firewall or SSH policy. Keep a tested second session or provider-console path so a mistaken rule does not cause a lockout. This gate is complete only when observed reachability matches the approved design. PHP’s manual gives a sharper boundary: PHP-FPM must not be reachable from an untrusted network because a FastCGI client can influence request configuration. Limit the FPM endpoint to the intended local or private caller. Apply the same test to the database. Public database access is not a harmless default to tidy later. ## Make security updates an operation, not an intention Check that the Ubuntu release is still in the support window relevant to its enabled repositories. Review recent update activity, pending updates, failed jobs, and whether a reboot is waiting. Assign an owner and a maintenance path for updates that require service restarts or a host reboot. Ubuntu says unattended-upgrades is included in default Server installations from Ubuntu 18.04 LTS and applies security updates automatically. It also says third-party repositories and PPAs are not covered automatically unless configured. Those are documented installation behaviors, not proof about this machine. Inspect the effective policy and its logs. Ubuntu also documents AppArmor as installed and loaded by default. Confirm its current status and investigate denials rather than disabling enforcement to make an unexplained failure disappear. ## Prove Nginx routes only the intended hosts Nginx selects a virtual server by listening address and port, then by the request’s Host field. If no name matches, the default server for that address and port handles the request. Test every expected hostname and an unexpected hostname over every exposed address family. An unmatched request must not reveal another application, a setup page, or a directory intended for a different site. Confirm each document root contains only public assets and the application’s public entry point. Keep diagnostic pages, environment files, database dumps, deployment archives, and private keys outside it. Before any reload, validate the complete effective configuration. Nginx’s test checks syntax and attempts to open referenced files. A failed reload keeps the old configuration, but that does not protect against a syntactically valid mistake. Preserve the previous reviewed configuration and define a rollback trigger. Use the [Nginx server-block guide](/nginx-server-blocks/) for implementation details. Production sign-off needs the routing results, not a screenshot of a configuration file. ## Treat TLS renewal as part of the service Confirm HTTPS serves the expected certificate for every production hostname and that the intended redirect works. Verify the renewal mechanism installed on this host; Certbot says the installation method determines the actual setup. Run a renewal dry run through the approved, tested procedure and route renewal failure and certificate-expiry alerts to a monitored destination. Record who responds. Do not manually edit Certbot renewal files; its documentation warns that this can break renewal. The [Nginx and Certbot guide](/nginx-lets-encrypt-certbot/) covers certificate implementation. ## Size PHP-FPM from measurements Identify each application’s pool, operating-system identity, and Nginx endpoint. Separate pools where applications cross a trust boundary. Limit write access to required directories. PHP-FPM supports static, dynamic, and on-demand process management. Capacity is not a universal number. Measure worker memory, concurrency, and queue behavior; leave headroom for Nginx, the database, the operating system, and bursts; then set a sustainable limit. Recheck after application or extension changes. PHP states that displayed errors should be disabled in production because output can reveal confidential information, while error logging can preserve diagnostic detail. Verify both the browser response and the log destination. If the FPM status page is enabled, PHP says it must be limited to internal requests or known client addresses because it reveals request URLs and resource information. Verify OPcache behavior against the deployment method. PHP documents that disabling timestamp validation requires an explicit cache reset or web-server restart before filesystem changes take effect. Do not copy cache sizes or validation settings from another host. Confirm that a release becomes visible when expected and that rollback does too. Finally, align request and upload limits across Nginx, PHP, and the application. The smallest effective limit wins, and inconsistent settings produce failures that look intermittent to users. ## Give the application the smallest database role it needs Use a dedicated application account rather than an administrative account. MySQL’s privilege guidance says an account should receive only the privileges it needs and calls for particular caution with file and administrative privileges. Review privileges against real application operations, including migrations, instead of granting broad access to avoid a deployment error. Check both account host scope and the database listener. MySQL documents that bind addresses determine which interfaces accept connections; MariaDB behavior and packaged configuration can differ. If the database is local, prove it is not reachable from an untrusted network. If it is remote, restrict the route and use the installed product’s documented transport protection. Store credentials outside the public document root, restrict who can read them, and document rotation. Monitor authentication failure, connection exhaustion, storage pressure, and slow queries without exposing query text or secrets. ## Make the deployment reversible Give every release an immutable identifier. Record the artifact, configuration revision, migration state, validation result, and rollback decision. File timestamps are not a release record. Define which changes can roll back independently and which database changes require a forward fix. Test the rollback path before traffic depends on it. Account for scheduled tasks, background workers, caches, and writable data that a web-only check misses. Keep ownership and permissions explicit, but do not copy a universal numeric mode. The correct access depends on which identities deploy, serve, and write. The evidence is that each identity can do exactly its job and cannot modify unrelated code, configuration, or secrets. ## Monitor the entire request path An external HTTPS check proves more than a local process check: DNS, routing, Nginx, TLS, and at least one response path must all work. Add application checks that exercise PHP and, where safe, a database read. Keep the health endpoint cheap and prevent it from disclosing versions, configuration, or credentials. Monitor HTTP error rate, request time, Nginx errors, PHP-FPM queue and capacity signals, database connection pressure, disk space and inodes, memory pressure, backup results, and certificate renewal. Thresholds must come from measured baseline and service requirements, not a generic checklist. Route every actionable alert to a person or rotation. Record the first diagnostic link and the escalation path. An alert without an owner is a log entry with better typography. ## Run one launch rehearsal Before DNS or traffic changes, rehearse the production path using the real hostname and intended address families. Verify HTTP and HTTPS behavior, static files, a PHP request, a database-backed request, expected not-found and error responses, logging, and any application feature that writes data or schedules work. Then test service recovery after the kind of restart your maintenance plan permits. Confirm monitoring notices the interruption and recovery. Finish by reviewing the rollback trigger and the evidence table with the person responsible for the launch. Mark a gate complete only when its evidence exists. “Configured” describes an action. “Observed working, recoverable, and monitored” describes a production system. ## Sources - [Ubuntu security updates](https://documentation.ubuntu.com/security/security-updates/) — automatic-update behavior, repository coverage, and support context; accessed September 3, 2026. - [Ubuntu Server firewall documentation](https://documentation.ubuntu.com/server/how-to/security/firewalls/index.html) — UFW role and initial state; accessed September 3, 2026. - [Ubuntu Server AppArmor documentation](https://documentation.ubuntu.com/server/how-to/security/apparmor/index.html) — AppArmor state, modes, and audit handling; accessed September 3, 2026. - [Nginx request processing](https://nginx.org/en/docs/http/request_processing.html) — virtual-server and default-server selection; accessed September 3, 2026. - [Nginx command-line parameters](https://nginx.org/en/docs/switches.html) and [Nginx control](https://nginx.org/en/docs/control.html) — configuration validation and reload behavior; accessed September 3, 2026. - [PHP error basics](https://www.php.net/manual/en/language.errors.basics.php) — production error display and logging; accessed September 3, 2026. - [PHP-FPM overview](https://www.php.net/manual/en/install.fpm.php), [FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php), and [FPM status](https://www.php.net/manual/en/fpm.status.php) — network boundary, pools, capacity, and status-page restriction; accessed September 3, 2026. - [PHP OPcache configuration](https://www.php.net/manual/en/opcache.configuration.php) — cache and timestamp behavior; accessed September 3, 2026. - [MySQL security](https://dev.mysql.com/doc/refman/8.4/en/security.html), [privilege guidance](https://dev.mysql.com/doc/refman/8.4/en/privileges-provided.html), and [server variables](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html) — recovery testing, least privilege, and listener configuration; accessed September 3, 2026. - [Certbot user guide](https://eff-certbot.readthedocs.io/en/stable/using.html) — automated renewal, dry runs, and renewal-file handling; accessed September 3, 2026. ## Frequently asked questions **What should I change before putting a LEMP stack into production?** Start by inventorying the effective configuration, then verify recovery, network exposure, security updates, Nginx host routing, TLS renewal, PHP error handling, database privileges, logs, monitoring, and rollback. Do not copy process counts, timeouts, cache sizes, or listener settings from another server. Measure the workload and keep evidence for every completed gate. **Is an installed LEMP stack ready for production?** No. Successful installation proves that Linux, Nginx, PHP-FPM, and the database can start and communicate. Production readiness also requires restricted access, recoverable backups, validated routing, renewable TLS, safe error handling, least-privilege accounts, monitoring, and a reversible deployment. Each item needs an observed result rather than an assumption about package defaults. **How do I choose PHP-FPM worker limits?** Measure worker memory under representative requests and observe concurrency and queue behavior. Reserve capacity for Nginx, the database, the operating system, and traffic bursts, then choose a limit the host can sustain. Recheck after changes to the application or PHP extensions. A worker count copied from a different machine is not evidence. **Should MySQL or MariaDB be reachable from the internet?** Not unless the architecture explicitly requires remote database access. For a database used only by applications on the same host, verify that its listener is unavailable to untrusted networks. For a remote database, restrict network routes and account host scope, use the installed product's documented transport protection, and grant the application only the privileges it needs. **How do I know a LEMP backup is usable?** Restore it into an isolated target and prove that the application can read the recovered data. Include the database, uploaded files, configuration, secrets, and any metadata needed to resume service. Record the observed restoration time, the responsible owner, and the product-version requirements. A successful backup job alone does not prove recoverability. **What should a LEMP production monitor check?** Use an external HTTPS check for DNS, routing, Nginx, and TLS, then add safe checks for PHP and a database read. Monitor HTTP errors and latency, Nginx logs, PHP-FPM queue and capacity, database connections, disk space and inodes, memory pressure, backup results, and certificate renewal. Every actionable alert needs a monitored destination and an owner. --- # LEMP vs LAMP: What Actually Changes When You Swap Apache for Nginx > LEMP uses Nginx; LAMP uses Apache HTTP Server. Both can retain Linux, PHP, and MySQL or MariaDB, but the web-server swap changes request routing, PHP integration, and configuration ownership. Choose LAMP when an application depends on Apache modules or maintained .htaccess rules. Choose LEMP when the application has tested Nginx configuration and the team can operate Nginx with PHP-FPM. Neither stack is universally faster. Source: https://lemp.io/lemp-vs-lamp-stack/ Publisher: LEMP Field Notes Published: 2026-09-07 Updated: 2026-09-07 ## What is the difference between LEMP and LAMP? Both stacks combine Linux, a relational database, and PHP; the web server changes. LAMP uses Apache HTTP Server, while LEMP uses Nginx. That swap changes request routing, PHP integration, per-directory configuration, and migration work. Choose LAMP when an application or hosting workflow depends on Apache modules or `.htaccess`. Choose LEMP when the team is prepared to maintain explicit Nginx server and location rules. Neither acronym guarantees better performance. This is a desk-researched architecture comparison, not a benchmark. The right result depends on the application, configuration, traffic, hardware, PHP capacity, database behavior, and operating team. ## Which components stay the same? The database and language do not have to change when the web server changes. A typical comparison keeps these three layers fixed: - **Linux:** the operating system and service manager. - **Database:** MySQL or MariaDB, selected against the application's support requirements. - **PHP:** the runtime that executes the application. The web server receives HTTP requests, selects a site and route, serves static files where appropriate, and hands eligible PHP requests to a PHP handler. A migration from LAMP to LEMP is therefore not a rewrite of every layer. It is a replacement of the request-routing and web-server integration layer, with consequences for configuration the application previously delegated to Apache. For the web-server-specific comparison, see [Nginx versus Apache](/nginx-vs-apache/). This guide stays with the whole-stack decision. ## How does PHP run in each stack? Nginx sends PHP requests to a FastCGI server. Its official request-processing guide shows a PHP location passing a request to a FastCGI endpoint and setting parameters such as the script filename. In a common LEMP design, PHP-FPM is that FastCGI service. PHP's manual describes PHP-FPM as a primary FastCGI implementation with process-manager modes, separate pools, logging, status information, and other controls. It also warns that PHP-FPM must not be reachable from an untrusted network. The web server-to-FPM boundary is part of the security design, not merely plumbing. Apache has more than one PHP integration model. It can also connect to PHP-FPM through `mod_proxy_fcgi`; Apache's current module documentation includes both network and Unix-domain-socket handler forms. Some older LAMP deployments instead load PHP into Apache through a module. Do not infer the active model from the word LAMP. Inventory the actual Apache modules, Multi-Processing Module, PHP service, socket or port, and virtual-host configuration. | Question | LEMP | LAMP | | --- | --- | --- | | Web server | Nginx | Apache HTTP Server | | Common external PHP handler | PHP-FPM through FastCGI | PHP-FPM is supported; other integration models also exist | | Site routing | `server` and `location` configuration | Virtual hosts, directory sections, and modules | | Per-directory overrides | No `.htaccess` processing | Available when permitted with `AllowOverride` or `AllowOverrideList` | | Main migration risk | Translating Apache behavior into explicit Nginx rules | Preserving or rationalizing Apache modules and overrides | ## What happens to .htaccess on Nginx? Nginx does not process Apache `.htaccess` files. Rewrite rules, redirects, access controls, custom error behavior, index handling, caching headers, and other instructions in those files do not follow the application automatically. They must be translated into the appropriate Nginx configuration and tested in their actual request contexts. Apache calls `.htaccess` a distributed configuration mechanism. Its current documentation says that when an administrator controls the main server configuration, those rules should normally be placed there instead. Apache must check permitted directories for `.htaccess` during requests, and the documentation identifies both performance and security reasons to prefer main configuration where possible. That does not make `.htaccess` universally wrong. It can be central to shared hosting, control-panel workflows, and applications designed to ship per-directory rules without server-administrator access. If that operational model is a requirement, Apache is usually the lower-friction choice. Before a migration, search every deployed directory for override files and classify each directive. A translation checklist should include: - front-controller rewrites and canonical redirects; - authorization and denied paths; - static-file caching and compression behavior; - MIME types, indexes, and custom errors; - uploaded-file and script-execution restrictions; - application-generated rule changes during updates. Compare behavior with a URL corpus, not by visual inspection of configuration syntax. ## Does Nginx route requests differently? Yes. Nginx first selects a server for the address and port, then uses the request host and its location-selection rules. Its documentation distinguishes literal prefix locations from regular-expression locations and explains that their selection order matters. A rule that looks nearby in a file may not handle the request you expect. Apache's behavior is assembled through virtual hosts, modules, directory and location containers, and any permitted per-directory overrides. Migrating requires a route map: hostname, path, static or dynamic handling, authentication, redirects, headers, upload limits, and error response. Test default-host behavior as well as known domains so an unmatched host does not silently reach the wrong application. ## Is LEMP always faster than LAMP? No. The web server's architecture is only one part of response time and capacity. PHP worker limits, slow application code, database queries, external calls, disk behavior, caches, TLS, connection patterns, and response size can dominate the result. A generic benchmark cannot select a production stack. Measure the real application with the same data, PHP release, dependencies, cache state, TLS path, hardware, and request mix. Record errors and tail latency as well as throughput. If the comparison changes several layers at once, it does not isolate the web server. Choose Nginx for a technical or operational reason: your team knows its routing model, the application has a proven configuration, or the surrounding platform standardizes on it. Choose Apache for the same kind of reason, including required modules or per-directory control. “Faster” without a matched test is not a reason. ## Which stack is easier to operate? The easier stack is the one your application and team already support safely. LAMP often reduces migration work when an application ships Apache rules, administrators rely on Apache-specific modules, or content owners genuinely need constrained per-directory changes. Apache's `AllowOverrideList` can permit named directives more narrowly than broad override categories, but that choice still needs an explicit security review. LEMP can make control more centralized because routing lives in administrator-managed Nginx configuration. That is useful only when someone owns the translations, validation, reload process, PHP-FPM capacity, and rollback. Centralized configuration is not self-documenting. Operational fit includes: 1. supported packages and security updates for the chosen distribution; 2. documented application requirements; 3. staff experience and on-call diagnostics; 4. backup, restore, logging, monitoring, and deployment integration; 5. a staging environment that reproduces routing and PHP behavior; 6. a rollback that restores both application files and web-server configuration. ## How should you evaluate a LAMP-to-LEMP migration? Start with evidence from the running LAMP site. Record virtual hosts, enabled modules, PHP integration, `.htaccess` files, certificates, redirects, scheduled jobs, upload behavior, cache rules, and log destinations. List the URL and response behavior that must remain unchanged. Build the LEMP candidate outside production. Translate one behavior at a time, run the same functional tests, inspect both Nginx and PHP-FPM logs, and verify static files, PHP routes, redirects, authorization, uploads, errors, headers, and default-host handling. Use a temporary hostname or isolated routing method that does not expose the candidate as the live service. Do not remove the working Apache configuration during evaluation. Preserve a known-good deployment and a database-compatible rollback point. The cutover plan should name the owner, evidence, stop conditions, and method for returning traffic. ## Should a new project use LEMP or LAMP? Use this decision order: - Choose the stack the application vendor or managed platform explicitly supports. - Choose LAMP when Apache modules or maintained `.htaccess` behavior are requirements. - Choose LEMP when the application has a tested Nginx configuration and the team owns Nginx plus PHP-FPM operations. - If both are supported, prototype both with identical application layers and compare operational evidence, not internet benchmark claims. The least expensive stack is usually the one that avoids an unnecessary translation. For a new application with no Apache dependency, LEMP is a reasonable default when the team already operates Nginx. For an established Apache application, staying on LAMP may be the more disciplined choice until the migration has a measurable benefit and a tested rollback. ## Sources - [Nginx: How Nginx processes a request](https://nginx.org/en/docs/http/request_processing.html) - [Nginx: Beginner's guide, including FastCGI proxying](https://nginx.org/en/docs/beginners_guide.html) - [PHP Manual: FastCGI Process Manager](https://www.php.net/manual/en/install.fpm.php) - [Apache HTTP Server 2.4: mod_proxy_fcgi](https://httpd.apache.org/docs/2.4/mod/mod_proxy_fcgi.html) - [Apache HTTP Server 2.4: .htaccess files](https://httpd.apache.org/docs/2.4/howto/htaccess.html) ## Frequently asked questions **Is LEMP faster than LAMP?** Not universally. PHP capacity, application code, database queries, caches, storage, TLS, and request mix may matter more than the web server. Compare the actual application with identical data, PHP, dependencies, hardware, and traffic. Record errors and tail latency as well as throughput. A benchmark that changes several layers at once cannot attribute its result to Nginx or Apache. **Can Nginx use .htaccess files?** No. Nginx does not process Apache .htaccess files. Rewrites, redirects, access controls, caching headers, and other rules must be translated into Nginx configuration and tested. If an application or shared-hosting workflow depends on content owners changing constrained per-directory rules, Apache may be the lower-friction choice. **Does LAMP have to run PHP as an Apache module?** No. Apache HTTP Server can connect to PHP-FPM through mod_proxy_fcgi, including network and Unix-domain-socket handler configurations. Older deployments may use another integration model. Inspect the enabled Apache modules, Multi-Processing Module, PHP service, and handler configuration rather than inferring the architecture from the word LAMP. **Should an existing Apache site migrate to Nginx?** Migrate only for a defined benefit and after testing every routed behavior. Inventory virtual hosts, modules, .htaccess rules, PHP handling, redirects, uploads, authorization, headers, errors, certificates, and logs. Build an isolated Nginx candidate, compare a representative URL corpus, preserve the working Apache deployment, and define stop conditions and rollback before moving traffic. --- # MySQL vs MariaDB for a LEMP Stack: Which to Install and When It Matters > Choose the database your application, extensions, backup tools, and hosting platform explicitly support. MySQL is the safer choice for MySQL-specific JSON, authentication, or replication features. MariaDB is appropriate when the application certifies it and your operations already use MariaDB tooling. Modern releases are separate products, not automatic drop-in replacements, so test the exact versions, schema, queries, accounts, backups, and restore path before committing. Source: https://lemp.io/mysql-vs-mariadb-lemp/ Publisher: LEMP Field Notes Published: 2026-09-07 Updated: 2026-09-08 ## Should a LEMP stack use MySQL or MariaDB? Use the database version your application, extensions, backup tooling, and hosting plan explicitly support. MySQL is the safer default when vendor documentation names MySQL or the application depends on MySQL-specific JSON, authentication, or replication features. MariaDB is a sound choice when the application certifies it and you prefer Ubuntu's MariaDB packages or MariaDB's own ecosystem. Do not choose on the old claim that they are interchangeable. Modern releases require separate compatibility testing. This is a desk-researched product comparison, not a benchmark. No database was load-tested for this article, so it does not rank either server by speed. ## What is the practical difference in a LEMP stack? LEMP means Linux, Nginx, a MySQL-compatible database, and PHP. Nginx does not care whether the application stores data in MySQL or MariaDB. PHP normally connects through PDO or MySQLi, while the application and database server determine which SQL, authentication, data types, and operational features are valid. MariaDB began as a MySQL-compatible server, and the two still share much of the client protocol and familiar SQL surface. They are now separate products with separate release lines. Compatibility at connection time does not prove that a schema, query set, replication topology, or restore procedure will behave identically. | Decision area | Prefer MySQL when | Prefer MariaDB when | | --- | --- | --- | | Application support | The vendor certifies a specific MySQL release | The vendor certifies a specific MariaDB release | | Existing estate | Backups, replicas, monitoring, and staff procedures already target MySQL | Those operational dependencies already target MariaDB | | JSON use | The application relies on MySQL's native JSON storage or MySQL-specific functions | JSON is used within MariaDB's documented text-backed model and tested there | | Replication | The design uses MySQL GTIDs or MySQL Group Replication | The design uses MariaDB's replication model and tooling | | Migration risk | The source system is already MySQL | The source system is already MariaDB | For a conventional PHP site that uses ordinary InnoDB tables and supported SQL, either may be viable. The decisive word is supported. ## Which database does Ubuntu 24.04 package? Ubuntu 24.04 LTS offers both servers as distinct packages. On 7 September 2026, Ubuntu's Noble package index listed the `mysql-server` metapackage on the MySQL 8.0 line and `mariadb-server` on the MariaDB 10.11 line. The exact security revision differed by architecture, and it will change again as updates ship. That means Ubuntu does not make the architectural choice for you. Selecting the MySQL package installs MySQL; selecting the MariaDB package installs MariaDB. Record the repository, architecture, package version, and application requirement before installation. A tutorial's version string is only a historical observation. For the wider server build, use the [Ubuntu 24.04 LEMP installation guide](/install-lemp-stack-ubuntu-24-04/), then replace its database choice only when the application's support matrix permits it. ## Does WordPress support both MySQL and MariaDB? Yes, within WordPress's stated version floor. The current WordPress requirements page recommends MariaDB 10.11 or later or MySQL 8.0 or later. That supports either database family for WordPress itself; it does not certify every plugin, managed-host extension, backup utility, or migration path. Apply the same rule to any PHP application. Read its current requirements and upgrade notes. Check the exact release you intend to run, not just the product name. An application may support both families generally while an extension uses a function, collation, or administrative feature available in only one. ## Where have MySQL and MariaDB diverged? The largest risk is not ordinary `SELECT`, `INSERT`, or `UPDATE` statements. It is code and operations that touch product-specific behavior. ### JSON is not the same storage type MySQL 8.4 documents `JSON` as a native type. It validates documents and stores them in an internal binary format designed for direct access to members. MariaDB documents `JSON` as an alias for `LONGTEXT COLLATE utf8mb4_bin`, with JSON validation applied through a constraint. Both can accept and query JSON, but identical type names do not imply identical storage, functions, indexing options, comparison behavior, or replication. Inventory every JSON column and function before choosing or migrating. Test generated columns, indexes, path expressions, duplicate-key handling, and export/import behavior with representative data. ### GTIDs and replication are product-specific MariaDB's current compatibility documentation says its Global Transaction ID structure differs from MySQL's and that the formats cannot be mixed directly. MySQL also documents Group Replication as its own feature with explicit requirements and limitations. Do not design a mixed topology from the phrase “MySQL-compatible.” Choose one documented replication model, verify that backups restore into the target release, and rehearse failover outside production. If a managed service is involved, its supported engines and replication controls take priority. ### Authentication and administration can differ Account plugins, password policies, roles, system variables, configuration names, and administrative utilities have diverged. A PHP connector speaking the shared protocol may connect successfully while an old deployment script, monitoring check, or user-creation statement fails. List every dependency that talks to the database: the application runtime, command-line clients, backup jobs, schema migration tools, monitoring exporters, reporting systems, and replicas. Confirm each against the exact target version. ## Is MariaDB a drop-in replacement for MySQL? Treat “drop-in replacement” as historical context, not a migration plan. MariaDB's compatibility matrix says the projects preserve broad compatibility but identifies high-impact differences in authentication, JSON, collations, variables, and GTIDs. Its upgrade guidance specifically tells MySQL 8.0 users to use a logical dump when moving to MariaDB rather than swapping server binaries over the existing data directory. A safe evaluation uses a copy of production-shaped data in an isolated environment. Restore through the target product's documented logical path, run schema migrations, compare row counts and critical queries, exercise login and background jobs, and test a second restore. Preserve the original backup and source server until the acceptance and rollback criteria are complete. Never point the other database server at an existing production data directory. Physical files, system tables, redo logs, and upgrade state are version-specific. A logical migration is slower than wishful thinking but easier to audit. ## What should you test before committing? Use a short acceptance sheet with observed results: 1. **Support:** record the application and extension pages that name the exact database family and minimum release. 2. **Schema:** load a fresh copy and check tables, views, triggers, stored routines, generated columns, collations, and foreign keys. 3. **Queries:** run the application's automated tests plus representative reads, writes, searches, reports, and background jobs. 4. **Identity:** test application accounts, administrative access, password rotation, TLS requirements, and least-privilege grants. 5. **JSON and SQL modes:** exercise every nontrivial JSON expression and any query affected by strictness, grouping, dates, or reserved words. 6. **Operations:** prove backup, restore, monitoring, log collection, upgrade, and rollback procedures. 7. **Capacity:** measure the candidate under the application's own workload. Do not import a benchmark from unrelated hardware or data. Pass or fail each item. “It started” is not an acceptance result. ## Which one should a new LEMP project choose? Choose in this order: - If a commercial application or managed platform specifies one engine, use that engine and supported release. - If an existing system is being moved, remain on its current database family unless a documented requirement justifies the migration. - If a new application uses MySQL-native JSON, Group Replication, or other MySQL-specific behavior, choose MySQL. - If the application explicitly supports MariaDB and the operating team standardizes on its packages and tooling, choose MariaDB. - If both remain valid, prototype both from the same schema and select on operational fit: supported upgrades, restore evidence, observability, staff familiarity, and exit cost. Do not select MariaDB because it once behaved like a transparent MySQL substitute. Do not select MySQL because its name appears in the letter M of LEMP. Select the supported system you can restore, monitor, upgrade, and migrate deliberately. ## Sources - [Ubuntu Packages: MySQL server in Ubuntu 24.04 LTS](https://packages.ubuntu.com/noble/mysql-server) - [Ubuntu Packages: MariaDB server in Ubuntu 24.04 LTS](https://packages.ubuntu.com/noble/mariadb-server) - [WordPress.org: Requirements](https://wordpress.org/about/requirements/) - [MySQL 8.4 Reference Manual: The JSON data type](https://dev.mysql.com/doc/refman/8.4/en/json.html) - [MySQL 8.4 Reference Manual: Group Replication](https://dev.mysql.com/doc/refman/8.4/en/group-replication.html) - [MariaDB Documentation: JSON data type](https://mariadb.com/docs/server/reference/data-types/string-data-types/json) - [MariaDB Documentation: MySQL to MariaDB compatibility matrix](https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb/migrating-to-mariadb/moving-from-mysql/mysql-to-mariadb-compatibility-matrix) - [MariaDB Documentation: Upgrading from MySQL to MariaDB](https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb/migrating-to-mariadb/moving-from-mysql/upgrading-from-mysql-to-mariadb) ## Frequently asked questions **Is MariaDB a drop-in replacement for MySQL?** Do not assume it is. The products retain broad protocol and SQL compatibility, but modern releases differ in JSON storage, authentication, collations, system variables, and GTID replication. Treat a move between them as a migration: use the target product's documented logical path, test a production-shaped copy, prove restore and rollback, and keep the source intact until acceptance is complete. **Does WordPress use MySQL or MariaDB?** WordPress's current requirements recommend either MariaDB 10.11 or later or MySQL 8.0 or later. That makes both viable for WordPress core at the stated versions. Check every plugin, backup tool, hosting extension, and migration utility separately, because WordPress's general requirement does not certify the rest of your operating stack. **Which database is included with Ubuntu 24.04?** Ubuntu 24.04 LTS offers separate MySQL and MariaDB server packages. The Noble package index inspected on 7 September 2026 listed MySQL on the 8.0 line and MariaDB on the 10.11 line. Security revisions vary by architecture and change over time, so record the exact candidate from your approved repository before installation. **Can MySQL and MariaDB replicate with each other?** Do not assume direct compatibility. MariaDB documents that its GTID structure differs from MySQL's and that the formats cannot be mixed directly. JSON, authentication, collations, and binary-log features can add further constraints. Design replication from the exact product documentation, prove it in an isolated environment, and use a single supported topology rather than combining defaults from both products. --- # Nginx 502 Bad Gateway with PHP-FPM: 6 Causes and Fixes > A 502 Bad Gateway on Nginx with PHP-FPM means Nginx received the request but got no valid response from PHP-FPM. The usual causes: PHP-FPM is stopped, the fastcgi_pass path does not match PHP-FPM's listen socket, a socket/TCP mismatch, socket permission errors, an exhausted pm.max_children worker pool, or crashed workers. Check /var/log/nginx/error.log first — the exact error message identifies which cause applies, then restart PHP-FPM or align the two configs. Source: https://lemp.io/nginx-502-bad-gateway-php-fpm/ Publisher: LEMP Field Notes Published: 2026-09-04 Updated: 2026-09-04 ## What a 502 Bad Gateway Means on a LEMP Stack A **502 Bad Gateway** error means Nginx accepted the request but got no valid response from the upstream service it proxied to — on a LEMP stack, that upstream is almost always PHP-FPM. Nginx itself is running fine; the handoff to PHP failed. Either PHP-FPM is not running, Nginx is pointing at the wrong socket or port, permissions block the connection, or PHP-FPM is running but too overloaded or broken to answer. That framing matters because it tells you where to look. The problem is rarely in your HTML, your PHP code, or your DNS. It lives somewhere on the short path between Nginx and PHP-FPM: the service itself, the address Nginx dials in `fastcgi_pass`, the socket or port that connects them, or the pool's ability to actually answer once the connection is made. This guide walks through the six causes that account for nearly every 502 on an Nginx + PHP-FPM server, in the order you should check them. Each fix includes the exact commands and the log lines that confirm you found the right culprit. If you are still setting up your stack, our [LEMP install guide for Ubuntu 24.04](/install-lemp-stack-ubuntu-24-04/) shows the working baseline configuration these fixes assume. ## First: Read the Nginx Error Log Before changing anything, look at the actual error. The Nginx error log tells you *why* the gateway failed, and the message maps almost directly to one of the six causes below. ```bash sudo tail -20 /var/log/nginx/error.log ``` The three messages you will most likely see: - **`connect() to unix:/run/php/php8.3-fpm.sock failed (2: No such file or directory)`** — the socket file does not exist. PHP-FPM is stopped, or Nginx points at the wrong socket path (Causes 1 and 2). - **`connect() failed (111: Connection refused) while connecting to upstream`** — Nginx is dialing a TCP port and nothing is listening there (Cause 3). - **`connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied)`** — the socket exists but the Nginx worker user cannot open it (Cause 4). With the log line in hand, jump to the matching cause. If the log shows something else — timeouts, "upstream prematurely closed connection" — Causes 5 and 6 cover those. ## Cause 1: PHP-FPM Is Not Running The most common cause of a 502 is simply that the PHP-FPM service is stopped, crashed, or was never started after installation. Check its status. On Ubuntu 24.04 the default package is PHP 8.3, so the unit is named `php8.3-fpm`; adjust the version number to match your installed PHP: ```bash sudo systemctl status php8.3-fpm ``` If the output shows `inactive (dead)` or `failed`, start it and enable it so it survives reboots: ```bash sudo systemctl start php8.3-fpm sudo systemctl enable php8.3-fpm ``` If the service refuses to start, the reason is usually a syntax error in a pool file. PHP-FPM ships its own config tester, just like `nginx -t`: ```bash sudo php-fpm8.3 -t ``` Fix whatever it reports, then start the service again. Reload your site — if the 502 is gone, you are done. If PHP-FPM keeps dying after starting cleanly, skip ahead to Cause 6. ## Cause 2: Nginx Points at the Wrong Socket Path If PHP-FPM is running but the error log says `No such file or directory`, Nginx and PHP-FPM disagree about where the socket lives. This is extremely common after a PHP upgrade: you install PHP 8.3, but your server block still says `php8.1-fpm.sock`, and that file no longer exists. Find out where PHP-FPM is actually listening. The `listen` directive in the pool config is the source of truth: ```bash grep -r "^listen" /etc/php/8.3/fpm/pool.d/ ``` On Debian and Ubuntu the default is a Unix socket: ```text listen = /run/php/php8.3-fpm.sock ``` Now check what Nginx thinks: ```bash grep -r "fastcgi_pass" /etc/nginx/sites-enabled/ ``` The two values must match exactly. A correct PHP location block in your [Nginx server block](/nginx-server-blocks/) looks like this: ```nginx location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } ``` If they differ, edit the server block so `fastcgi_pass` matches the pool's `listen` value, then test and reload: ```bash sudo nginx -t sudo systemctl reload nginx ``` You can also confirm the socket file really exists on disk: ```bash ls -l /run/php/ ``` A healthy socket shows up as a file of type `s` (socket) owned by `www-data` on Ubuntu. No file means PHP-FPM is not running or is listening on TCP instead — which brings us to the next cause. ## Cause 3: Connection Refused on a TCP Port The error `connect() failed (111: Connection refused) while connecting to upstream` means Nginx tried to open a TCP connection — typically to `127.0.0.1:9000` — and the kernel refused it because nothing is listening on that port. This happens when Nginx is configured for TCP (`fastcgi_pass 127.0.0.1:9000;`) while PHP-FPM is listening on a Unix socket — or is not running at all. Docker-derived tutorials often show the TCP form, which then fails on a stock Ubuntu install where the package defaults to a socket. Note that the mirror-image mistake — Nginx pointed at a socket while PHP-FPM listens on TCP — produces `(2: No such file or directory)` instead, because there is no socket file to open. Only the TCP direction can return "connection refused." Verify what is actually listening: ```bash sudo ss -tlnp | grep 9000 ``` No output means nothing owns port 9000. You have two valid fixes — pick one and make both sides agree. **Option A — switch Nginx to the socket (recommended on a single server):** ```nginx fastcgi_pass unix:/run/php/php8.3-fpm.sock; ``` **Option B — switch PHP-FPM to TCP.** Edit `/etc/php/8.3/fpm/pool.d/www.conf` and change the listen line: ```text listen = 127.0.0.1:9000 ``` Then restart PHP-FPM and reload Nginx: ```bash sudo systemctl restart php8.3-fpm sudo nginx -t && sudo systemctl reload nginx ``` ### Unix Socket vs TCP: Which Should You Use? | | Unix socket | TCP (127.0.0.1:9000) | |---|---|---| | Overhead | Lower — no TCP/IP stack | Slightly higher | | Works across hosts/containers | No — same machine only | Yes | | Failure mode | File permissions, missing file | Port conflicts, firewall rules | | Debian/Ubuntu package default | Yes | No | The practical rule: when Nginx and PHP-FPM run on the same machine, use the Unix socket — it is the distro default and avoids port management entirely. Use TCP only when PHP-FPM runs on a different host or in a separate container, where a socket file cannot be shared. ## Cause 4: Socket Permission Denied A `(13: Permission denied)` error means the socket exists and PHP-FPM is running, but the user Nginx's worker processes run as cannot connect to it. On Ubuntu and Debian, both Nginx workers and the default PHP-FPM pool run as `www-data`, so the stock configuration works out of the box. The error appears when one side has been changed — a custom pool running as a different user, or Nginx workers switched away from `www-data`. Check the socket's ownership settings in the pool file: ```bash grep -E "^;?\s*listen\.(owner|group|mode)" /etc/php/8.3/fpm/pool.d/www.conf ``` The `;?` in that pattern is deliberate: on stock Debian and Ubuntu, `listen.mode` ships commented out as `;listen.mode = 0660`. That is not a missing setting — a commented line simply leaves PHP-FPM's built-in default of `0660` in force. An anchored `^listen\.` grep would hide it and send you looking for a problem that is not there. The values you want in effect: ```text listen.owner = www-data listen.group = www-data listen.mode = 0660 ``` And confirm which user Nginx runs as: ```bash grep -E "^user" /etc/nginx/nginx.conf ``` The fix: make `listen.owner` and `listen.group` match the Nginx worker user (or put that user in the socket's group), keep `listen.mode = 0660`, then restart PHP-FPM. Avoid the tempting shortcut of `listen.mode = 0666` — a world-writable socket lets any local user send requests directly to your PHP pool, which is a real risk on shared servers. ## Cause 5: PHP-FPM Is Overloaded — All Workers Busy If the 502s are intermittent — the site works, then throws 502s under traffic, then recovers — PHP-FPM is likely running out of worker processes. Every incoming PHP request needs a free worker; when all of them are busy, new connections pile up in the pool's listen backlog instead of being served. Be aware of the failure order here, because it decides which error you see. While the backlog still has room, connections just wait, and the usual symptom is a **504 Gateway Timeout** — Nginx gave up waiting after `fastcgi_read_timeout`. The 502 appears at the next stage: once the backlog itself is full, the kernel stops accepting new connections and Nginx gets a refused or reset connection instead of a slow one. So a saturated pool typically produces a burst of 504s first, with 502s arriving as the load gets worse. Seeing either one under traffic points at this section. The telltale line lives in the PHP-FPM log, not the Nginx log: ```bash sudo grep "max_children" /var/log/php8.3-fpm.log ``` If you see `server reached pm.max_children setting`, the pool is saturated. The setting lives in `/etc/php/8.3/fpm/pool.d/www.conf`: ```text pm = dynamic pm.max_children = 5 ``` The default of 5 is deliberately conservative and easily overwhelmed on a busy site. To raise it safely, base the number on memory: check the average size of a PHP-FPM process, divide your spare RAM by that, and leave headroom for Nginx and MySQL. ```bash ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1; n++} END {if (n) printf "%.0f MB avg\n", sum/n/1024}' ``` If each worker averages roughly 60 MB and you can spare about 1 GB for PHP, around 16 workers is a reasonable ceiling — recompute for your own numbers rather than copying anyone's. Set `pm.max_children` accordingly and restart PHP-FPM. Slow PHP code makes this worse: a request that takes 30 seconds holds a worker hostage for 30 seconds. If saturation persists after raising the limit, profile what the slow requests are doing (often the database) before adding more workers. ## Cause 6: Crashes, Timeouts, and Oversized Headers The remaining 502s come from PHP-FPM accepting the connection but failing to deliver a usable response. ### Worker crashes `upstream prematurely closed connection while reading response header` in the Nginx log means the PHP worker died mid-request — commonly a fatal error in a PHP extension or an out-of-memory kill. The detail that trips people up is *how* PHP-FPM records a dead worker. It does not write the words "segfault" or "killed" to its error log. It writes the signal that killed the child: ```text WARNING: [pool www] child 1234 exited on signal 11 (SIGSEGV - core dumped) after 3.472 seconds from start WARNING: [pool www] child 1235 exited on signal 9 (SIGKILL) after 61.203 seconds from start ``` Signal 11 is a crash; signal 9 is almost always the kernel's OOM killer (or something else sending `SIGKILL`). So grep the FPM log for the signal vocabulary it actually uses: ```bash sudo grep -iE "SIGSEGV|SIGKILL|SIGBUS|exited on signal" /var/log/php8.3-fpm.log ``` The literal string `segfault` belongs to the kernel ring buffer, not the FPM log, so check `dmesg` separately for both crashes and OOM kills: ```bash sudo dmesg -T | grep -iE "segfault|out of memory|oom-killer" ``` Running only one of these will mislead you: the FPM log tells you a worker died and which signal did it, while `dmesg` tells you what the kernel saw. If the kernel is OOM-killing workers, lower `pm.max_children` or PHP's `memory_limit`, or add RAM. If a specific extension segfaults, disabling it confirms the diagnosis. ### Timeouts Nginx waits a limited time for the FastCGI response — the `fastcgi_read_timeout` directive, 60 seconds by default. A long-running request that exceeds it is cut off. Note that a pure timeout usually returns **504 Gateway Timeout**, not 502 — but PHP-FPM's own `request_terminate_timeout`, if set, kills the worker outright and produces a 502. Align the two: PHP's limit should be at or below Nginx's, and both should be as low as your slowest legitimate request allows. ### FastCGI buffers Rarely, a 502 with `upstream sent too big header` in the log means a PHP response's headers overflowed Nginx's FastCGI buffers — large cookie payloads or frameworks emitting big headers can trigger it. Raising the buffers in the PHP location block resolves it: ```nginx fastcgi_buffer_size 32k; fastcgi_buffers 16 32k; ``` Reload Nginx after the change. ## A 5-Minute 502 Checklist When a 502 hits in production, run this sequence top to bottom: 1. **Read the log:** `sudo tail -20 /var/log/nginx/error.log` — the error message picks the cause. 2. **Check the service:** `sudo systemctl status php8.3-fpm` — start it if it is down. 3. **Match the paths:** compare `fastcgi_pass` in Nginx against `listen` in the pool config. 4. **Check permissions:** socket owned by the Nginx worker user, mode `0660`. 5. **Check saturation:** grep the PHP-FPM log for `max_children` — expect 504s alongside the 502s. 6. **Check crashes:** `sudo grep -iE "SIGSEGV|SIGKILL|exited on signal" /var/log/php8.3-fpm.log`, then `sudo dmesg -T | grep -iE "segfault|out of memory"`. 7. **Test before reload:** `sudo nginx -t && sudo systemctl reload nginx` after any config edit. One last pointer: a misconfigured HTTPS setup is not a cause of the FastCGI 502s covered here — adding TLS in front of PHP-FPM does not change the Nginx-to-PHP handoff at all. (502s from TLS *are* possible in a different setup: an Nginx `proxy_pass` to an `https://` upstream can return 502 when the upstream certificate fails verification or the two sides disagree on protocol. That is a reverse-proxy problem, not a PHP-FPM one.) If you fixed your gateway and are now moving on to hardening, [securing Nginx with Let's Encrypt](/nginx-lets-encrypt-certbot/) is the natural next step — and the FastCGI configuration you just verified carries over unchanged to the TLS server block. ## Frequently asked questions **What causes a 502 Bad Gateway error with Nginx and PHP-FPM?** A 502 means Nginx could not get a valid response from PHP-FPM. The six common causes are: the PHP-FPM service is stopped or crashed, Nginx's fastcgi_pass points at the wrong socket path (common after PHP upgrades), a Unix-socket vs TCP mismatch between the two configs, socket permission errors, all PHP-FPM workers busy because pm.max_children is too low, or workers dying mid-request from fatal errors or out-of-memory kills. **How do I check if PHP-FPM is running?** Run sudo systemctl status php8.3-fpm, adjusting the version number to your installed PHP. If it shows inactive or failed, start it with sudo systemctl start php8.3-fpm and enable it at boot with systemctl enable. If it refuses to start, test the configuration with sudo php-fpm8.3 -t, which reports syntax errors in pool files the same way nginx -t does for Nginx. **What does 'connect() failed (111: Connection refused) while connecting to upstream' mean?** It means Nginx tried to open a TCP connection to the upstream — typically 127.0.0.1:9000 — and nothing was listening on that port. Usually Nginx is configured for TCP while PHP-FPM listens on a Unix socket, or PHP-FPM is down. Verify with sudo ss -tlnp | grep 9000, then make both sides agree: either point fastcgi_pass at the Unix socket or set PHP-FPM's listen directive to 127.0.0.1:9000. **Should PHP-FPM use a Unix socket or TCP port?** Use a Unix socket when Nginx and PHP-FPM run on the same machine — it has lower overhead, avoids port management, and is the Debian/Ubuntu package default (/run/php/php8.3-fpm.sock). Use TCP (127.0.0.1:9000) only when PHP-FPM runs on a different host or in a separate container, where a socket file cannot be shared. Whichever you choose, the fastcgi_pass and listen directives must match exactly. **How do I fix a PHP-FPM socket permission denied error?** A (13: Permission denied) error means the Nginx worker user cannot open the socket. In the pool file (/etc/php/8.3/fpm/pool.d/www.conf), set listen.owner and listen.group to the Nginx worker user — www-data on Ubuntu — with listen.mode = 0660, then restart PHP-FPM. Avoid 0666: a world-writable socket lets any local user send requests straight to your PHP pool. **Why do I get 502 errors only under heavy traffic?** Intermittent 502s under load usually mean the PHP-FPM worker pool is saturated. Check the PHP-FPM log for 'server reached pm.max_children setting'. Expect 504 Gateway Timeout errors first — requests wait in the pool's listen backlog until Nginx gives up — with 502s appearing once the backlog fills and connections are refused outright. The Ubuntu default of 5 workers is conservative; raise pm.max_children based on memory, dividing the RAM you can spare for PHP by the average worker size and leaving headroom for Nginx and MySQL. Also profile slow requests, since each one holds a worker for its full duration. --- # FastCGI Cache, Varnish, or Application Cache: Which Layer for a PHP Site > Use Nginx FastCGI cache for shared PHP responses when Nginx already fronts the site and you can prove every personalized bypass. Add Varnish when its HTTP policy and independent proxy tier justify another service. Use an application or object cache when PHP must run but repeatedly computes or queries the same data. The layers can coexist, but every key, bypass, invalidation event, failure mode, and owner must be documented. Source: https://lemp.io/nginx-caching-layers-compared/ Publisher: LEMP Field Notes Published: 2026-09-07 Updated: 2026-09-07 ## Should a PHP site use FastCGI cache, Varnish, or an application cache? Use FastCGI cache when Nginx already fronts a mostly public PHP site and you need full-response caching without another daemon. Add Varnish when its HTTP cache policy and operational tooling justify another proxy layer. Use an application or object cache when the expensive work is inside PHP or database queries and responses must remain personalized. These layers solve different problems; a site may use more than one, but each added cache needs an owner, invalidation rules, observability, and a safe bypass. This is a capability and operational-cost comparison, not a benchmark. No universal hit rate, latency improvement, or traffic multiple is claimed. ## What does each cache actually store? A full-page cache stores an HTTP response under a cache key. On a hit, it can avoid running PHP and repeating the database work that produced the page. Nginx FastCGI cache and Varnish both operate at this response layer, although they occupy different positions and use different configuration models. An application cache stores values chosen by the application: a database-query result, computed object, rendered fragment, session-independent lookup, or other reusable data. PHP still runs, but it may skip the expensive operation. WordPress, for example, documents its object cache as a way to save trips to the database. Its default cache lasts only for the request unless a persistent cache implementation is installed. | Layer | Primary cached unit | Can bypass PHP on a hit? | Main control plane | Added service? | | --- | --- | --- | --- | --- | | Nginx FastCGI cache | Complete FastCGI response | Yes | Nginx configuration and response headers | No separate cache daemon | | Varnish | Complete HTTP response | Yes | Varnish Configuration Language and HTTP metadata | Yes | | Application/object cache | Values selected by application code | No | Application, plugin, library, and cache backend | Often | The table is architectural. It does not say which implementation will be faster for a particular site. ## When is Nginx FastCGI cache the simplest fit? Nginx's official FastCGI module includes cache-path, cache-key, bypass, no-cache, validity, locking, revalidation, and stale-response controls. It can cache responses returned by PHP-FPM and serve a valid cached response without sending that request back to PHP. That makes it a practical first response cache when: - Nginx already terminates client traffic and passes PHP to FastCGI; - most anonymous GET and HEAD responses are shared; - the team can define logged-in, personalized, preview, checkout, and administrative bypasses; - the application emits useful cache-control or expiration information; and - operations already monitor Nginx logs, storage, and configuration. The difficult part is not creating a cache directory. It is proving the key and bypass policy. Host, scheme, path, query parameters, language, device variation, authentication state, and application cookies may change the correct response. If a relevant dimension is absent from the key or bypass rules, one visitor can receive another state. Nginx documents that responses carrying `Set-Cookie` are not cached by default and that response headers such as `X-Accel-Expires`, `Expires`, and `Cache-Control` can affect caching. Treat those defaults as inputs to a design review, not as proof that every personalized response is excluded. ## When does Varnish earn the extra layer? Varnish Cache is a caching HTTP reverse proxy. It belongs between a TLS termination layer and an HTTP backend in a common deployment model. Its documentation illustrates a chain with TLS, Varnish, and backend as separate stages. Varnish becomes attractive when the team wants its HTTP-oriented policy language, cache inspection, backend selection, grace behavior, or an independent cache tier shared across backends. It may also fit an organization that already operates Varnish consistently across applications. The cost is another production service and another request boundary. The team must own: - TLS termination and the connection into Varnish; - trustworthy forwarding of client, host, and protocol information; - Varnish policy, reloads, storage, logs, and upgrades; - health checks and behavior when a backend or cache node fails; - purge or invalidation authorization; and - coordination with any Nginx or application cache behind it. Do not add Varnish merely because it is described as fast. If Nginx FastCGI cache already satisfies the response-cache policy, a second proxy may add more failure modes than value. ## When is an application or object cache the right layer? Choose an application cache when the page cannot safely be shared as one HTTP response but expensive sub-results can be reused. Common cases include authenticated dashboards, personalized pages, APIs assembled from several queries, and templates where only part of the output changes per user. The application understands the data dependency, so it can use a key tied to the relevant object, locale, permissions, version, or tenant. It can also invalidate that key when the source changes. That precision is its advantage and its responsibility. Redis is one possible backend, not a synonym for application caching. Redis documents that a cache can evict keys when memory exceeds a configured limit and that eviction policy determines which keys are removed. The application must therefore tolerate misses and rebuild values correctly. If a value cannot be regenerated from an authoritative store, it is not merely a cache entry. For WordPress, a persistent object cache requires an appropriate drop-in or plugin and backend; setting an old configuration constant alone does not make the current object cache persistent. Other PHP frameworks have their own adapters, namespaces, serialization, and invalidation behavior. Follow the exact application documentation. ## Can you combine these caching layers? Yes, when each layer has a distinct job. A public article page might be served from FastCGI cache, while misses run PHP and reuse cached database objects. A larger platform might put Varnish in front of several application backends and still use application caches for personalized work. The combination is safe only if invalidation crosses every layer. Updating content in the database may require application-object invalidation and response-cache purge. A response cache must never store logged-in, account, checkout, or permission-dependent output unless the application has deliberately designed a correct private-cache model. Write a cache map before enabling anything: 1. Name the object or response stored at each layer. 2. Define the complete cache key and all variation dimensions. 3. List requests and responses that must bypass storage and lookup. 4. Name the event that invalidates or expires each entry. 5. Define behavior on a cold cache, stale data, backend failure, and cache failure. 6. Expose a safe diagnostic that shows hit, miss, bypass, and stale states. 7. Assign the configuration, purge path, and incident response to an owner. ## How does TLS affect the decision? Nginx can terminate HTTPS and use FastCGI cache inside the same web-server layer. A Varnish design commonly terminates TLS before Varnish, then forwards HTTP plus the protocol and client context the backend requires. That makes proxy trust and header normalization explicit architecture decisions. Do not accept forwarded client or scheme headers from an untrusted source. Define which proxy is trusted to set them, replace untrusted inbound values at the boundary, and test redirect, secure-cookie, canonical-host, and client-address behavior through the full chain. An application cache is below HTTP termination. TLS does not change its cached object directly, but scheme, host, tenant, and permissions may still belong in application cache keys. ## Which cache should you choose first? Start with the measured bottleneck: - If repeated public requests spend time generating the same complete PHP response, evaluate Nginx FastCGI cache first on an existing Nginx/PHP-FPM stack. - If you need a dedicated HTTP caching tier with Varnish-specific policy or multi-backend operations, evaluate Varnish and budget for the extra proxy boundary. - If PHP must run but repeats expensive queries or computations, implement the application's supported persistent object or data cache. - If the bottleneck is an uncached database query, external API, disk operation, or PHP worker shortage, fix or isolate that cause before adding an unrelated cache. Establish a baseline, change one layer, and compare identical requests at matched load. Record correctness, hit and bypass states, PHP work, database work, errors, memory, disk, and recovery after purge or restart. A high hit ratio is not success if it serves stale or private content. For sites hosted in Nginx server blocks, document cache policy beside the site's routing ownership; the [Nginx server-block guide](/nginx-server-blocks/) explains that boundary. ## Sources - [Nginx: ngx_http_fastcgi_module](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html) - [Varnish Cache: The web-delivery architecture](https://varnish-cache.org/docs/7.0/tutorial/introduction.html) - [WordPress Developer Resources: WP_Object_Cache](https://developer.wordpress.org/reference/classes/wp_object_cache/) - [Redis Documentation: Key eviction](https://redis.io/docs/latest/develop/reference/eviction/) ## Frequently asked questions **Is Nginx FastCGI cache the same as Redis?** No. FastCGI cache stores complete responses returned by PHP-FPM and can bypass PHP on a hit. Redis is a data service that an application or cache plugin may use to store selected objects or computed values. PHP normally still runs on an object-cache hit. Choose based on the work you need to avoid, not the product name. **Do I need Varnish if Nginx caching is enabled?** Not automatically. Varnish adds an independent HTTP proxy and policy layer, plus configuration, TLS placement, forwarding, logging, health, upgrade, and purge responsibilities. If Nginx FastCGI cache already meets the site's response-cache and invalidation requirements, another proxy may not justify its operational cost. Add Varnish only for a specific capability your team will operate. **Can a site use page cache and object cache together?** Yes. The response cache can serve shared pages without PHP, while requests that reach PHP can reuse cached objects or query results. Document both layers. A content change may need to invalidate the object and every affected response, and personalized output must bypass any shared response cache unless the application implements a proven private-cache design. **How do I compare caching layers fairly?** Measure the current bottleneck, enable one layer, and repeat identical requests at matched load. Record correctness, cache hit, miss, bypass and stale states, PHP and database work, errors, resource use, and behavior after purge or restart. Do not judge by hit ratio alone: a cache that returns stale or private content is incorrect even when every request is fast. --- # Nginx Hardening Checklist for Production Servers > Nginx hardening starts with the effective configuration, not a generic snippet. Inventory package sources and modules, expose only required services, define safe behavior for unknown hosts, separate Nginx and PHP-FPM permissions, bound requests to the application's contract, maintain TLS, minimize sensitive logs, and observe rate limits before enforcing them. Validate each change and keep a tested rollback path. Exact values depend on the installed build and application. Source: https://lemp.io/nginx-hardening-checklist/ Publisher: LEMP Field Notes Published: 2026-09-07 Updated: 2026-09-07 ## What belongs on an Nginx hardening checklist? Nginx hardening is a controlled reduction of attack surface: inventory the effective configuration, expose only required services, route unknown hosts safely, separate Nginx from PHP-FPM, bound requests to what the application accepts, maintain TLS, limit sensitive logging, and verify every change with a rollback ready. This checklist uses Ubuntu 24.04 LTS as its reference platform. It contains no command blocks because the configuration was not tested on a clean Ubuntu 24.04 host. A checklist cannot know which package source, modules, application routes, proxies, upload sizes, or recovery tools exist on a particular server. Record those facts before changing anything. | Gate | Evidence to keep | Failure the gate is meant to prevent | Owner and rollback | |---|---|---|---| | Version and config inventory | Package origins, enabled modules, effective Nginx and PHP-FPM configuration | Applying advice for a different build | Named operator; last known-good config | | Network exposure | Listening services, firewall policy, administrative access route | Unnecessary public services | System owner; console or recovery access | | Host routing | Expected names and an explicit default-server policy on every public port | Unknown hosts reaching a real site | Nginx owner; prior server blocks | | Files and runtime | Public roots, owners, PHP-FPM pools, writable paths | Web processes changing code or reading another site | Application owner; permission manifest | | Request controls | Application-approved methods, body limits, and timeouts | Unbounded or invalid traffic consuming resources | Application owner; recorded prior values | | TLS and headers | Certificate scope, renewal evidence, protocol test, header report | Expiry, downgrade, or a policy that breaks the app | TLS owner; tested prior policy | | Abuse controls | Baseline traffic, trusted proxy chain, dry-run results | Blocking legitimate clients or trusting forged addresses | Operations owner; immediate disable path | | Logs and monitoring | Required fields, retention, alerts, access controls | Missing incidents or retaining secrets | Operations and privacy owners; prior format | ## Start with the effective configuration, not a sample file Record the Ubuntu release, Nginx package source and build, PHP version, PHP-FPM pools, loaded modules, active server blocks, listening addresses, certificate paths, and upstreams. Include configuration brought in through other files. A hardening change reviewed against one visible file can miss an inherited directive or a second virtual host. Nginx documents a configuration test and a separate option that dumps the complete configuration. Use the platform's approved operational procedure to obtain both syntax-test evidence and the effective configuration. This article does not publish those commands because they were not tested here as written. Treat a scanner result as an observation, not an implementation plan. Give each finding an owner, proposed control, validation test, and rollback condition. ## Harden the Ubuntu host before tuning Nginx The web server cannot compensate for an exposed administration service or an unpatched operating system. Ubuntu's current security guidance recommends updates, least-privilege accounts, a firewall, secured remote access, and AppArmor. Verify which services listen publicly, which account can administer them, and how recovery works if network access is lost. Ubuntu says `unattended-upgrades` is installed by default and security updates are enabled after installation. It also warns that adding a third-party repository does not automatically make that repository eligible for unattended upgrades. Inspect the actual allowed origins and logs instead of assuming every installed package is covered. AppArmor is installed and loaded by default on Ubuntu, but that does not prove every workload has a useful enforcing profile. Record profile state and denials. Firewall, SSH, update, and AppArmor changes require a system owner and tested recovery path. For the base stack and its package context, start with the [Ubuntu 24.04 LEMP installation guide](/install-lemp-stack-ubuntu-24-04/). ## Make host routing fail closed Nginx selects a virtual server by address, port, and request host. Its documentation states that an unmatched host is handled by the default server for that listening port. Therefore, list every public address and port, identify the default for each, and test an expected name, an unknown name, the server IP, and a request without a normal host value. The default path must be deliberate. It should not expose a production application merely because that server block appears first in a file. Keep document roots specific to one site, outside user-upload or backup directories, and confirm that directory indexing is not enabled where no listing is intended. Search public roots for environment files, archives, database exports, editor copies, source maps, and deployment artifacts before launch. The [Nginx server-block guide](/nginx-server-blocks/) explains virtual-host structure. Every public listener also needs a tested unknown-host outcome. ## Separate Nginx, PHP-FPM, code, and writable data Run application code and Nginx workers without administrative privileges. Give the web path only the access required by the application. Code and configuration normally need read access, while uploads, caches, sessions, and generated files need narrowly defined writable locations. A recursive world-writable permission change is not a hardening method. Separate applications into PHP-FPM pools when they need distinct users, limits, logs, or deployment ownership. The PHP manual documents that `clear_env` defaults to enabled, which prevents arbitrary environment variables from reaching workers unless added to the pool configuration. It also documents `security.limit_extensions`, whose default permits `.php` and `.phar`; review the effective value against the application's entry points and the Nginx-to-FPM mapping. Keep an FPM status endpoint off the public Internet. If operations needs it, restrict it to the monitoring path and authorized clients. Record socket or loopback ownership, Nginx access, pool user, application write paths, and cross-site isolation as separate checks. ## Bound requests to the application's real contract Set request-body size, accepted methods, header and body timeouts, and upstream timeouts from application requirements and observed traffic. There is no universal secure value. An upload service, an API, and a read-only documentation site have different contracts. Nginx documents a `client_max_body_size` default of `1m`; a request above the configured limit receives a 413 response, while a value of `0` disables the check. That documented default is a fact about the directive, not a recommendation. The application owner should supply the largest legitimate request, expected method set, slowest valid operation, and proxy path before an operator changes the values. Test normal requests, boundary-sized requests, invalid methods, slow upstream behavior, and the application's own error handling. A lower limit that rejects valid backups or media is an outage. A very high limit chosen to make the error disappear removes the control. ## Reduce information exposure without mistaking it for a boundary Nginx documents `server_tokens on` as the default. Turning version emission off removes the version from Nginx-generated error pages and the `Server` response header field. It does not patch the service, remove every product clue, or hide information added by an upstream application. PHP's `expose_php` setting similarly controls PHP identification in web-server headers. PHP's own security documentation describes this as a limited information-reduction measure, not a security control by itself. Verify the response at the public edge, including errors and redirects, but keep update status and exposure reduction as separate checklist lines. Public responses should avoid stack traces, local paths, queries, secrets, and internal service names. Preserve actionable detail in access-controlled logs. ## Treat TLS and response headers as application changes Maintain certificate coverage, renewal automation, private-key access, redirects, and the deployed certificate chain. Confirm renewal with the actual automation path and monitor the certificate served at the edge, not merely a file on disk. The existing [Certbot and Nginx guide](/nginx-lets-encrypt-certbot/) covers certificate installation. Do not copy a cipher list or protocol block whose origin and platform support are unknown. Use current guidance for the installed TLS library and Nginx build, then test representative clients. Do not enable HTTP Strict Transport Security until the owner understands its persistence and the effect on every covered hostname. Content Security Policy, framing controls, referrer policy, content-type handling, and permissions policy are application-dependent. Inventory scripts, styles, frames, APIs, fonts, media, and third-party origins before enforcement. Start with report-only behavior where the policy supports it, inspect violations, and obtain application-owner approval before blocking. A green header scanner does not prove the application remains usable. ## Add rate limiting only after the client identity is trustworthy Nginx's request-limiting module applies a leaky-bucket method to a configured key and supports dry-run observation. The appropriate key, rate, burst behavior, and exclusions depend on the application. No fixed value belongs in a general checklist. First establish whether Nginx sees the actual client or a reverse proxy. Trust only known proxy addresses and verify the resulting client identity in logs. Otherwise, one shared proxy address can make unrelated users look like one client, while an untrusted forwarded header can make the limit ineffective. Observe candidate rules before enforcement. Account for login endpoints, APIs, health checks, webhooks, shared networks, and legitimate bursts. Define the enforcement metric and rollback threshold. Rate limiting is not authentication or a denial-of-service guarantee. ## Keep logs useful and proportionate Nginx access logs can include status, request length, bytes sent, and request time. Choose the minimum fields required for operations and incident review. Avoid logging authorization values, session identifiers, passwords, full sensitive query strings, or request bodies. Check application and proxy logs as well; sensitive data often enters upstream of Nginx. Define ownership, permissions, rotation, retention, clock synchronization, alerting, and deletion for each log. Monitor changes in error rate, upstream failures, rejected requests, certificate expiry, disk use, restart failures, and update failures. A log file with no alert or review owner is storage, not monitoring. ## Make every production change reversible Before editing, preserve the previous configuration through the team's approved version-control or backup process and confirm how it will be restored. Make one coherent change at a time. Validate syntax and the effective configuration, exercise the affected routes in a staging or canary path, reload through the approved service procedure, then inspect process state, logs, response headers, certificates, and application transactions. Do not proceed when recovery access is uncertain. Firewall, remote-access, permission, TLS, and process-user changes can remove administrative or application access. A named operator must own the observation window and rollback decision. Record the old and new evidence, reviewer, deployment time, validation results, exceptions, and rollback outcome. Hardening is maintained state, not a one-time edit. ## Final production review - The OS, package origins, modules, effective configuration, public listeners, and owners are recorded. - Security updates and third-party repository coverage are verified from configuration and logs. - Administrative access, firewall policy, AppArmor state, recovery access, and rollback ownership are known. - Every public port has tested expected-host and unknown-host behavior. - Public roots contain no backups, secrets, exports, or unintended listings. - Nginx, PHP-FPM pools, application code, and writable data have distinct least-privilege boundaries. - Request sizes, methods, and timeouts match an application-approved contract. - TLS renewal, edge certificate, redirects, and response-header behavior are monitored. - Rate limits use a verified client identity and were observed before enforcement. - Logs omit unnecessary sensitive data and have rotation, retention, access, and alert owners. - The change has syntax, effective-config, route, application, and rollback evidence. ## Sources - [Ubuntu Server documentation: Security suggestions](https://ubuntu.com/server/docs/explanation/security/security_suggestions/) — updated June 26, 2026; supports updates, least privilege, firewall, SSH, package reduction, and AppArmor guidance. - [Ubuntu Server documentation: Automatic updates](https://ubuntu.com/server/docs/how-to/software/automatic-updates/) — accessed September 3, 2026; supports the default installation and daily security-update behavior of `unattended-upgrades`, its configuration and logs, and the warning about third-party repositories. - [Ubuntu Server documentation: AppArmor](https://ubuntu.com/server/docs/how-to/security/apparmor/) — accessed September 3, 2026; supports AppArmor's purpose and default installed-and-loaded state on Ubuntu. - [Nginx: How nginx processes a request](https://nginx.org/en/docs/http/request_processing.html) — accessed September 3, 2026; supports host selection and default-server behavior per listening port. - [Nginx HTTP core module](https://nginx.org/en/docs/http/ngx_http_core_module.html) — accessed September 3, 2026; supports the exact `server_tokens` and `client_max_body_size` behavior and defaults described here. - [Nginx request-limiting module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html) — accessed September 3, 2026; supports the leaky-bucket model and dry-run capability. - [Nginx HTTP log module](https://nginx.org/en/docs/http/ngx_http_log_module.html) — accessed September 3, 2026; supports configurable access-log formats and the named timing, size, and status fields. - [PHP manual: PHP-FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php) — accessed September 3, 2026; supports `clear_env`, `security.limit_extensions`, and FPM status-path behavior. - [PHP manual: Hiding PHP](https://www.php.net/manual/en/security.hiding.php) — accessed September 3, 2026; supports the limited purpose of reducing exposed PHP information. ## Frequently asked questions **Does hiding the Nginx version make a server secure?** No. Disabling version emission reduces information in Nginx-generated errors and the Server response header, but it does not patch Nginx, secure the operating system, or remove clues exposed by an upstream application. Track version exposure and update status as separate controls, and verify the public response path after any change. **What should client_max_body_size be in Nginx?** Set it from the largest legitimate request the application accepts, with an explicit margin and a boundary test. Nginx documents a 1m default and returns 413 above the configured value; setting it to 0 disables the check. Those are directive facts, not a universal production recommendation. Upload services and read-only sites need different limits. **Should every Nginx server use the same security headers?** No. Header policies depend on the application's scripts, styles, frames, APIs, media, and third-party origins. Inventory those dependencies, use report-only behavior where available, inspect violations, and obtain application-owner approval before enforcement. A copied Content Security Policy can break legitimate behavior while still looking strict in a configuration review. **Is rate limiting always safe to enable?** No. Verify the client address through any trusted proxy, measure normal traffic, and observe a proposed rule before enforcement. Shared networks, health checks, webhooks, APIs, and legitimate bursts can otherwise be blocked. The key, rate, burst behavior, and exceptions are application-specific; a general checklist cannot supply safe universal values. **How do I harden Nginx without locking myself out?** Confirm recovery access first, preserve the last known-good configuration, assign a rollback owner, and make one coherent change at a time. Validate syntax and the effective configuration, test affected routes, then monitor service state, logs, certificates, headers, and application transactions. Stop when rollback or console access is uncertain. **Is this a tested Nginx configuration guide?** No. It is a documentation-based audit checklist using Ubuntu 24.04 LTS as the reference platform. It intentionally contains no command blocks because the configuration was not tested on a clean Ubuntu 24.04 host. Record the installed package sources, modules, application requirements, and rollback process before translating any checkpoint into a local change. --- # How to Secure Nginx with Let's Encrypt (Certbot Tutorial) > To secure Nginx with Let's Encrypt, install Certbot (sudo snap install --classic certbot on Ubuntu), then run sudo certbot --nginx -d yourdomain.com. Certbot proves you control the domain over port 80, obtains a free browser-trusted TLS certificate, edits your Nginx server block to serve HTTPS, and sets up an HTTP-to-HTTPS redirect. Certificates last 90 days, and a bundled systemd timer renews them automatically — verify it with sudo certbot renew --dry-run. Source: https://lemp.io/nginx-lets-encrypt-certbot/ Publisher: LEMP Field Notes Published: 2026-07-19 Updated: 2026-08-15 ## How Do You Secure Nginx with Let's Encrypt? To secure Nginx with Let's Encrypt, you install Certbot, run `sudo certbot --nginx -d yourdomain.com`, and let it obtain a free, browser-trusted TLS certificate and rewrite your Nginx configuration to serve HTTPS. The certificate is valid for 90 days, and Certbot installs a timer that renews it automatically — so after one short session at the terminal, HTTPS becomes something your server maintains by itself. That is the whole arc of this tutorial. The rest of this guide walks through each step on Ubuntu, explains what Certbot actually changes on disk, and shows you how to verify that auto-renewal will keep working long after you have logged out. This guide assumes you already have Nginx running and a server block answering for your domain. If you are starting from a bare server, work through [installing the LEMP stack on Ubuntu 24.04](/install-lemp-stack-ubuntu-24-04/) first, then set up [Nginx server blocks](/nginx-server-blocks/) for your domain — Certbot reads your server block to figure out which config to edit, so having it correct beforehand makes everything smoother. ## What Let's Encrypt and Certbot Actually Do **Let's Encrypt** is a nonprofit certificate authority that issues TLS certificates at no cost. Its certificates are trusted by all mainstream browsers and are functionally equivalent to paid domain-validated (DV) certificates: same encryption, same padlock. What you give up compared to paid certs is organization validation and support contracts — nothing that matters for a typical site. **Certbot** is the official client recommended by the EFF for talking to Let's Encrypt. It speaks the ACME protocol: it proves to Let's Encrypt that you control your domain, retrieves the certificate, and — with the Nginx plugin — edits your server block to use it. The domain-validation step is worth understanding because it explains most failures: 1. Certbot asks Let's Encrypt for a challenge. 2. Let's Encrypt replies with a token that must be served at a specific URL under `http://yourdomain.com/.well-known/acme-challenge/`. 3. Certbot arranges for Nginx to serve that token, and Let's Encrypt's servers fetch it over the public internet. 4. If the token matches, validation passes and the certificate is issued. This is why your DNS must already point at the server and why port 80 must be reachable. No amount of local configuration can substitute for Let's Encrypt being able to reach your domain from outside. ## Prerequisites Before running Certbot, confirm all four of these: - **A registered domain** with an A record (and AAAA record, if you use IPv6) pointing at your server's public IP. DNS changes can take a while to propagate; verify with `dig +short yourdomain.com` before proceeding. - **Nginx installed and running**, with a server block whose `server_name` matches your domain — for example `server_name example.com www.example.com;`. - **Ports 80 and 443 open.** If you use ufw, allow the full Nginx profile: ```bash sudo ufw allow 'Nginx Full' sudo ufw delete allow 'Nginx HTTP' ``` The first command opens both 80 and 443; the second removes the now-redundant HTTP-only rule if you added it earlier. Do not close port 80 entirely — the HTTP-01 challenge above needs it, both now and at every renewal. - **Root or sudo access** on the server. ## Step 1 — Install Certbot On current Ubuntu releases, Certbot's maintainers recommend installing via snap, which stays up to date independently of Ubuntu's release cycle: ```bash sudo snap install core sudo snap refresh core sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/bin/certbot ``` The symlink just makes `certbot` available on your normal PATH. If you prefer apt — for instance, on a system where snap is unavailable — the packaged version works too, though it may lag behind: ```bash sudo apt update sudo apt install certbot python3-certbot-nginx ``` Either route gives you the `certbot` command plus the Nginx plugin. Pick one; do not install both. ## Step 2 — Obtain and Install the Certificate One command does the work: ```bash sudo certbot --nginx -d example.com -d www.example.com ``` Replace the domains with your own, and list every hostname your server block answers for — each `-d` flag adds a name to the same certificate. On first run, Certbot will prompt you for an email address (used for expiry and security notices) and ask you to agree to the Let's Encrypt terms of service. Certbot then performs the challenge, retrieves the certificate, and edits your server block. Depending on your Certbot version, it will either configure an HTTP-to-HTTPS redirect automatically or ask whether you want one — say yes. Redirecting is standard practice: it guarantees visitors and search engines land on the encrypted version of your site. When it finishes, Certbot reports where the certificate lives. The important paths: | Path | Contents | |---|---| | `/etc/letsencrypt/live/example.com/fullchain.pem` | Certificate plus intermediate chain | | `/etc/letsencrypt/live/example.com/privkey.pem` | Private key | | `/etc/letsencrypt/renewal/example.com.conf` | Renewal settings for this certificate | The files under `live/` are symlinks into an archive directory, which is how renewal swaps in new certificates without you touching Nginx config again. ## What Certbot Changed in Your Nginx Config Open your server block afterward and you will find Certbot added lines like these inside the `server` block: ```nginx listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ``` The included `options-ssl-nginx.conf` carries Certbot's maintained TLS settings — protocol versions and cipher choices — so you inherit sane, current defaults without hand-tuning them. Certbot also adds (or offers to add) a small HTTP `server` block that returns a `301` redirect to HTTPS. If you ever edit this file by hand, keep the habit from every good Nginx workflow: run `sudo nginx -t` to validate syntax, then `sudo systemctl reload nginx` to apply. A reload picks up config changes without dropping connections. ## Step 3 — Verify HTTPS Is Working Three quick checks: - **Browser:** visit `https://example.com` and confirm the padlock appears with no warnings. Then visit the plain `http://` URL and confirm it redirects. - **Command line:** `curl -I http://example.com` should return `301 Moved Permanently` with a `Location:` header pointing at the HTTPS URL. - **External scan:** a TLS testing service such as Qualys SSL Labs will grade your configuration and flag chain or protocol issues. Certbot's defaults typically score well. If the browser still warns you, the usual culprits are a cached old page, a `server_name` mismatch, or mixed content — HTTPS pages loading images or scripts over plain HTTP. Mixed content is a page problem, not a certificate problem: update those asset URLs to `https://` or protocol-relative paths. ## Step 4 — Confirm Automatic Renewal Let's Encrypt certificates last 90 days by design — short lifetimes limit the damage of a stolen key and force automation. Certbot renews any certificate that is within 30 days of expiry, and the installer sets up scheduled runs for you: the snap install uses a systemd timer (`snap.certbot.renew.timer`), and the apt package ships `certbot.timer`. You can see whichever is present with: ```bash systemctl list-timers | grep -i certbot ``` The check that actually matters is the dry run, which exercises the full renewal path against Let's Encrypt's staging environment without issuing anything: ```bash sudo certbot renew --dry-run ``` If that succeeds, real renewals will succeed under the same conditions. Run it once after setup, and again after any change to your firewall, DNS, or Nginx config. The renewal process reloads Nginx for you when a certificate is replaced, so new certificates go live without manual steps. Two things quietly break renewal more than anything else: closing port 80 ("we're HTTPS now, why keep it?" — because the challenge uses it) and deleting or renaming the server block Certbot originally modified. ## Common Errors and Fixes | Symptom | Likely cause | Fix | |---|---|---| | Challenge fails with a connection or timeout error | Port 80 blocked, or DNS not pointing at this server | Open port 80 in ufw and any cloud firewall; verify `dig +short yourdomain.com` returns your server IP | | Certbot says it can't find a matching server block | `server_name` doesn't include the domain you passed with `-d` | Add the domain to `server_name`, run `sudo nginx -t`, reload, retry | | Certificate issued but browser shows the wrong site | Request hit the default server block | Make sure your domain's server block exists and is enabled in `sites-enabled` | | "Too many certificates" or rate-limit message | Repeated failed-then-fixed attempts against production | Wait out the limit; use `--dry-run` (staging) while debugging so failures don't count against you | Let's Encrypt enforces rate limits on production issuance, which is exactly why the staging dry run exists — debug there, then issue for real once the path works. ## Wrapping Up You installed Certbot, issued a free certificate with `certbot --nginx`, confirmed the redirect, and verified renewal with a dry run. From here, the certificate maintains itself; your only ongoing job is not to break the two things renewal depends on — an open port 80 and the server block Certbot manages. HTTPS is the first hardening step for a new server, not the last. For the broader picture — firewalls, SSH keys, and permissions — browse the rest of our [Security & TLS guides](/security-and-tls/). And if you are adding more sites to this server, each new [server block](/nginx-server-blocks/) can get its own certificate with one more `certbot --nginx` run. ## Frequently asked questions **Is a Let's Encrypt certificate really free?** Yes. Let's Encrypt is a nonprofit certificate authority that issues domain-validated TLS certificates at no cost, and they are trusted by all mainstream browsers. The encryption is identical to what paid DV certificates provide. What you don't get is organization or extended validation and a paid support contract — neither of which affects security for a typical website. **How do I install Certbot on Ubuntu?** Certbot's maintainers recommend the snap package: run sudo snap install core, sudo snap refresh core, then sudo snap install --classic certbot, and symlink it with sudo ln -s /snap/bin/certbot /usr/bin/certbot. Alternatively, install from apt with sudo apt install certbot python3-certbot-nginx, though the apt version can lag behind the snap. Use one method, not both. **How does Let's Encrypt auto renewal work with Nginx?** Let's Encrypt certificates are valid for 90 days, and Certbot renews any certificate within 30 days of expiry. The installer sets up a scheduled task automatically — a systemd timer for both the snap and apt installs — so no cron job is needed. When a certificate renews, Certbot reloads Nginx to pick it up. Test the whole path safely with sudo certbot renew --dry-run. **Do I still need port 80 open after enabling HTTPS?** Yes, keep port 80 open. Let's Encrypt's HTTP-01 challenge validates your domain by fetching a token over plain HTTP, and that validation repeats at every renewal — closing port 80 is one of the most common causes of silent renewal failure. Port 80 should serve only a 301 redirect to HTTPS, which Certbot configures for you. **Can one certificate cover multiple domains or subdomains?** Yes. Pass each hostname with its own -d flag — for example sudo certbot --nginx -d example.com -d www.example.com — and Certbot puts all the names on a single certificate. Every name must resolve to your server and appear in a server_name directive so validation can succeed. Wildcard certificates (*.example.com) are also available but require the DNS-01 challenge instead of the Nginx plugin's HTTP challenge. **Why did my Certbot challenge fail?** The usual causes: DNS doesn't point at the server yet (check with dig +short yourdomain.com), port 80 is blocked by ufw or a cloud firewall, or your server block's server_name doesn't include the domain you requested. Fix the issue, then retry with sudo certbot renew --dry-run or a fresh issuance. Debug against the staging environment via --dry-run so failed attempts don't count toward Let's Encrypt's production rate limits. --- # Nginx Server Blocks: Host Multiple Sites on One Server > Nginx server blocks are configuration sections — each a server { } block — that let one Nginx instance host multiple websites on a single server. Each block declares the port it listens on, the domain names it answers to via server_name, and the document root for that site. Nginx matches each incoming request's Host header against every block's server_name to route it. On Ubuntu, blocks live in /etc/nginx/sites-available/ and are activated by symlinking them into /etc/nginx/sites-enabled/. Source: https://lemp.io/nginx-server-blocks/ Publisher: LEMP Field Notes Published: 2026-07-19 Updated: 2026-08-15 ## What Are Nginx Server Blocks? Nginx server blocks are configuration sections that let a single Nginx instance serve multiple websites from one server. Each `server { }` block defines one site: which port it listens on, which domain names it answers to (`server_name`), and where its files live (`root`). When a request arrives, Nginx reads the `Host` header, matches it against the `server_name` of every block listening on that port, and hands the request to the winner. If you're coming from Apache, server blocks are the direct equivalent of virtual hosts — same idea, different syntax. This guide walks through creating a server block from scratch on Ubuntu, enabling it the standard way with `sites-available` and `sites-enabled`, and verifying it works — then repeating the pattern for as many sites as your server can handle. ## Prerequisites Before you start, you'll need: - A server running Nginx. If you don't have one yet, our guide to [installing a LEMP stack on Ubuntu 24.04](/install-lemp-stack-ubuntu-24-04/) gets you from bare server to working stack. - A domain name with an A record (and ideally an AAAA record) pointing at your server's IP. For testing without a domain, you can fake it with your local `hosts` file — covered below. - A non-root user with `sudo` privileges. We'll use `example.com` throughout. Substitute your own domain everywhere it appears. ## How sites-available and sites-enabled Work On Debian and Ubuntu, the Nginx package ships two directories for site configs: | Directory | Purpose | |---|---| | `/etc/nginx/sites-available/` | One file per site. Every config you've written lives here, active or not. | | `/etc/nginx/sites-enabled/` | Symlinks to files in `sites-available`. Only linked configs are loaded. | The main config file, `/etc/nginx/nginx.conf`, contains the line `include /etc/nginx/sites-enabled/*;` — so Nginx only ever reads what's linked into `sites-enabled`. The payoff is operational: to take a site offline, you delete its symlink and reload Nginx. The config itself stays safely in `sites-available`, ready to re-enable with one command. No editing, no commenting-out, no risk of losing work. Two things worth knowing: - **This layout is a Debian/Ubuntu convention, not an Nginx feature.** Nginx builds from other sources (or on RHEL-family distros) typically use `/etc/nginx/conf.d/*.conf` instead. Both work; use whichever your distribution set up. - **A fresh Ubuntu install ships a `default` site** in `sites-enabled` that serves the "Welcome to nginx" page. We'll deal with it shortly. ## Step 1 — Create the Web Root Each site gets its own document root. The conventional location is a per-domain directory under `/var/www`: ```bash sudo mkdir -p /var/www/example.com/html ``` Give your own user ownership so you can deploy files without `sudo`: ```bash sudo chown -R $USER:$USER /var/www/example.com/html ``` Then make sure permissions allow Nginx (which runs as the `www-data` user on Ubuntu) to read the files: ```bash sudo chmod -R 755 /var/www/example.com ``` Create a placeholder page so you can confirm the block works: ```bash nano /var/www/example.com/html/index.html ``` Paste something identifiable: ```html example.com works

Server block for example.com is live.

``` ## Step 2 — Write the Server Block Create a new config file named after the domain: ```bash sudo nano /etc/nginx/sites-available/example.com ``` Here's a complete, working server block for a static site: ```nginx server { listen 80; listen [::]:80; server_name example.com www.example.com; root /var/www/example.com/html; index index.html index.htm; location / { try_files $uri $uri/ =404; } } ``` Line by line: - **`listen 80;` and `listen [::]:80;`** — accept HTTP connections on port 80, over IPv4 and IPv6 respectively. - **`server_name example.com www.example.com;`** — the domains this block answers to. Requests whose `Host` header matches either name land here. - **`root`** — the directory Nginx serves files from for this site. - **`index`** — which file to serve when a request hits a directory. - **`try_files $uri $uri/ =404;`** — try the exact file, then a directory of that name, then return 404. This is the safe default for static content. If the site runs PHP, add a PHP location block that hands `.php` requests to PHP-FPM: ```nginx location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } ``` The socket path must match your installed PHP version — on Ubuntu 24.04 that's PHP 8.3 by default; run `ls /run/php/` to confirm what's actually there. ## Step 3 — Enable the Site Link the config into `sites-enabled`: ```bash sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ ``` Now — always — test the configuration before touching the running service: ```bash sudo nginx -t ``` You want to see `syntax is ok` and `test is successful`. If you get an error instead, the message includes the file and line number; fix it and re-test. Nothing has broken yet, because the running Nginx is still using the old config. Once the test passes, reload: ```bash sudo systemctl reload nginx ``` `reload` tells Nginx to re-read its config and swap in new worker processes without dropping existing connections — safer than `restart` on a live server. Visit `http://example.com` in a browser. You should see your placeholder page. **No public domain yet?** Add a line to the `hosts` file on your *local* machine (`/etc/hosts` on Linux/macOS, `C:\Windows\System32\drivers\etc\hosts` on Windows): ``` 203.0.113.10 example.com www.example.com ``` Replace `203.0.113.10` with your server's real IP. Your browser will then resolve the domain to your server, letting you test name-based hosting before DNS exists. Remove the line when you're done. ## Adding More Sites This is where server blocks earn their keep. To host a second site, repeat the pattern with a new domain: 1. `sudo mkdir -p /var/www/seconddomain.com/html` and set ownership. 2. Create `/etc/nginx/sites-available/seconddomain.com` with its own `server_name` and `root`. 3. Symlink it into `sites-enabled`, run `sudo nginx -t`, then reload. Both sites now share port 80 on the same IP. Nginx tells them apart purely by the `Host` header each browser sends — this is name-based virtual hosting, and it's how one modest VPS can serve dozens of low-traffic sites. ### How Nginx Picks a Block When several blocks listen on the same port, Nginx selects one in this order: 1. Exact `server_name` match. 2. Longest wildcard name starting with `*` (e.g. `*.example.com`). 3. Longest wildcard name ending with `*` (e.g. `mail.*`). 4. First matching regular-expression name, in config-file order. 5. If nothing matches: the block marked `default_server`, or failing that, the first block defined for that port. ### Set an Explicit Catch-All Requests that match no `server_name` — bots probing your bare IP, typo domains pointed at you — fall through to the default. Rather than let one of your real sites absorb that traffic, define an explicit catch-all: ```nginx server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; } ``` `444` is a non-standard Nginx status that closes the connection without a response — a clean way to drop junk traffic. Ubuntu's stock `default` site already claims `default_server`, and only one block per port may hold it, so either edit that file or remove its symlink (`sudo rm /etc/nginx/sites-enabled/default`) before adding your own catch-all. Then `nginx -t` and reload as always. ## Troubleshooting Common Errors - **"conflicting server name ... ignored"** — two enabled blocks claim the same `server_name` on the same port. Nginx uses the first and warns about the rest. Find the duplicate with `grep -R "server_name" /etc/nginx/sites-enabled/`. - **"a duplicate default server"** — two blocks declare `default_server` on one port. Remove the flag from one of them (the stock `default` site is the usual culprit). - **"could not build server_names_hash"** — you have many (or unusually long) domain names. Raise the bucket size in the `http` block of `/etc/nginx/nginx.conf`: `server_names_hash_bucket_size 64;`. - **Wrong site loads** — usually DNS hasn't propagated, or your `Host` header doesn't match any `server_name`, so the request fell to the default block. Test precisely with `curl -H "Host: example.com" http://your-server-ip/`. - **403 Forbidden** — Nginx can't read the web root. Check that every directory in the path is executable by `www-data` and files are readable (the `755`/`644` pattern from Step 1 handles this). ## Next Step: Add HTTPS A server block on port 80 is only half a site in 2026 — browsers flag plain HTTP, and search engines expect encryption. The good news: Certbot reads your `server_name` directives and configures TLS per site automatically, adding the `listen 443 ssl` configuration for you. Our [Let's Encrypt and Certbot tutorial](/nginx-lets-encrypt-certbot/) walks through issuing free certificates and setting up automatic renewal for every server block you've created here. From there, the pattern is set: one config file per site, `nginx -t` before every reload, and a server that scales to as many domains as you care to point at it. For more line-by-line Nginx configuration guides, browse our [Nginx & PHP](/nginx-and-php/) collection. ## Frequently asked questions **What is the difference between sites-available and sites-enabled in Nginx?** sites-available holds one config file per site, whether or not that site is live. sites-enabled holds symlinks pointing to files in sites-available, and Nginx's main config only loads what's in sites-enabled. To activate a site you create a symlink with ln -s; to deactivate it you delete the symlink and reload Nginx. The original config stays untouched in sites-available, so re-enabling later takes one command. This two-directory layout is a Debian/Ubuntu packaging convention, not a core Nginx feature. **Are Nginx server blocks the same as Apache virtual hosts?** Functionally, yes. Both let one web server host multiple sites on a single machine by inspecting the Host header of each request. Apache calls them virtual hosts and configures them with VirtualHost sections; Nginx calls them server blocks and uses server { } sections with a server_name directive. The concept and the DNS setup are identical — only the configuration syntax and file locations differ. If a tutorial mentions an Nginx virtual host, it means a server block. **How many websites can one Nginx server host?** There is no fixed limit in Nginx itself — you can define as many server blocks as you like, and one modest VPS commonly serves dozens of low-traffic sites. The practical ceiling is your server's resources: RAM, CPU, and (for dynamic sites) PHP-FPM capacity and database load. If you host many domains or very long domain names, you may need to raise server_names_hash_bucket_size in nginx.conf so Nginx can build its name-lookup table. **How does Nginx decide which server block handles a request?** Nginx first narrows to the blocks listening on the matching IP and port, then compares the request's Host header against each block's server_name in a fixed order: exact match first, then the longest wildcard starting with an asterisk, then the longest wildcard ending with one, then the first matching regular expression. If nothing matches, the request goes to the block marked default_server on that port — or, absent that flag, to the first block defined for it. **Why is my Nginx server block showing the wrong website?** Almost always, the request is falling through to the default server block because no server_name matched. Common causes: DNS hasn't propagated yet, a typo in the server_name directive, the symlink in sites-enabled was never created, or Nginx wasn't reloaded after the change. Test precisely with curl -H "Host: example.com" http://your-server-ip/ to bypass DNS, and run nginx -t to surface warnings like conflicting server names, which mean two blocks claim the same domain. **Do I need a separate server block for HTTPS?** Each site typically ends up with configuration listening on both port 80 and port 443, and the common pattern is a small port-80 block that redirects to HTTPS plus a main block with listen 443 ssl and the certificate paths. In practice you rarely write this by hand: Certbot's Nginx plugin reads your existing server_name directives, obtains a certificate, and edits the block to add the TLS configuration and redirect for you. --- # Nginx vs Apache: Performance, Config, and When to Use Each > Nginx and Apache are both mature, production-grade web servers; the core difference is architecture. Nginx uses an event-driven model where a few worker processes handle thousands of connections, making it faster and leaner for static files, high concurrency, and reverse proxying. Apache assigns processes or threads per connection and offers per-directory .htaccess overrides plus a deep module ecosystem. For PHP sites using PHP-FPM, real-world performance is similar; choose Nginx for efficiency, Apache for .htaccess flexibility. Source: https://lemp.io/nginx-vs-apache/ Publisher: LEMP Field Notes Published: 2026-07-19 Updated: 2026-08-15 ## Nginx vs Apache: What's the Real Difference? Nginx and Apache are the two web servers you're most likely to meet on a Linux box, and the honest answer is that both are excellent. The real difference is architectural: **Nginx uses an event-driven model** that handles many connections per worker process, while **Apache traditionally dedicates a process or thread to each connection**. That makes Nginx leaner under heavy concurrency and a natural fit as a reverse proxy, while Apache offers per-directory `.htaccess` overrides and a deep module ecosystem that shared hosts and legacy apps depend on. Neither choice will sink your project. But the differences in performance behavior, configuration style, and operational habits are real, and they matter more as your traffic grows. This guide walks through each one so you can pick deliberately rather than by default. ## A Quick History (and Why It Explains Everything) Apache HTTP Server dates back to 1995 and powered a huge share of the early web. It was built in an era when a server handling a few hundred simultaneous connections was doing well, so its design — spawn a process or thread per connection — was perfectly reasonable. Nginx (pronounced "engine-x") was released in 2004 by Igor Sysoev, written specifically to address the **C10K problem**: how do you serve ten thousand concurrent connections on one machine? Its answer was an asynchronous, event-driven core where a small, fixed number of worker processes each juggle thousands of connections using non-blocking I/O. That origin story explains almost every practical difference between the two. Apache optimizes for flexibility and per-site customization; Nginx optimizes for predictable resource use at scale. ## How Each Server Handles Requests ### Apache: processes, threads, and MPMs Apache's connection handling is pluggable through **Multi-Processing Modules (MPMs)**: - **prefork** — one process per connection. No threads, so it's safe with non-thread-safe modules like classic `mod_php`, but memory use climbs steeply with traffic. - **worker** — multiple threads per process. Lighter than prefork, still one thread per connection. - **event** — the default on modern distributions when `mod_php` isn't installed. It keeps threads free during keep-alive waits, which closes much of the gap with Nginx. The catch: if you install `mod_php`, Apache typically falls back to prefork, the heaviest option. Running PHP-FPM behind Apache via `mod_proxy_fcgi` avoids this and lets you keep the event MPM. ### Nginx: the event loop Nginx starts a master process plus a small number of worker processes — commonly one per CPU core. Each worker runs an event loop that services thousands of connections without allocating a thread to each one. Memory use stays low and roughly flat as connection counts rise, which is why Nginx became the go-to choice for busy sites, reverse proxies, and load balancers. The trade-off is that Nginx workers must never block. That's why Nginx doesn't embed language runtimes the way `mod_php` embeds PHP: dynamic requests are handed to a separate backend, usually **PHP-FPM over FastCGI** in a LEMP stack. ## Nginx vs Apache Performance ### Static content For static files — images, CSS, JavaScript, downloads — Nginx generally serves more requests per second with less memory, and the advantage widens as concurrency climbs. Apache with the event MPM performs respectably, but Apache with prefork under high concurrency can exhaust RAM and start swapping, which is the failure mode behind many "server died under load" stories. We won't quote specific benchmark numbers here because they vary enormously with hardware, kernel tuning, file sizes, and connection patterns. The honest general finding, consistent across years of independent testing, is: **Nginx holds a clear edge on static content at high concurrency; at low traffic the difference is negligible.** ### Dynamic content (PHP) For PHP applications the picture flattens, because the web server isn't doing the heavy work — PHP is. Whether the request path is Nginx → PHP-FPM or Apache → PHP-FPM, the time is dominated by PHP execution and database queries. A well-configured Apache event + PHP-FPM setup and a Nginx + PHP-FPM setup will feel very similar for typical dynamic workloads. Where Nginx still pulls ahead is everything *around* the PHP request: serving the page's static assets, holding thousands of keep-alive connections cheaply, and acting as a caching or proxy layer. ## Configuration: Two Different Philosophies ### Central config vs .htaccess This is the difference you'll feel daily. **Apache** allows per-directory configuration through `.htaccess` files. Any directory can carry its own rewrite rules, access controls, and overrides, applied without touching the main config or reloading the server. It's why shared hosting standardized on Apache: customers can self-serve rules without root access. The cost is performance and debuggability — Apache must check for `.htaccess` files along the filesystem path on requests, and rules scattered across directories are harder to audit. **Nginx** has no `.htaccess` equivalent, deliberately. All configuration lives centrally (on Debian/Ubuntu, under `/etc/nginx/`, with per-site files in `sites-available/`), and changes take effect only when you test with `nginx -t` and reload. That's more disciplined and faster at runtime, but it means every change requires shell access. ### What the configs look like A minimal Apache virtual host: ```apache ServerName example.com DocumentRoot /var/www/example.com ``` The equivalent Nginx server block, with a PHP handoff included: ```nginx server { listen 80; server_name example.com; root /var/www/example.com; index index.php index.html; location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } } ``` (The socket path above matches PHP 8.3 on Ubuntu 24.04 — adjust it to your installed PHP version.) If the Nginx style appeals to you, our guide to [Nginx server blocks](/nginx-server-blocks/) walks through hosting multiple sites this way, line by line. ### At a glance | | Nginx | Apache | |---|---|---| | Architecture | Event-driven, async workers | Process/thread per connection (MPM-dependent) | | Static file performance | Excellent, low memory at high concurrency | Good with event MPM; weak with prefork | | PHP handling | External PHP-FPM via FastCGI | Embedded `mod_php` or external PHP-FPM | | Per-directory config | No — central config only | Yes — `.htaccess` | | Config style | Declarative blocks, must reload | Directives + runtime overrides | | Typical extra roles | Reverse proxy, load balancer, cache | Broad module ecosystem | | Best known for | Concurrency and efficiency | Flexibility and compatibility | ## Apache or Nginx for WordPress? WordPress runs well on both, and the WordPress ecosystem supports both officially. The practical considerations: - **On shared hosting**, you rarely get a choice — it's usually Apache, and WordPress's pretty permalinks work out of the box via the `.htaccess` file WordPress writes itself. - **On your own VPS**, Nginx + PHP-FPM is a very common and well-documented pairing. Permalinks need one line — the `try_files $uri $uri/ /index.php?$args;` shown above — instead of `.htaccess` rewrites. - **Plugins that write `.htaccess` rules** (some caching and security plugins) need manual translation to Nginx directives. Most major plugins document their Nginx equivalents. If you're building a fresh WordPress server today and are comfortable editing config files over SSH, Nginx is the mainstream recommendation for its efficiency and simpler request path. If you depend on `.htaccess`-driven workflows or tooling, Apache remains a fully legitimate choice. ## When to Use Apache Choose Apache when: - **You're on shared hosting or building for it** — `.htaccess` support is non-negotiable there. - **Users need per-directory self-service** without root access. - **A legacy app documents only Apache**, with rewrite rules and module dependencies you'd otherwise have to port. - **You need a specific Apache module** with no mature Nginx equivalent. ## When to Use Nginx Choose Nginx when: - **You're building a LEMP stack** — it's the "E" in the acronym, and the ecosystem of tutorials and configs is deep. Our walkthrough shows how to [install a full LEMP stack on Ubuntu 24.04](/install-lemp-stack-ubuntu-24-04/) from a bare server. - **You expect high concurrency** — many simultaneous visitors, long-lived connections, or lots of static assets. - **You want one tool for serving, proxying, and load balancing** — Nginx does all three natively. - **You're on a small VPS** where predictable, low memory use is the difference between stable and swapping. ## Can You Use Both Together? Yes, and it's a classic pattern: **Nginx in front, Apache behind**. Nginx faces the internet, serves static files directly, terminates TLS, and proxies dynamic requests to Apache listening on a local port. You get Nginx's concurrency at the edge and keep `.htaccess` compatibility for the application. It's a genuinely useful migration path for legacy Apache apps. For a new build, though, it's usually unnecessary complexity — Nginx + PHP-FPM covers the same ground with one less moving part. ## The Bottom Line Is Nginx better than Apache? For high-concurrency serving, static content, and reverse proxying — yes, measurably. For per-directory flexibility, shared-hosting compatibility, and legacy module support — Apache still wins. For a typical PHP site with both servers configured well, the performance difference is smaller than most comparisons imply, because PHP-FPM does the heavy lifting either way. Our default recommendation for a new self-managed server is Nginx: central config that's easy to audit, excellent efficiency, and a huge tutorial ecosystem — including the rest of our [stack guides](/stack-guides/). Whichever you choose, configure it deliberately, test changes before reloading, and you'll be ahead of most of the internet. ## Frequently asked questions **Is Nginx faster than Apache?** For static content at high concurrency, yes — Nginx's event-driven workers serve more requests with less memory, and the gap widens as simultaneous connections grow. For dynamic PHP content the difference largely disappears, because PHP-FPM and the database dominate response time regardless of which server sits in front. Apache's event MPM also narrows the static-file gap considerably compared with its older prefork mode. **Should I use Nginx or Apache for WordPress?** Both run WordPress well. On shared hosting you'll usually get Apache, where WordPress manages its own .htaccess rewrites automatically. On a self-managed VPS, Nginx with PHP-FPM is the mainstream choice: permalinks need only a single try_files directive, and most major plugins document Nginx equivalents for any .htaccess rules they'd otherwise write. Pick Apache if your workflow or tooling depends on .htaccess files. **What is the main difference between Nginx and Apache?** Architecture. Apache traditionally dedicates a process or thread to each connection, configurable through its MPMs (prefork, worker, event). Nginx runs a small, fixed set of worker processes, each handling thousands of connections through a non-blocking event loop. This makes Nginx's memory use low and predictable under load, while Apache trades some efficiency for flexibility, including per-directory .htaccess configuration that Nginx deliberately omits. **Does Nginx support .htaccess files?** No. Nginx has no .htaccess equivalent by design: all configuration lives in central files (under /etc/nginx/ on Debian and Ubuntu), and changes apply only after you test with nginx -t and reload. This improves runtime performance and makes configuration easier to audit, but it means every change requires shell access. Rules from an Apache .htaccess file must be translated into Nginx server-block directives. **Can I run Nginx and Apache together?** Yes — a classic setup puts Nginx in front as a reverse proxy while Apache runs on a local port behind it. Nginx terminates TLS, serves static files, and absorbs high concurrency; Apache handles dynamic requests and keeps .htaccess compatibility. It's a practical migration path for legacy Apache applications, though for new builds a single Nginx + PHP-FPM stack is usually simpler and sufficient. **Why does the LEMP stack use Nginx instead of Apache?** The E in LEMP stands for Nginx (pronounced engine-x). The stack pairs Nginx with PHP-FPM over FastCGI, which suits Nginx's non-blocking architecture: the web server never embeds a language runtime, so workers stay light while PHP runs in its own managed pool. The result is efficient memory use on small servers and clean separation between web serving and application execution. --- # PHP-FPM Tuning for Nginx: Pools and Worker Limits > PHP-FPM tuning starts with measurements, not a copied pm.max_children value. Choose static, dynamic, or ondemand for the traffic pattern; reserve memory for the operating system, Nginx, database, and caches; divide the remaining FPM budget by a conservative measured worker footprint; then validate against queue depth, max-children events, latency, CPU, and swap. Keep each pool's listen endpoint aligned with Nginx and restrict any status endpoint to internal access. Source: https://lemp.io/php-fpm-tuning-nginx/ Publisher: LEMP Field Notes Published: 2026-09-06 Updated: 2026-09-07 ## How should you tune PHP-FPM for Nginx? PHP-FPM tuning starts with measurements, not a copied `pm.max_children` value. Choose `static`, `dynamic`, or `ondemand` for the traffic pattern; reserve memory for the operating system, Nginx, the database, and caches; divide the remaining FPM budget by a conservative measured worker footprint; then validate the result against queue depth, max-children events, latency, CPU, and swap. Keep each pool's `listen` endpoint aligned with Nginx, and restrict any status endpoint to internal access. This guide is documentation-based. Its directives were checked against the current PHP and Nginx manuals and Ubuntu 24.04 package records, but the examples were not executed on a clean Ubuntu test host. It therefore contains no copy-and-paste command blocks. Verify the installed versions, packaged configuration, application behavior, and service procedure on the server before changing a live pool. The reference platform is Ubuntu 24.04 LTS (Noble), whose distribution packages use the PHP 8.3 and Nginx 1.24 series. Security-update suffixes change over time, so the major and minor series are the useful scope here. The Ubuntu package file list places FPM's main configuration at `/etc/php/8.3/fpm/php-fpm.conf` and its packaged pool configuration at `/etc/php/8.3/fpm/pool.d/www.conf`. A third-party PHP repository or another Ubuntu release may use different versions and paths. ## First identify the constraint More workers do not make one slow PHP request execute faster. They allow more PHP requests to run concurrently. Raising the ceiling can help when requests are waiting for a free worker and the host has spare resources. It can make the outage worse when the real limit is memory, CPU, database connections, storage latency, or an external API. Establish a baseline before editing anything. Record the pool's active and idle processes, listen queue, max-children events, slow-request count, response latency, host memory, swap activity, CPU utilization, and database pressure during representative traffic. Also record the current configuration and the installed package versions. A change without a baseline is just a new configuration with better self-esteem. The joke stops there. On a production host, memory exhaustion and excessive process counts can terminate services or make the machine unresponsive. Change one variable at a time and keep a tested rollback path. ## What do static, dynamic, and ondemand mean? The [PHP-FPM configuration manual](https://www.php.net/manual/en/install.fpm.configuration.php) defines three process-manager modes. Their mechanics are exact; the workload choices below are operational judgments. | Mode | What PHP documents | Practical trade-off | |---|---|---| | `static` | FPM keeps a fixed number of children equal to `pm.max_children` | Predictable process count, but all workers occupy resources even when traffic is quiet | | `dynamic` | FPM changes the child count using `pm.max_children`, `pm.start_servers`, `pm.min_spare_servers`, and `pm.max_spare_servers` | A reasonable starting mode for sustained traffic that rises and falls | | `ondemand` | FPM spawns children when requests arrive and can remove them after `pm.process_idle_timeout` | Reduces idle workers on quiet pools, at the cost of process startup after idle periods | `pm.max_children` is mandatory in every mode. With `static`, it is the number of children created. With `dynamic` and `ondemand`, it is the maximum. PHP describes it as the limit on simultaneous requests served by that pool. For `dynamic`, the spare-server directives are not decorative. `pm.min_spare_servers` and `pm.max_spare_servers` are mandatory, and `pm.start_servers` applies only at startup. PHP documents the default start value as the average of the two spare-server settings. For `ondemand`, PHP documents a `pm.process_idle_timeout` default of 10 seconds. These are upstream PHP defaults; the effective Ubuntu pool file may override them. ## How do you size pm.max_children? Do not divide total RAM by PHP's `memory_limit`. PHP defines `memory_limit` as the maximum memory a script may allocate. It is not a documented measurement of a worker's resident footprint, and it says nothing about the memory already required by the operating system, Nginx, the database, caches, monitoring agents, or other pools. Use a measured budget instead: 1. Observe non-FPM memory use during representative peak traffic. 2. Leave operating headroom rather than assigning every remaining byte. 3. Measure worker memory while representative routes, plugins, and data sets are active. 4. Use a conservative worker figure, not the smallest idle process you can find. 5. Calculate `floor(FPM memory budget / measured worker footprint)` as a memory ceiling. 6. Reduce that ceiling if CPU, database connections, storage, or a downstream service becomes the earlier constraint. The result is a starting limit, not a certificate of capacity. Validate it under representative traffic. A growing listen queue and a nonzero `max children reached` counter show that the pool has reached its concurrency limit, but they do not prove that raising the limit is safe. If CPU is saturated or the database is already queuing work, more PHP children add contenders rather than capacity. With several pools, perform the budget across the host, not once per site. Ten pools cannot each spend the same unallocated RAM. PHP also documents the global `process.max` directive for controlling the total number of FPM processes when many dynamic pools are used. Its documented default is `0`, and the manual says to use it with caution. Do not introduce it merely to compensate for pool limits that were never added together. ## When should you use separate pools? A separate pool can give a site its own Unix user, group, endpoint, process-manager policy, worker ceiling, logs, and status counters. That makes a noisy site easier to observe and prevents it from consuming another pool's reserved workers. The boundary is only as strong as its operating-system permissions. A separate pool is not a container. If two pool users can read the same application secrets or writable files, the names in square brackets do not create isolation. Each pool needs a unique `listen` address. For a Unix socket, set ownership and group so the Nginx worker can connect without making the socket world-writable. PHP documents an upstream Unix-socket mode default of `0660`, but packaged and local settings can differ. Read the effective pool configuration rather than copying that value as an Ubuntu guarantee. Separate pools also carry an idle-memory cost under `static` or `dynamic`. `ondemand` can reduce that cost for a rarely used site, but the first request after an idle period may wait for a child to start. Measure the trade-off on the actual application. ## Which status signals matter? PHP's [FPM status page](https://www.php.net/manual/en/fpm.status.php) exposes pool-specific evidence: current and maximum listen queue, idle and active processes, total processes, maximum active processes, `max children reached`, and slow requests. These values reset when FPM restarts, so capture them in monitoring if historical trends matter. The status page is sensitive. Full output can reveal request URLs, script paths, users, resource use, and other operational details. PHP explicitly says to restrict it to internal requests or known client IP addresses. Do not expose it as an ordinary public site path, rely on an obscure URL, or let a public server block inherit access to it accidentally. `pm.status_path` enables the status URI and defaults to unset. PHP 8.3 also supports `pm.status_listen`, which creates a separate status listener that can answer while the application pool is occupied by long requests. That improves observability during saturation; it does not remove the need for an internal-only access control. Use the counters together. A temporary busy pool with no queue may be healthy. A sustained queue, repeated max-children events, and rising latency indicate constrained capacity. A low active-process count with slow responses points elsewhere, often to application, database, storage, DNS, or downstream-service latency. ## What should the slow log tell you? `request_slowlog_timeout` tells FPM when to write a PHP backtrace to the pool's `slowlog` file. PHP documents its default as `0`, meaning off. The slow log helps identify which code path occupied a worker; it is not a request killer. Do not confuse it with `request_terminate_timeout`. That separate directive kills a worker after its threshold, and PHP also documents it as off by default. A termination setting can interrupt legitimate imports, reports, uploads, or shutdown work. Set neither timeout from a generic article number. Start from the application's legitimate request profile, enable observation first, and decide with the application owner what may safely be terminated. `pm.max_requests` controls how many requests a child executes before it respawns. PHP documents a default of `0`, meaning endless request processing, and says a finite value can work around memory leaks in third-party libraries. It is not a general speed control. Consider it when measured worker memory grows with request count and recycling demonstrably resets that growth. The correct long-term fix remains the leaking extension or application code. ## How should Nginx connect to the pool? FPM's `listen` and Nginx's `fastcgi_pass` must describe the same endpoint. The [Nginx FastCGI module documentation](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html) permits an IP address and port or a Unix-domain socket. Our [Ubuntu 24.04 LEMP installation guide](/install-lemp-stack-ubuntu-24-04/) shows the baseline stack, and the [Nginx server-block guide](/nginx-server-blocks/) explains where the PHP location belongs. For Nginx and FPM on the same host, PHP's current manual says a Unix socket should be preferred. It warns that an exposed FastCGI endpoint allows arbitrary code execution. If TCP is required, `listen.allowed_clients` applies only to TCP listeners; PHP documents it as unset by default, which accepts any address. Do not publish an FPM port to the internet or assume a host firewall is a substitute for a correctly scoped listener. Do not treat Nginx timeout increases as PHP-FPM tuning. Nginx documents `fastcgi_read_timeout` as 60 seconds by default and measures it between successive reads, not across the whole response. Raising it may allow a slow request to occupy a PHP worker for longer. Find the reason for the delay before changing the timeout. ## Apply changes without guessing Retain the current configuration, change one pool variable, validate both FPM and Nginx configuration with the tools installed on that host, and use the service unit's supported reload or restart procedure during a monitored window. This article does not provide those commands because they were not tested here on a clean Ubuntu 24.04 host. After the change, compare the same baseline signals. Keep it only if queueing or latency improves without unsafe memory, swap, CPU, database, or error behavior. If the pool is still slow with spare workers, stop tuning the process count and profile the work inside the request. That is the unglamorous answer to PHP-FPM tuning: measure the queue, measure the workers, respect every other service on the host, and make the smallest change the evidence supports. ## Sources - [PHP Manual, FPM Configuration](https://www.php.net/manual/en/install.fpm.configuration.php) — accessed September 3, 2026; process-manager semantics, pool directives, documented defaults, slow logging, socket permissions, and FastCGI listener warnings. - [PHP Manual, FPM Status Page](https://www.php.net/manual/en/fpm.status.php) — accessed September 3, 2026; status fields, reset scope, separate status listener, and the internal-access warning. - [PHP Manual, Core `memory_limit` Directive](https://www.php.net/manual/en/ini.core.php#ini.memory-limit) — accessed September 3, 2026; the per-script allocation scope of `memory_limit`. - [Nginx, FastCGI Module](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html) — accessed September 3, 2026; `fastcgi_pass` endpoint forms and FastCGI timeout semantics. - [Nginx, Controlling Nginx](https://nginx.org/en/docs/control.html) — accessed September 3, 2026; configuration validation, rollback, and worker behavior during reload. - [Ubuntu, `php8.3-fpm` Package](https://packages.ubuntu.com/noble/php8.3-fpm) and [package file list](https://packages.ubuntu.com/noble/amd64/php8.3-fpm/filelist) — accessed September 3, 2026; Noble's PHP-FPM series and packaged configuration paths. - [Ubuntu, Nginx Package](https://packages.ubuntu.com/noble/nginx) and [Ubuntu 24.04 release notes](https://documentation.ubuntu.com/release-notes/24.04/) — accessed September 3, 2026; Noble's Nginx 1.24 series. ## Frequently asked questions **What does pm.max_children control?** pm.max_children limits the simultaneous requests a PHP-FPM pool can serve. In static mode it is the fixed child count; in dynamic and ondemand modes it is the maximum. Raising it can reduce queueing only when the host and its dependencies have capacity. It does not make an individual PHP request execute faster. **How do I calculate pm.max_children?** Measure non-FPM memory during representative peak traffic, leave operating headroom, and measure a conservative PHP worker footprint under real application routes. Divide the memory budget available to FPM by that worker figure, round down, and then reduce the ceiling if CPU, database connections, storage, or downstream services impose a lower limit. Do not use PHP memory_limit as worker size. **Should PHP-FPM use static, dynamic, or ondemand?** Use the traffic pattern and resource budget. Static keeps a fixed worker count. Dynamic maintains a changing pool between configured spare-process and maximum limits. Ondemand starts workers when requests arrive and removes idle workers after its timeout. Dynamic is often a practical baseline for sustained variable traffic; ondemand can suit quiet pools. Measure startup latency and memory on the actual application. **Should I set pm.max_requests?** Not as a generic performance tweak. PHP documents a default of zero and says a finite value can work around memory leaks in third-party libraries by respawning children. Consider it only when monitoring shows worker memory growing with request count and recycling resets that growth. The underlying extension or application leak still needs to be identified and fixed. **Should Nginx connect to PHP-FPM through a Unix socket or TCP?** When Nginx and PHP-FPM share a host, PHP's manual says a Unix socket should be preferred, with access controlled by socket owner, group, and mode. TCP is useful across hosts or container boundaries but must be explicitly restricted. An exposed FastCGI endpoint permits arbitrary code execution. In either case, FPM's listen value and Nginx's fastcgi_pass must match. **How can I tell whether a PHP-FPM pool is saturated?** Use the restricted FPM status page and monitoring history. A sustained listen queue, repeated max-children events, and rising latency indicate that requests are waiting for workers. Check memory, swap, CPU, database connections, storage, and downstream latency at the same time. A busy counter alone does not prove that raising pm.max_children is safe. --- # UFW Firewall Setup for Nginx on Ubuntu: 5 Essential Rules > To set up a UFW firewall for Nginx on Ubuntu, run five commands: sudo ufw default deny incoming, sudo ufw default allow outgoing, sudo ufw allow OpenSSH, sudo ufw allow 'Nginx Full', then sudo ufw enable. Allowing SSH before enabling prevents locking yourself out of a remote server. The 'Nginx Full' application profile opens ports 80 and 443, which HTTPS redirects and Let's Encrypt renewals both require. Verify the result with sudo ufw status verbose. Source: https://lemp.io/ufw-firewall-nginx-ubuntu/ Publisher: LEMP Field Notes Published: 2026-08-28 Updated: 2026-08-28 ## How to Set Up a UFW Firewall for Nginx on Ubuntu UFW (Uncomplicated Firewall) is Ubuntu's built-in front end for the kernel's netfilter firewall, and it ships installed but **disabled** on Ubuntu Server. Securing an Nginx web server with UFW takes five rules: set default policies to deny incoming and allow outgoing traffic, allow SSH *before* enabling the firewall, allow web traffic with the `Nginx Full` application profile, rate-limit SSH with `ufw limit`, and keep every internal service — MySQL, PHP-FPM, Redis — off the public rule list entirely. The short version looks like this: ```bash sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable ``` The rest of this guide explains what each rule does, why the order matters, and how to verify and manage the result. If you're starting from a bare server, our [LEMP stack install guide](/install-lemp-stack-ubuntu-24-04/) covers getting Nginx running in the first place; this article assumes Nginx is installed and you're connected over SSH. A public server gets probed by automated scanners within minutes of coming online. The firewall is not a finishing touch — it belongs in the same session as the install. ## Before You Start: Check UFW's Status First, confirm UFW is present and see its current state: ```bash sudo ufw status verbose ``` On a fresh Ubuntu Server install you'll see `Status: inactive`. That means no UFW rules are being enforced yet — every port your services listen on is reachable from the internet. If the command isn't found (some minimal images omit it), install it: ```bash sudo apt update sudo apt install ufw ``` Installing UFW does not enable it. Nothing changes until you run `ufw enable`, which is exactly the safety margin you want: you can stage all five rules first, then switch the firewall on knowing SSH is already allowed. ## Rule 1: Set Default Policies (Deny In, Allow Out) A firewall is only as good as its default posture. The standard policy for a web server is: refuse everything inbound unless explicitly allowed, and permit everything outbound so the server can fetch packages, renew certificates, and make DNS queries. ```bash sudo ufw default deny incoming sudo ufw default allow outgoing ``` These are actually UFW's defaults on Ubuntu, but set them explicitly anyway. It costs two commands, it makes your setup reproducible on servers where someone changed the defaults, and it documents your intent. With `deny incoming` as the baseline, every later rule is an *exception* you consciously carve out. That's the right mental model: your public rule list should be a short, complete inventory of what the world is allowed to reach. ## Rule 2: Allow SSH Before You Enable UFW This is the step that locks people out of their own servers. If you enable UFW with a default-deny inbound policy and no SSH rule, your current session may survive, but the **next** connection attempt will be refused — and on a remote VPS with no console access, that's a genuine emergency. So allow SSH first: ```bash sudo ufw allow OpenSSH ``` `OpenSSH` is an application profile registered by the `openssh-server` package; it opens TCP port 22. If you've moved SSH to a non-standard port, allow that port explicitly instead: ```bash sudo ufw allow 2222/tcp ``` Only after the SSH rule exists should you enable the firewall: ```bash sudo ufw enable ``` UFW will warn that enabling may disrupt existing SSH connections — that warning is precisely why this rule comes before `enable`, not after. Confirm with `y`, then verify from a **second terminal** that you can still open a fresh SSH session before you close the one you're in. UFW also persists across reboots once enabled, so this is one-time setup, not something to script into cron. ## Rule 3: Allow Web Traffic with 'Nginx Full' On Ubuntu, the Nginx package registers UFW application profiles — named bundles of ports defined in `/etc/ufw/applications.d/`. List them: ```bash sudo ufw app list ``` You should see three Nginx entries: | Profile | Ports opened | Use when | |---|---|---| | `Nginx HTTP` | 80/tcp | Serving plain HTTP only | | `Nginx HTTPS` | 443/tcp | Serving HTTPS only (rare — you almost always need 80 too) | | `Nginx Full` | 80/tcp and 443/tcp | Serving HTTPS with HTTP available for redirects and renewals | For any real site, use `Nginx Full`: ```bash sudo ufw allow 'Nginx Full' ``` The quotes matter — the profile name contains a space. Why open port 80 even on an HTTPS-only site? Two reasons. First, your Nginx config should redirect HTTP requests to HTTPS, and that redirect can only happen if port 80 is reachable. Second, Certbot's default HTTP-01 challenge validates domain ownership over port 80, so [Let's Encrypt certificate issuance and renewal](/nginx-lets-encrypt-certbot/) depends on it staying open. To see exactly what a profile opens before you trust it: ```bash sudo ufw app info 'Nginx Full' ``` Prefer profiles over raw port numbers where they exist. `Allow Nginx Full` in your status output tells a future admin *what* the rule is for; `80,443/tcp` makes them go look it up. If you're hosting several sites on one server, the firewall rule doesn't change — ports 80 and 443 serve all of them, and [Nginx server blocks](/nginx-server-blocks/) handle routing each domain to the right site. ## Rule 4: Rate-Limit SSH Against Brute Force An open port 22 collects password-guessing attempts around the clock. UFW has a built-in mitigation: the `limit` rule, which denies a source IP that opens 6 or more connections within 30 seconds. Replace the plain allow rule with a limit rule: ```bash sudo ufw delete allow OpenSSH sudo ufw limit OpenSSH ``` Legitimate use is unaffected — you'd have to reconnect several times in half a minute to trip it — but it blunts automated brute-force scripts considerably. Two honest caveats. First, `limit` is a mitigation, not a substitute for key-based SSH authentication with passwords disabled; do both. Second, if you use tools that open many rapid SSH connections (some deployment scripts, `rsync` loops, parallel Ansible runs), you can trigger the limit yourself — in that case keep the plain `allow` rule for a trusted admin IP: ```bash sudo ufw allow from 203.0.113.10 to any port 22 proto tcp ``` Do **not** apply `limit` to the Nginx ports. Web browsers legitimately open multiple connections in quick succession, and CDNs or proxies funnel many users through one IP — rate limiting for web traffic belongs in Nginx itself, not the packet filter. ## Rule 5: Keep Internal Services Off the Public List The most important rules on a LEMP server are the ones you *don't* add. MySQL (port 3306) and PHP-FPM should never appear in your UFW rule list, because nothing outside the server should reach them: - **MySQL** on Ubuntu binds to `127.0.0.1` by default, so it isn't reachable externally anyway — and with default-deny, UFW would refuse the traffic even if it were. Never run `ufw allow 3306` because a tutorial told you to. - **PHP-FPM** communicates with Nginx over a local Unix socket (or a loopback TCP port). Firewalls don't apply to Unix sockets; there's nothing to allow. - **Redis, Memcached, and similar** caches follow the same pattern: loopback-only binding, no firewall rule. If you genuinely need remote database access — say, from a separate application server — allow it only from that server's specific IP: ```bash sudo ufw allow from 203.0.113.20 to any port 3306 proto tcp ``` That still requires changing MySQL's `bind-address`, which is a deliberate architectural decision, not a default. For everyone else, the correct number of database rules is zero. The test is simple: run `sudo ufw status` and read every line aloud. If you can't say what a rule is for and who needs it, delete it. ## Verify and Manage Your Rules After setup, confirm the full picture: ```bash sudo ufw status verbose ``` A healthy Nginx web server firewall reads like this: ``` Status: active Default: deny (incoming), allow (outgoing), disabled (routed) To Action From -- ------ ---- OpenSSH LIMIT Anywhere Nginx Full ALLOW Anywhere OpenSSH (v6) LIMIT Anywhere (v6) Nginx Full (v6) ALLOW Anywhere (v6) ``` The `(v6)` entries are automatic — UFW mirrors rules for IPv6 when it's enabled in `/etc/default/ufw`, which it is by default. ### Deleting a Rule Use numbered mode to remove rules precisely: ```bash sudo ufw status numbered sudo ufw delete 2 ``` UFW shows you the rule and asks for confirmation before deleting. Note that numbers shift after each deletion, so re-run `status numbered` between deletes rather than firing off several numbers in a row. You can also delete by repeating the original rule with `delete` prefixed, as in `sudo ufw delete allow 'Nginx Full'` — useful in scripts where numbering is unreliable. ### Resetting or Disabling Two commands worth knowing and using carefully: ```bash sudo ufw disable # turns enforcement off, keeps your rules sudo ufw reset # disables AND wipes all rules back to defaults ``` If you `reset`, you're back to square one — re-add the SSH rule before enabling again. ## Common Mistakes to Avoid - **Enabling UFW before allowing SSH.** The classic lockout. Always `allow OpenSSH` (or your custom port) first. - **Allowing only `Nginx HTTPS`.** Breaks HTTP-to-HTTPS redirects and Certbot's HTTP-01 renewals. Use `Nginx Full`. - **Opening 3306 "to fix a connection error".** Database connection problems on a single-server LEMP setup are configuration issues, not firewall issues — MySQL and your PHP app talk over loopback. - **Forgetting the firewall exists.** When a new service "doesn't work" from outside, check `ufw status` before spending an hour in the service's own config. - **Treating UFW as complete security.** A firewall controls which ports are reachable; it does nothing about vulnerable code behind an open port. Pair it with HTTPS, key-based SSH, and regular `apt upgrade` runs. Five rules, two of which are "don't add rules you don't need" — that's genuinely all a standard Nginx server requires from UFW. The best firewall configuration is a short one you fully understand. ## Frequently asked questions **What is the difference between Nginx Full, Nginx HTTP, and Nginx HTTPS in UFW?** They are UFW application profiles registered by the Nginx package on Ubuntu. Nginx HTTP opens port 80 only, Nginx HTTPS opens port 443 only, and Nginx Full opens both. For a real site you almost always want Nginx Full: port 80 stays open for HTTP-to-HTTPS redirects and for Certbot's HTTP-01 challenge, while port 443 serves the encrypted traffic. Inspect any profile with sudo ufw app info 'Nginx Full'. **Why do I need to allow SSH before enabling UFW?** UFW's recommended default policy denies all incoming connections. If you enable the firewall on a remote server without first allowing SSH, your next connection attempt will be refused, and on a VPS without console access that means you are locked out. Run sudo ufw allow OpenSSH (or allow your custom SSH port) before sudo ufw enable, then confirm from a second terminal that a fresh SSH session still works. **Should I open port 3306 for MySQL in UFW?** No, not on a standard single-server LEMP setup. MySQL on Ubuntu binds to 127.0.0.1 by default, and your PHP application connects over loopback, which the firewall does not restrict. Opening 3306 to the internet only exposes the database to brute-force attempts. If a separate application server genuinely needs access, allow only that server's IP with a specific rule and deliberately change MySQL's bind-address. **What does sudo ufw limit do, and should I use it for SSH?** The limit rule allows a connection but blocks a source IP that opens six or more connections within 30 seconds, which blunts automated SSH brute-force attempts. Applying it to SSH with sudo ufw limit OpenSSH is a sensible default for most servers. Do not apply it to web ports, since browsers and proxies legitimately open many rapid connections, and keep using key-based authentication — rate limiting is a mitigation, not a substitute. **How do I check which UFW rules are active?** Run sudo ufw status verbose to see whether the firewall is active, the default policies, and every rule including automatic IPv6 mirrors. Use sudo ufw status numbered when you want to delete a rule: it prefixes each rule with a number you can pass to sudo ufw delete. Rule numbers shift after each deletion, so re-run the numbered listing between deletes rather than removing several numbers in one pass. **Does UFW stay enabled after a reboot?** Yes. Once you run sudo ufw enable, UFW is configured to start at boot and reapply your rules automatically, so it is one-time setup rather than something to re-run. Your rules persist until you change them: sudo ufw disable turns enforcement off but keeps the rule set, while sudo ufw reset disables the firewall and wipes all rules back to defaults, meaning you must re-add SSH access before enabling it again. ---