WordPress doesn’t use .htaccess on Nginx, so the server block does all the work: pretty permalinks, PHP handling, caching headers and security rules. Here’s a complete, annotated configuration you can adapt, followed by the reasoning for each part.

New install? Start with WordPress on Ubuntu with Nginx.

The full server block

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;
    # ssl_certificate / ssl_certificate_key managed by Certbot

    root /var/www/example.com;
    index index.php;

    client_max_body_size 64M;               # match upload_max_filesize in PHP

    # --- Permalinks ---
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # --- PHP ---
    location ~ \.php$ {
        try_files $uri =404;                # don't pass non-existent files to PHP
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_read_timeout 120s;
    }

    # --- Static files: long cache, no logs ---
    location ~* \.(?:css|js|mjs|jpg|jpeg|gif|png|webp|avif|svg|ico|woff2?|ttf|eot|mp4)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
        access_log off;
        try_files $uri =404;
    }

    # --- Security ---
    location = /xmlrpc.php { deny all; access_log off; }
    location ~* /wp-content/uploads/.*\.php$ { deny all; }
    location ~* /(?:wp-content|wp-includes)/.*\.php$ {
        deny all;                            # see note about plugins that need direct PHP access
    }
    location ~ /\.(?!well-known) { deny all; }
    location ~* (?:wp-config\.php|readme\.html|license\.txt|debug\.log)$ { deny all; }

    # --- Rate-limit logins (zone defined in nginx.conf) ---
    location = /wp-login.php {
        limit_req zone=wplogin burst=5 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    # --- Quiet common noise ---
    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; try_files $uri /index.php?$args; }
}

In /etc/nginx/nginx.conf (inside http {}):

limit_req_zone $binary_remote_addr zone=wplogin:10m rate=10r/m;

Test and reload: sudo nginx -t && sudo systemctl reload nginx. See Nginx config testing.

Why each part matters

Permalinks: try_files $uri $uri/ /index.php?$args serves real files directly, and sends everything else to WordPress. Without it, every pretty URL returns 404. See WordPress permalinks 404.

try_files $uri =404 in the PHP block: stops Nginx from passing requests for non-existent .php files to PHP-FPM, which is a classic security issue with some configurations.

Static files: browsers cache theme assets and images for 30 days. WordPress appends ?ver= to CSS and JS, so updates still bust the cache. Logging off reduces disk I/O. See Nginx static file caching.

Blocking PHP in uploads: uploaded files should never be executable. This one rule stops many malware infections from running even if a file gets uploaded.

Blocking PHP in wp-content and wp-includes: a strong hardening rule. Some plugins call their own PHP files directly. If something breaks, add a specific location exception for that plugin path, above this rule.

xmlrpc.php: a common target for brute force and pingback abuse. Block it unless you use the Jetpack or WordPress mobile app features that need it. See disable XML-RPC.

wp-login rate limit: slows down password guessing without affecting real users. Combine it with Fail2ban. See Fail2ban.

Hidden files: blocks .git, .env and .htaccess leftovers, but allows .well-known (Let’s Encrypt).

Compression

Enable gzip (or Brotli) for text assets in nginx.conf:

gzip on;
gzip_comp_level 5;
gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;

See Nginx gzip and Brotli.

Add page caching (optional, big win)

For mostly-anonymous traffic, FastCGI cache serves cached HTML straight from Nginx, without touching PHP, and skips logged-in users and carts. See Nginx FastCGI cache. If you use a caching plugin that writes static HTML files instead, add its Nginx rules. Don’t stack two page caches. See WordPress cache plugins compared.

Security headers

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

Add headers in the server block. Note that a location with its own add_header doesn’t inherit these, so repeat them there, or use an include snippet.

Multisite

Subdirectory multisite needs extra rewrite rules for /site/wp-admin and file paths. See WordPress multisite setup.

Quick reference

Goal Directive
Pretty permalinks try_files $uri $uri/ /index.php?$args;
Upload size client_max_body_size 64M;
No PHP in uploads location ~* /wp-content/uploads/.*\.php$ { deny all; }
Block XML-RPC location = /xmlrpc.php { deny all; }
Login rate limit limit_req zone=wplogin …

Related: WordPress hardening and WordPress VPS hosting.

  • WordPress
  • Nginx
  • Performance