How to install WordPress with Nginx

A current LEMP walkthrough: nginx, MySQL 8.4, PHP 8.5 and WordPress 7.0 on Ubuntu 26.04, plus TLS, least-privilege database access and file permissions.

Share
wordpress installed using nginx as the web server.jpg

Nginx and WordPress work well together on a VPS. Nginx serves static files from a small memory footprint, hands PHP requests to PHP-FPM, and handles a lot of concurrent connections without much tuning. This guide builds a current LEMP stack on Ubuntu 26.04 LTS: nginx, MySQL 8.4, PHP 8.5, and WordPress 7.0, with a Let's Encrypt certificate and the hardening steps most install guides leave out. A WordPress site that is online and unpatched is a liability, so the security work is part of the install, not something you bolt on later.

Prerequisites

  • An Ubuntu 26.04 LTS server with a non-root user that has sudo. Do not run WordPress as root. The 24.04 LTS release works too, with the version substitutions noted below.
  • A domain whose A record points at the server's public IP address, and a AAAA record if the server has IPv6.
  • Comfort with the command line and a text editor.
Version note: Ubuntu 26.04 LTS ships MySQL 8.4 and PHP 8.5. On Ubuntu 24.04 LTS, substitute MySQL 8.0 and PHP 8.3 (php8.3-fpm and the matching php8.3-* extensions), and the PHP socket path becomes /run/php/php8.3-fpm.sock. Everything else is the same.

If you need a server, the providers below all have entry plans that are more than enough for one WordPress site. The links are affiliate links, and they cost you nothing extra. Check each provider's site for the current signup credit, since the promotions change.

  • Vultr has a wide choice of operating systems and several data centre regions.
  • DigitalOcean is the simplest to start with and has solid documentation.
  • Linode is a good middle ground on price and support.
  • Microsoft Azure, AWS, and Google Cloud all have free tiers or trial credit if you already use them.

Installing nginx

Ubuntu ships its own nginx package and keeps it patched for the life of the release, which is what you want on a server. Install it with apt:

sudo apt update
sudo apt install nginx

If you want a newer build than the archive carries, nginx.org publishes its own repository for Ubuntu, currently the 1.30 stable and 1.31 mainline branches. The archive build is fine for most sites and avoids adding a third-party source. Setup instructions are at nginx.org/en/linux_packages.html, and the documentation lives at nginx.org. Note that nginx.com now redirects to F5's product page, so link to nginx.org when you cite the project.

Open the firewall before you test anything. Allow SSH first or you will lock yourself out, then allow nginx:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw status

'Nginx Full' opens both port 80 and port 443, which you need once TLS is in place. Confirm the default page loads at http://your_server_ip before moving on. If ufw is not installed, see the UFW documentation.

Installing MySQL

Ubuntu 26.04 ships MySQL 8.4 LTS; Ubuntu 24.04 ships MySQL 8.0. Both are supported by WordPress. The current manual is at dev.mysql.com. Install the server and run the script that removes the anonymous accounts and the test database:

sudo apt install mysql-server
sudo mysql_secure_installation

On Ubuntu the packaged root account authenticates through the auth_socket plugin, so sudo mysql connects without a password and you never have to set a root password at all. Older guides tell you to switch root to mysql_native_password with an ALTER USER statement. Do not do that. MySQL 8.4 disables the mysql_native_password plugin by default, and it is the weakest of the available authentication plugins. If you do want a password on root, use the default caching_sha2_password instead:

ALTER USER 'root'@'localhost' IDENTIFIED BY 'a-strong-password';

Creating the WordPress database

Log in with sudo mysql and create the database plus a dedicated user. Create the database as utf8mb4, not utf8. MySQL's utf8 is a three-byte subset that cannot store emoji or a fair amount of non-Latin script, and WordPress has defaulted to utf8mb4 since version 4.2.

CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'wordpressuser'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, CREATE TEMPORARY TABLES, LOCK TABLES, REFERENCES ON wordpress.* TO 'wordpressuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;

That grant is close to the least privilege WordPress can run with. The account can touch the wordpress database and nothing else: no global privileges, no GRANT OPTION, and no read access to other schemas. The DDL privileges (CREATE, DROP, ALTER, INDEX) are there because WordPress creates and migrates its own tables during upgrades. On MariaDB, use utf8mb4_unicode_ci as the collation, since utf8mb4_0900_ai_ci is MySQL 8 only.

Installing PHP

WordPress 7.0 requires at least PHP 7.4 and recommends 8.3 or newer, with 8.4 or later recommended for production, per the WordPress requirements page and the hosting handbook. Ubuntu 26.04 ships PHP 8.5, which WordPress 6.9 and later support fully, so the archive packages are a good match.

sudo apt install php8.5-fpm php8.5-mysql php8.5-curl php8.5-gd php8.5-intl php8.5-mbstring php8.5-xml php8.5-zip php8.5-opcache

On Ubuntu 24.04 the same command uses php8.3-fpm and the matching php8.3-* extensions. Check what the archive offers with apt-cache search '^php.*-fpm$'.

Note what is missing from that list: php-xmlrpc. PHP moved the xmlrpc extension out of core and into PECL in PHP 8.0, so there is no package for it on current PHP, and WordPress does not need it. WordPress ships its own XML-RPC implementation, and the endpoint is something you may want to close anyway (more on that below).

Enable and start PHP-FPM, then confirm the socket exists:

sudo systemctl enable php8.5-fpm
sudo systemctl restart php8.5-fpm
ls /run/php/

You should see php8.5-fpm.sock. That path goes into the nginx config next. If the service name is not found, you are on a different PHP branch; use the version that ls /etc/php reports.

Configuring nginx for WordPress

Nginx uses server blocks where Apache uses virtual hosts. Create a document root and a config file for your domain, replacing example.com with your own throughout this section:

sudo mkdir -p /var/www/example.com
sudo chown -R $USER:$USER /var/www/example.com
sudo nano /etc/nginx/sites-available/example.com

Paste the config below. It is the standard WordPress server block plus the pieces that keep the install from leaking. The comments explain each part.

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/example.com;
    index index.php index.html;

    server_tokens off;
    client_max_body_size 64M;

    # Let certbot reach its HTTP-01 challenge even with the hidden-file rule below.
    location ^~ /.well-known/acme-challenge/ {
        allow all;
    }

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
    }

    # Deny hidden files and directories (.git, .env, .htaccess), except .well-known.
    location ~ /\.(?!well-known) {
        deny all;
    }

    # Never execute PHP that an upload or a plugin dropped in uploads.
    location ~* /wp-content/uploads/.*\.php$ {
        deny all;
    }

    # Files that leak information or are not meant to be fetched.
    location = /wp-config.php { deny all; }
    location = /readme.html   { deny all; }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; allow all; }
    location ~* \.(css|gif|ico|jpeg|jpg|js|png|svg|webp|avif|woff|woff2)$ {
        expires max;
        log_not_found off;
    }
}

Two of those blocks matter more than they look. Denying hidden files stops the classic leak of a committed .git directory or a stray .env. Denying PHP in wp-content/uploads closes the path that a lot of plugin and theme upload vulnerabilities rely on: even if an attacker gets a PHP file into uploads, nginx will not run it.

Enable the site, disable the default one, and test the config before reloading:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo unlink /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Restricting wp-admin and wp-login.php

If you have a static IP address, you can limit the login page to that address. It cuts off most automated brute-force traffic, and it also locks you out the moment your IP changes, so think about whether you want that on a laptop that moves between networks.

# Optional. Replace the address with your own static IP.
location = /wp-login.php {
    allow 203.0.113.10;
    deny all;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}

Do not extend that rule to all of /wp-admin/. The admin area loads admin-ajax.php, which the block editor, the media library and many plugins call from the front end, and blocking it breaks the site in ways that are hard to trace. HTTP basic auth over the whole admin directory is a better fit than an IP allowlist if you want to gate the admin area without breaking AJAX.

TLS with Certbot

WordPress login cookies and admin traffic should never travel over plain HTTP. The Certbot nginx plugin obtains a Let's Encrypt certificate and edits the server block to add the TLS listener and an HTTP to HTTPS redirect.

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot asks for an email address and whether to redirect HTTP to HTTPS. Choose the redirect. Renewal is handled by a systemd timer that the package installs, so confirm it exists and test it once:

systemctl list-timers | grep certbot
sudo certbot renew --dry-run

After you have confirmed HTTPS works and you are not going to serve the site over plain HTTP again, add HSTS. Only add it once you are sure: browsers that see the header will refuse plain HTTP to your domain for as long as the max-age says. Drop these four lines inside the TLS server block that Certbot added, then reload nginx.

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

HSTS is the only one of the four with a lasting effect. The other three are cheap defaults that close a few classes of browser-side attack.

Downloading WordPress

Get the current release from wordpress.org rather than a mirror. At the time of writing that is WordPress 7.0 "Armstrong", released in May 2026.

cd /tmp
curl -LO https://wordpress.org/latest.tar.gz
tar xzf latest.tar.gz
sudo cp -a /tmp/wordpress/. /var/www/example.com/
sudo mv /var/www/example.com/wp-config-sample.php /var/www/example.com/wp-config.php
rm -rf /tmp/wordpress /tmp/latest.tar.gz

File permissions

Set ownership and permissions in one pass. Directories get 755, regular files get 644, and wp-config.php gets 640 so its database credentials and salts are not world-readable. The WordPress file permissions guide covers the reasoning in more detail.

sudo chown -R www-data:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
sudo chmod 640 /var/www/example.com/wp-config.php

There is a tradeoff in that ownership. Giving www-data the whole tree is what lets WordPress update itself without asking for FTP credentials, and it is the setup the rest of this guide assumes. The cost is that PHP can rewrite its own code, so a vulnerable plugin can persist across a reboot. The stricter alternative is to keep the tree owned by your user, give www-data write access only to wp-content/uploads, and run updates yourself with WP-CLI or as your user. That version breaks the one-click updater. Pick the one that matches how much you trust your plugins and how reliably you patch.

Configuring WordPress

Generate the authentication salts instead of leaving the placeholders in place. These keys sign login cookies, and the sample values in the config file are public.

curl -s https://api.wordpress.org/secret-key/1.1/salt/

Open wp-config.php, fill in the database values you set earlier, and paste the generated salt block over the placeholder one.

sudo nano /var/www/example.com/wp-config.php

Then add the following lines above the line that says /* That's all, stop editing! */. Each one closes a specific door:

define( 'FS_METHOD', 'direct' );
define( 'DISALLOW_FILE_EDIT', true );
define( 'WP_AUTO_UPDATE_CORE', true );
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_DEBUG', false );
  • FS_METHOD set to direct tells WordPress to write files directly instead of asking for FTP credentials, which works because of the ownership above.
  • DISALLOW_FILE_EDIT removes the built-in plugin and theme code editor. That editor is the shortest path from a stolen admin account to running code on the server.
  • WP_AUTO_UPDATE_CORE set to true keeps core patched automatically, including major releases. The default only applies minor and security updates.
  • FORCE_SSL_ADMIN keeps admin cookies off plain HTTP.
  • WP_DEBUG stays false on a live site so errors do not leak paths and queries to visitors.

If you want to go further, DISALLOW_FILE_MODS blocks all installs and updates from the dashboard, including the automatic ones. It is stronger, but you then have to apply every update yourself through WP-CLI. That tradeoff is control against convenience, and it only helps if you actually run the updates.

You can also move wp-config.php one directory above the web root. WordPress looks for it there, and it keeps the credentials file out of any misconfigured path that serves the document root.

Installing WordPress

The browser setup wizard works, but WP-CLI does the same job in one command, is repeatable, and does not depend on a UI that changes between releases. Install it, then run the install step as www-data so the files it writes have the right owner.

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
wp --info
sudo -u www-data wp core install \
    --path=/var/www/example.com \
    --url=https://example.com \
    --title="My WordPress Site" \
    --admin_user=frank \
    [email protected] \
    --skip-email \
    --prompt

Use a username other than admin. Let --prompt ask you for the password rather than putting it on the command line, where it lands in your shell history. WP-CLI prompts for any required value that is missing.

If you prefer the browser route, the steps are the same ones the wizard has always asked for, in order:

  1. Open https://example.com in a browser.
  2. Choose your language and continue.
  3. Enter the site title, an admin username that is not admin, a strong password, and your email address.
  4. Click Install WordPress, then log in at https://example.com/wp-login.php.

Whichever route you use, do not leave the site on the default admin username. It is the most targeted account on any WordPress install.

Disabling XML-RPC (and when not to)

xmlrpc.php is enabled by default and rarely needed. It is a common target for authentication brute-force, because a single request can test many username and password pairs, and for pingback abuse, where an attacker uses your server to attack someone else. The clean way to close it is at the web server, before PHP is involved:

location = /xmlrpc.php {
    deny all;
}

Add that to the server block and reload nginx. The WordPress-side equivalent is a small mu-plugin that returns false from the xmlrpc_enabled filter, but blocking it in nginx is cheaper and does not run PHP at all.

Blocking it is not free. It breaks the WordPress mobile app on older versions, Jetpack, and any desktop publishing tool that talks to the legacy API. If you depend on one of those, leave XML-RPC enabled and protect it another way: strong unique passwords, two-factor authentication on every account, and a rate limit or fail2ban rule on the endpoint. The decision is about what you actually use, not about a rule that fits every site.

Keeping the server patched

Ubuntu applies security updates automatically through unattended-upgrades, which is installed by default. Confirm it is on, and turn it on if it is not:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

That covers the operating system packages. For WordPress itself, core auto-updates are handled by the WP_AUTO_UPDATE_CORE constant above. Plugin and theme auto-updates can be enabled per item on the Plugins and Themes screens, or in bulk with WP-CLI:

sudo -u www-data wp plugin auto-updates enable --all --path=/var/www/example.com

Auto-updates are not a substitute for a backup. Take a database dump and a copy of the files on a schedule, and keep them somewhere other than the server, so a bad update or a compromise is recoverable.

That is a working, hardened WordPress site on nginx. From here, tighten the web server further with the settings in How to harden nginx, and run through the checklist in the first 24 hours on a new VPS if the server is new.

## Convertkit Newsletter