Installing WordPress 7.0 on Nginx

WordPress powers a staggering share of the web, and its Nginx configuration has been refined over years of real-world abuse. This guide walks through a production setup for WordPress 7.0 — the same stack running behind this site — with CMS-specific blocks, static caching, rate limiting, and PHP-FPM tuning that actually makes a difference under load.

Prerequisites

  • Nginx 1.18+
  • PHP-FPM 8.1+ (WordPress 7.0 requires PHP 7.4+, but 8.1+ is recommended)
  • MySQL 8.0+ or MariaDB 10.6+ (WordPress 7.0 raised the database minimum from MySQL 5.5.5)
  • PHP extensions: mysqli (or mysqlnd), curl, json, mbstring, gd (or imagick), zip
  • APCu and opcache enabled for object caching and PHP acceleration
  • A domain name pointed to your server
  • SSL certificates via Let's Encrypt

Step 1: Download WordPress

The ownership model here is deliberate: files are owned by your SSH/SFTP user with www-data as the group. PHP-FPM runs as www-data — the standard Debian/Ubuntu default. Most of WordPress should not be writable by PHP; only a handful of directories get group-write permissions so WordPress can handle uploads, plugin installs, and updates from the admin panel.

# Create the web root
sudo mkdir -p /var/www/mysite.com/public_html

# Download WordPress
cd /tmp
wget https://wordpress.org/wordpress-7.0.zip
sudo unzip wordpress-7.0.zip -d /var/www/mysite.com/public_html

# Ownership: your user owns the files, www-data is the group.
# Replace 'user' with your actual SSH/SFTP username.
sudo chown -R user:www-data /var/www/mysite.com/public_html

# Base permissions: 755 on directories, 644 on files.
# These are the only permissions the vast majority of WordPress needs.
# Nginx (www-data) gets read+traverse via the group bits.
sudo find /var/www/mysite.com/public_html -type d -exec chmod 755 {} \;
sudo find /var/www/mysite.com/public_html -type f -exec chmod 644 {} \;

# WordPress needs to write to these four directories from the admin panel.
# 775 = owner (user) full, group (www-data) full, others read+traverse.
# 664 = owner full, group full, others read-only.
# PHP-FPM runs as www-data, so group-write is what lets it create and
# delete files here — and nowhere else.
sudo find /var/www/mysite.com/public_html/wp-content/uploads   -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/uploads   -type f -exec chmod 664 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/plugins   -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/plugins   -type f -exec chmod 664 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/themes    -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/themes    -type f -exec chmod 664 {} \;

# wp-content/upgrade is used during core/plugin/theme updates and may
# not exist on a fresh install — create it if it's missing
sudo mkdir -p /var/www/mysite.com/public_html/wp-content/upgrade
sudo chown user:www-data /var/www/mysite.com/public_html/wp-content/upgrade
sudo chmod 775 /var/www/mysite.com/public_html/wp-content/upgrade

Why this model. The common shortcut is chown -R www-data:www-data on everything — that means your SSH/SFTP user doesn't own anything, every edit requires sudo, and PHP (as the file owner) can write to every file and directory regardless of the permission bits. Flipping it around — you own the files, www-data is only the group — means you can SSH in and edit files directly. More importantly, PHP cannot write anywhere except the four directories above: wp-content/uploads, wp-content/plugins, wp-content/themes, and wp-content/upgrade. The rest of the install stays at 755/644, which PHP sees as read-only through the group bits. A compromised plugin that tries to modify index.php or wp-settings.php hits a permissions wall. You may still require sudo if you need to modify or remove content created with the ownership www-data:www-data from the shell.

If your system user was created with its own private group (e.g. user:user), run this first so www-data becomes the group:

sudo chown -R user:www-data /var/www/mysite.com/public_html

Step 2: Base Nginx server block

This is the smallest configuration that will get WordPress running. As with any minimal config, it omits security hardening and static caching — the full production version is at the end of the article.

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name mysite.com;

    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    ssl_certificate /etc/letsencrypt/live/mysite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mysite.com/privkey.pem;

    root /var/www/mysite.com/public_html;

    access_log /var/log/nginx/mysite.access.log;
    error_log /var/log/nginx/mysite.error.log;

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

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
    }
}
  • try_files $uri $uri/ /index.php$is_args$args; — The standard WordPress front controller. Static files and directories are served directly; everything else routes through index.php with query strings preserved. WordPress handles its own permalink routing from there.
  • try_files $uri =404; in the PHP block prevents WordPress from executing non-existent PHP files — a request for /uploads/hack.png/index.php gets a 404 before PHP ever sees it.
  • No client_max_body_size at the server-block level — uploads belong in the admin area. The /wp-admin location block in the next section sets this instead. Whatever you set there, match it in php.ini: upload_max_filesize should equal the Nginx limit, and post_max_size should be a couple megabytes larger to accommodate the rest of the form data. A mismatch means either Nginx rejects the upload before PHP sees it, or PHP accepts body payloads Nginx already dropped.

The ssl_trusted_certificate directive is absent here — Let's Encrypt phased out OCSP stapling in 2025, and the chain certificate is already bundled in fullchain.pem. Only ssl_certificate and ssl_certificate_key are needed.

At this point you can visit https://mysite.com and run the famous five-minute WordPress installer. But don't stop here — the next sections are where the real configuration happens.


Step 3: WordPress-specific blocks

These rules handle security and routing for WordPress. All of them must appear before the general location ~ \.php$ handler — Nginx processes regex locations in order, and the first match wins. Save this as wp.conf and include it from your server block:

# wp.conf — WordPress security and routing

# Block access to sensitive WordPress files
location ~* ^/(wp-config\.php|wp-settings\.php|wp-mail\.php|readme\.html|license\.txt) {
    deny all;
    access_log off;
    log_not_found off;
}

# Block PHP execution in the uploads directory
location ~* ^/wp-content/uploads/.*\.php$ {
    deny all;
    access_log off;
    log_not_found off;
}

# Block xmlrpc.php (major brute-force and DDoS vector)
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

# Admin area — no caching, body size for uploads, explicit front-controller routing
location /wp-admin {
    expires 0;
    client_max_body_size 16M;
    try_files $uri $uri/ /index.php$is_args$args;
}

# Sitemap routes — pass through to WordPress, cache for 1 hour
location = /sitemap.xml       { try_files $uri /index.php$is_args$args; expires 1h; }
location = /wp-sitemap.xml    { try_files $uri /index.php$is_args$args; expires 1h; }
location = /sitemap_index.xml { try_files $uri /index.php$is_args$args; expires 1h; }
location ~* ^/wp-sitemap-.*\.xml$ {
    try_files $uri /index.php$is_args$args;
    expires 1h;
}

The /wp-admin block does three things: expires 0 prevents browsers from caching admin pages (stale admin pages cause more problems than they solve), client_max_body_size 16M scopes uploads to where they actually happen instead of the entire server block, and the explicit try_files ensures query strings survive the internal redirect — $is_args$args appended to /index.php is what lets plugin pages with long query strings (/wp-admin/admin.php?page=some-plugin&tab=settings) route correctly.

For themes that import demo content — Avada being a common example — the default 16 MB may not be enough. Bump client_max_body_size to 32M in this block and match it in php.ini with upload_max_filesize = 32M and post_max_size = 34M. For most WordPress sites where the heaviest upload is a photo or a PDF, 16 MB is plenty.

A note on xmlrpc.php: If you use the WordPress mobile app or Jetpack, you need XML-RPC. For everyone else, blocking it eliminates a persistent source of brute-force login attempts and pingback amplification attacks. Nginx dropping the request before PHP sees it saves CPU cycles and PHP-FPM workers that would otherwise spin up for every bot hammering the endpoint.

Why sitemap rules are here: SEO plugins like Yoast and Rank Math generate sitemaps dynamically through WordPress. Without explicit location blocks for sitemap paths, those requests fall through to the generic location / handler — which works, but the try_files fallback to index.php means Nginx can't apply any caching headers. Adding these rules lets you set expires 1h on sitemap responses, keeping crawlers from re-fetching them on every pass. The paths cover the core WordPress sitemap (/wp-sitemap.xml and its paginated variants), the common Yoast/Rank Math single-sitemap convention (/sitemap.xml and /sitemap_index.xml), and any plugin that writes to the root.

On sites where WordPress is already installed, also block the setup-config installer. Save as wp-installed.conf:

# wp-installed.conf — block the installer on existing sites
location = /wp-admin/setup-config.php {
    deny all;
    access_log off;
    log_not_found off;
}