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

- Installing WordPress on a LEMP Stack: What You're Actually Doing
- Step 1: Install the PHP Extensions WordPress Needs
- Step 2: Create the WordPress Database and MySQL User
- Step 3: Download and Extract WordPress
- Step 4: Set File Ownership and Permissions
- Step 5: Configure Nginx for WordPress
- Step 6: Create wp-config.php
- Step 7: Run the Browser Setup
- Step 8: Add HTTPS Before You Publish
- Quick Troubleshooting Reference
- Where to Go From Here
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:
- 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
.zipfiles - php8.3-intl — locale-aware formatting
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:
utf8mb4is 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 thewordpressdatabase only. The user can't touchmysql.*system tables or any other database on the server.- No
FLUSH PRIVILEGEShere. Nearly every tutorial tacks it on at this point, but MySQL reloads the grant tables automatically after account-management statements likeCREATE USERandGRANT. It is only needed if you modify themysql.usertables directly withINSERTorUPDATE, which you have no reason to do.
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 line that makes permalinks work
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:
- 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 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.