LEMP Field Notes
Stack Guides

How to Install WordPress on a LEMP Stack (Ubuntu 24.04)

How to Install WordPress on a LEMP Stack (Ubuntu 24.04)
In briefTo 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.

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 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:

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:

Restart PHP-FPM so the new extensions load:

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:

sudo mysql

Then run these statements, choosing a strong password of your own in place of the placeholder:

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:

Verify the credentials work before moving on:

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:

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:

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:

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:

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):

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:

sudo nano /etc/nginx/sites-available/example.com

Paste in this configuration, adjusting server_name and root:

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 single most important directive for WordPress on Nginx is this one:

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 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:

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:

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:

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:

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:

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:

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:

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:

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 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 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 collection. The differences in how Nginx and Apache handle rewrites and per-directory config are worth understanding too, and our Nginx vs Apache comparison goes deeper on both.

FAQ

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.