How to Configure Nginx for WordPress

How to Configure Nginx for WordPress

WordPress was originally built with Apache and .htaccess files in mind, which means every WordPress-on-Nginx setup needs a bit of translation — Nginx doesn’t read .htaccess at all, so pretty permalinks, security rules, and rewrite logic all need to be handled directly in the server block instead. I’ve set up more WordPress sites on Nginx than I can count at this point, and this guide walks through the full configuration I actually use, from the base server block through caching, security hardening, and the handful of WordPress-specific quirks that catch people off guard.

Why Nginx for WordPress

WordPress on Nginx (paired with PHP-FPM, covered in more detail in a separate guide) tends to handle concurrent traffic more efficiently than the classic Apache + mod_php stack, mostly because of Nginx’s event-driven architecture versus Apache’s process/thread-per-connection model. For a typical WordPress site, this translates into faster response times under load and lower memory usage per request — which matters a lot once you’re past a few hundred concurrent visitors.

Prerequisites

  • A server with Nginx and PHP-FPM already installed and talking to each other (see my PHP-FPM setup guide if you haven’t done this part yet).
  • MySQL or MariaDB installed for the WordPress database.
  • WordPress core files downloaded and extracted.
  • A domain pointed at your server (or /etc/hosts entry for local testing).

Step 1: Download and Place WordPress Files

cd /tmp
wget https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
sudo mv wordpress /var/www/example.com
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 {} \;

Step 2: Set Up the Database

sudo mysql -u root -p
CREATE DATABASE wordpress_db;
CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'a_strong_password_here';
GRANT ALL PRIVILEGES ON wordpress_db.* TO 'wp_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Then configure wp-config.php with these database credentials (copy from wp-config-sample.php if it doesn’t exist yet).

Step 3: The Core Nginx Server Block

This is the part that replaces everything a default WordPress .htaccess file would normally do:

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

    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;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|svg)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; }

    location ~ /\.ht {
        deny all;
    }

    location ~* /wp-config.php {
        deny all;
    }

    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
    }
}

Let me explain the parts that are specific to WordPress:

  • try_files $uri $uri/ /index.php?$args; — this single line is what makes WordPress’s “pretty permalinks” work (like /2026/08/post-title/ instead of /?p=123). It’s the Nginx equivalent of WordPress’s default .htaccess rewrite rules.
  • deny all; on /wp-config.php — this file contains your database credentials; it should never be servable directly, even though it’s already outside the request path in a properly configured install (defense in depth).
  • Blocking .php execution inside /uploads/ — this is an important hardening step. If an attacker manages to upload a malicious PHP file disguised as an image (a classic WordPress plugin vulnerability pattern), this rule prevents it from ever being executed, even if it lands in the uploads directory.

Step 4: Enable the Site

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

Then visit http://example.com — you should land on the WordPress installation wizard. Complete the setup, choose your site title and admin credentials, and confirm the dashboard loads.

Step 5: Set Permalinks and Confirm Rewrites Work

In the WordPress admin, go to Settings → Permalinks, select “Post name” (or your preferred structure), and save. Then visit any post using its pretty URL to confirm the rewrite rule in the Nginx config is actually working — if you get a 404, double check the try_files line matches exactly what’s shown above.

A Complete, Hardened Production Configuration

Here’s the fuller version I’d actually deploy for a client site, including SSL, caching, and additional security rules:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;
    root /var/www/example.com;
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 64M;

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

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_read_timeout 300;
    }

    # Static asset caching
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|svg|webp)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }

    # Block XML-RPC unless you specifically need it (Jetpack, some mobile apps)
    location = /xmlrpc.php {
        deny all;
    }

    # Block sensitive files
    location ~* /(?:wp-config\.php|readme\.html|license\.txt|\.htaccess)$ {
        deny all;
    }

    # Prevent PHP execution in uploads
    location ~* /wp-content/uploads/.*\.php$ {
        deny all;
    }

    # Limit login attempts area to reduce brute-force noise (optional, pairs with fail2ban)
    location = /wp-login.php {
        limit_req zone=wplogin burst=3 nodelay;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; }

    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Add this near the top of nginx.conf or in the http block for the rate limiting used above:

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

Setting Up FastCGI Caching for WordPress

Full-page caching is where the biggest WordPress performance gains come from, since it lets Nginx skip PHP execution entirely for repeat visitors viewing unchanged content.

In the http block:

fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

In the server block’s PHP location:

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

    fastcgi_cache WORDPRESS;
    fastcgi_cache_valid 200 60m;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache $skip_cache;
    add_header X-Cache-Status $upstream_cache_status;
}

And define $skip_cache so logged-in users, commenters, and admin pages never get served a cached page (which would show them someone else’s cached view, or hide their own logged-in state):

set $skip_cache 0;

if ($request_method = POST) {
    set $skip_cache 1;
}
if ($query_string != "") {
    set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") {
    set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
    set $skip_cache 1;
}

This is one of the few places I use if blocks in Nginx deliberately — while if is generally discouraged in Nginx configs due to unpredictable interactions with other directives, this specific pattern (setting a variable based on simple conditions) is safe and is the standard documented approach for FastCGI cache bypass logic.

Testing Your Setup

Confirm pretty permalinks work:

curl -I https://example.com/sample-post/

Should return 200 OK, not a redirect loop or 404.

Confirm blocked files are actually blocked:

curl -I https://example.com/wp-config.php
curl -I https://example.com/xmlrpc.php

Both should return 403 Forbidden.

Confirm uploads can’t execute PHP:

echo "<?php phpinfo(); ?>" | sudo tee /var/www/example.com/wp-content/uploads/test.php
curl https://example.com/wp-content/uploads/test.php

This should return 403 Forbidden, not the PHP info page. Delete the test file afterward:

sudo rm /var/www/example.com/wp-content/uploads/test.php

Verify FastCGI cache is working:

curl -I https://example.com/

Check the X-Cache-Status header — it should show MISS on first load, then HIT on subsequent loads of the same uncached page.

Troubleshooting Common Issues

404 on every post/page except the homepage. This is almost always the try_files line missing or incorrect. Confirm it reads exactly try_files $uri $uri/ /index.php?$args;.

“Too many redirects” error. Usually caused by a mismatch between the site URL configured in WordPress (Settings → General, or directly in the database wp_options table) and how the server is actually being accessed — for example, WordPress configured for https:// while Nginx is still serving plain http:// without a proper redirect, creating a loop between the two.

Logged-in users seeing cached/stale content. Double-check the $skip_cache logic includes wordpress_logged_in cookie detection — this is the most common cache-bypass rule people forget, and it results in every logged-in admin seeing an old cached homepage instead of their live edits.

File uploads failing above a certain size. Check three places that all need to agree: client_max_body_size in Nginx, upload_max_filesize and post_max_size in php.ini. All three need to be raised together — the smallest one wins.

White screen of death. Usually a PHP error being silently swallowed. Check wp-content/debug.log (enable WP_DEBUG_LOG in wp-config.php) and the PHP-FPM error log.

Security Considerations

  • Disable XML-RPC unless actively needed — it’s a common brute-force and DDoS amplification vector for WordPress sites, and most sites don’t use the plugins/integrations that require it (older Jetpack features, some mobile apps).
  • Block execution of PHP in the uploads directory — this is non-negotiable given how many WordPress plugin vulnerabilities historically involve arbitrary file upload.
  • Rate limit wp-login.php to slow down brute-force login attempts, ideally combined with fail2ban watching the Nginx access log for repeated failed login patterns.
  • Keep WordPress core, themes, and plugins updated. No amount of Nginx hardening compensates for an outdated plugin with a known remote code execution vulnerability.
  • Hide the WordPress version number where possible, and consider a security plugin (Wordfence, iThemes Security) alongside — not a replacement for the Nginx rules, but a useful complement for application-layer threats Nginx can’t see.

Performance Tips

  • FastCGI caching is the single biggest win for a typical content-heavy WordPress site — as shown above.
  • Enable OPcache in PHP (covered in more detail in the PHP-FPM guide) — this alone often provides a bigger speed improvement than most WordPress “performance” plugins.
  • Use a persistent object cache (Redis or Memcached) for database query caching — this helps with dynamic, logged-in, or WooCommerce-style pages that can’t be fully page-cached.
  • Optimize images — WordPress media libraries accumulate huge unoptimized images over time; combine with the static asset caching shown earlier.
  • Consider a CDN in front of Nginx for global traffic — WordPress sites in particular benefit heavily from offloading image and static asset delivery to edge locations.

Real-World Use Cases

  • A news/blog site where FastCGI caching cut average PHP execution from database-heavy page generation down to near-zero for the majority of anonymous traffic, since most visitors were reading cached pages rather than triggering fresh PHP execution.
  • A WooCommerce store, where the cache-bypass rules needed careful tuning — cart, checkout, and account pages all had to be explicitly excluded from full-page caching while product and category pages remained cached, since caching a live shopping cart page would show one customer another customer’s cart.
  • A multi-author publication recovering from a plugin vulnerability, where blocking PHP execution in /wp-content/uploads/ at the Nginx level (rather than relying solely on a security plugin) closed the actual attack vector that had been exploited.

Best Practices Summary

  • Replace .htaccess logic entirely with the try_files rewrite rule — Nginx never reads .htaccess.
  • Block direct access to wp-config.php and any sensitive files as defense in depth.
  • Never allow PHP execution inside the uploads directory.
  • Set up FastCGI caching with correct bypass rules for logged-in users and dynamic pages.
  • Keep client_max_body_size aligned with PHP’s own upload limits.
  • Rate limit or otherwise protect wp-login.php from brute-force attempts.
  • Test blocked paths and cache behavior explicitly — don’t just assume the rules are working.

Once this configuration is in place, WordPress on Nginx tends to just quietly work — fast, stable, and considerably more resistant to the common low-effort attacks that plague default WordPress installs. Most of the effort is front-loaded into getting this config right once; after that, it’s copy-paste-and-adjust-the-domain for every new site.

Migrating an Existing WordPress Site From Apache

A large chunk of the WordPress-on-Nginx setups I’ve done weren’t fresh installs — they were migrations from an existing Apache/mod_php server. A few things I always check specifically during that process:

Convert .htaccess rewrite rules manually. WordPress’s default .htaccess handles pretty permalinks, but plugins (especially SEO or security plugins) frequently add their own custom rules — redirects, canonical URL enforcement, hotlink protection. Since Nginx ignores .htaccess entirely, I go through the existing file line by line and translate anything beyond the default WordPress block into equivalent Nginx rewrite or location rules before cutting over.

Check for hardcoded Apache-specific logic in plugins. A small number of plugins (older ones especially) check for mod_rewrite availability or write directly to .htaccess as part of their setup process — these checks will silently fail or throw warnings under Nginx and usually just need to be dismissed or worked around, since the underlying rewrite functionality is being handled at the Nginx layer instead.

Verify file upload limits carry over. Apache configurations often set upload limits via .htaccess (php_value upload_max_filesize), which — again — Nginx won’t read. These need to be set directly in php.ini or a pool-specific PHP-FPM config instead, as covered in the PHP-FPM guide.

Test the full permalink structure before switching DNS. I always stand up the new Nginx server first (accessible via IP or a temporary /etc/hosts entry, before pointing the domain at it), and click through a representative sample of URLs — posts, category pages, search results, paginated archives — to catch any rewrite gaps before real traffic hits the new server.

Multisite (WordPress Network) Configuration Notes

WordPress Multisite adds another layer of URL rewriting complexity, since a single WordPress install now serves multiple sites, either via subdirectories (example.com/site2/) or subdomains (site2.example.com). The try_files rule needs adjusting for subdirectory-based multisite specifically:

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

location ~ ^/[_0-9a-zA-Z-]+/files/(.*)$ {
    try_files /wp-content/blogs.dir/$blogid/$uri /wp-includes/ms-files.php?file=$1 ;
    access_log off;
    log_not_found off;
    expires max;
}

if (!-e $request_filename) {
    rewrite /wp-admin$ $scheme://$host$uri/ permanent;
    rewrite ^(/[^/]+)?(/wp-.*) $2 last;
    rewrite ^(/[^/]+)?(/.*\.php) $2 last;
}

Subdomain-based multisite is simpler from an Nginx perspective, since each subdomain is really just a wildcard server_name pointing at the same WordPress install — server_name example.com *.example.com; — with the multisite logic handled entirely by WordPress itself once the request arrives.

Frequently Asked Questions

Do I still need a security plugin if I’ve hardened Nginx this thoroughly? I’d still recommend one, yes — Nginx-level rules protect against a specific category of threats (direct file access, PHP execution in uploads, brute force via rate limiting), but application-layer issues like a vulnerable plugin’s SQL injection flaw or a compromised admin password aren’t things Nginx can see or prevent. The two layers complement each other rather than one replacing the other.

Will FastCGI caching break WooCommerce or membership plugins? It can, if the cache-bypass rules aren’t tuned correctly for that specific plugin’s cookies and dynamic pages — cart, checkout, my-account, and any AJAX endpoints generally need to bypass the cache. Most caching plugins and managed WordPress hosts publish specific bypass rule sets for popular e-commerce and membership plugins, which I’d reference and adapt rather than guessing at the exclusion list from scratch.

Is FastCGI caching better or worse than a plugin-based caching solution like WP Super Cache? FastCGI caching at the Nginx level is generally faster, since it serves cached pages before PHP even starts, whereas plugin-based caching still requires WordPress to bootstrap (at minimum) before deciding to serve a cached page. The tradeoff is that Nginx-level caching requires server access and more hands-on configuration, while plugin-based caching is more accessible for someone managing WordPress without server-level access.

How often should I review the Nginx security rules as WordPress itself gets updated? I revisit this whenever I’m doing a major WordPress core upgrade or adding a new plugin with its own upload or API endpoints, since occasionally a plugin needs a specific carve-out from a blanket rule (a legitimate reason to access a normally-blocked path). Otherwise, the core ruleset in this guide tends to remain stable for years without needing changes.

Total
0
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Let's Encrypt SSL

How to Set Up Nginx with Let’s Encrypt SSL

Next Post
How to Set Up Nginx with PHP-FPM

How to Set Up Nginx with PHP-FPM

Related Posts