# 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