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

# 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

# Set ownership
sudo chown -R www-data: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;
}