WordPress on Nginx: server block configuration and permalink rewrite rules

A complete nginx server block for WordPress: the try_files directive that permalinks depend on, the PHP-FPM pass with the correct SCRIPT_FILENAME parameter, socket versus TCP trade-offs, static asset caching, the security rules nginx needs because it ignores .htaccess, the extra rewrite block for multisite subdirectory networks, and how to debug the setup using the nginx and PHP-FPM error logs.

WordPress grew up on Apache, where a site owner dropped an .htaccess file in the web root and pretty permalinks, directory protection, and PHP execution rules all worked without touching the server config. Nginx ignores .htaccess entirely: every rewrite, every access rule, every expires header has to live in the nginx config itself. That is the only real reason a WordPress-on-nginx setup is harder than it looks. The rules themselves are well-documented, but they are scattered across the WordPress nginx recipes, the nginx core module reference, the PHP-FPM configuration docs, and community reference configs like SpinupWP's wordpress-nginx repository. This article assembles them into one working server block, explains why each directive is there, and gives you the exact configuration to copy onto a fresh Debian or Ubuntu server.

Goal and scope

By the end of this article you will have a working nginx server block that serves a WordPress site at https://yoursite.nl over HTTPS, routes every permalink through index.php, passes PHP to PHP-FPM over a Unix socket, caches static assets in the browser for a year, blocks the paths that nginx has to handle because .htaccess does not, and optionally serves a multisite network in subdirectory mode. The scope deliberately excludes FastCGI caching (covered separately in WordPress + Nginx FastCGI cache) and TLS certificate issuance, because both are big enough topics to deserve their own articles.

Prerequisites

This article overrides the default WordPress-category audience. Most wordpress KB articles assume the reader only has wp-admin and a hosting control panel. Configuring nginx server blocks is the exception: you need root or sudo access to the server itself. If you are on managed hosting and do not have shell access, this article is not the right starting point, and you should follow the managed host's own WordPress setup instructions instead.

You need:

  • A Debian 12, Ubuntu 22.04 or Ubuntu 24.04 server with root or sudo access. The paths in this article (/etc/nginx/sites-available/, /run/php/php8.3-fpm.sock, www-data as the web server user) follow Debian-family conventions. RHEL-family distributions use /etc/nginx/conf.d/, /var/run/php-fpm/www.sock, and nginx as the web server user; the directives themselves are identical but the paths differ.
  • Nginx 1.25.1 or newer. This article uses the modern http2 on; directive (introduced in nginx 1.25.1), not the older listen 443 ssl http2; syntax. If you are stuck on nginx 1.24 or earlier, the old syntax still works; see the note at the bottom of the listen block.
  • PHP 8.3 installed with PHP-FPM (php8.3-fpm package on Debian and Ubuntu). PHP 8.3 is supported through November 2026 per the PHP supported versions page; PHP 8.4 works identically and only the socket path changes.
  • WordPress already extracted to /var/www/yoursite.nl/public/ with wp-config.php in place and a database reachable. This article is about the web server, not the WordPress install.
  • A TLS certificate for the domain (typically from Let's Encrypt via certbot). This article assumes the certificate files are at the standard /etc/letsencrypt/live/yoursite.nl/ paths. The HTTPS redirect and SSL parameters are included but the issuance step is out of scope.

Why WordPress behaves differently on nginx than on Apache

On Apache, the default .htaccess file WordPress generates contains the rewrite rules for pretty permalinks, the directory index, and the rules that block direct access to wp-config.php. Apache reads .htaccess on every request and applies those rules inline. The site owner never needs to touch the Apache config.

Nginx does not read .htaccess. The nginx developers rejected .htaccess support deliberately: per-directory config files are slow (they have to be re-read on every request) and the alternative (one global config loaded at startup) is faster and more explicit. That means three categories of behavior that Apache users take for granted have to be ported into nginx config manually:

  • Permalink routing. Apache's RewriteRule . /index.php [L] has to become nginx's try_files $uri $uri/ /index.php?$args; inside the site's location / block.
  • Security rules. Apache's <Files wp-config.php> Require all denied </Files> or the rule that blocks PHP execution in wp-content/uploads/ has to become explicit nginx location blocks with deny all; or regex-based restrictions.
  • Static asset headers. Apache modules like mod_expires and mod_deflate are handled in nginx with expires and gzip directives that live in the server block.

The practical consequence: a WordPress nginx config is a little longer than a WordPress Apache setup, and every WordPress-on-nginx article that says "just use this location / block" is incomplete.

Anatomy of a WordPress server block

A production-ready nginx server block for WordPress has five parts, in roughly this order inside the file:

  1. A port-80 server block that redirects all plain HTTP to HTTPS.
  2. The main server block listening on 443 with TLS and HTTP/2.
  3. Inside the main block: root, index, the permalink try_files rule, the PHP-FPM pass, static asset caching, and the security location blocks.
  4. If the site is a multisite subdirectory network: an extra rewrite block for subsite routing.
  5. Optional: a separate server block that redirects www.yoursite.nl to the non-www domain (or the reverse).

The full assembled configuration is at the end of this article. The sections in between explain each part.

This is the line that makes WordPress permalinks work. Inside the main server block:

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

The try_files directive checks the arguments from left to right and uses the first one that exists. For a request to /2026/04/my-post/, nginx checks:

  1. $uri: is there a file at /var/www/yoursite.nl/public/2026/04/my-post/? No, there is not (WordPress posts are virtual URLs, not directories on disk).
  2. $uri/: is there a directory at that path with an index file? No.
  3. /index.php?$args: fall through to WordPress, passing along any query string. WordPress reads the original request from $_SERVER['REQUEST_URI'] and resolves it through its rewrite engine.

For a request to /wp-content/uploads/2026/03/hero.jpg, the same three-stage check succeeds at step one: the file exists on disk, so nginx serves it directly without ever invoking PHP. This is why WordPress-on-nginx is fast: static files never touch PHP, and dynamic URLs hit PHP exactly once.

Expected output: With this block in place and Settings > Permalinks set to "Post name" or any other pretty format, https://yoursite.nl/sample-page/ returns the rendered page and https://yoursite.nl/wp-content/uploads/2024/01/hero.jpg returns the image directly. You can confirm static files are not hitting PHP by watching the PHP-FPM access log: only the dynamic URLs should appear there.

The single most common mistake on this block is using /index.php$is_args$args instead of /index.php?$args. Both work. The subtle difference: $is_args evaluates to ? if there are arguments and empty otherwise, while the ? literal always produces ?. The WordPress docs use ?$args; the SpinupWP reference config uses $is_args$args. Either is correct; do not mix them.

Configuring the PHP-FPM pass

The PHP-FPM handoff is the second load-bearing directive. Place this inside the main server block, after the location / block:

location ~ \.php$ {
    # Guard: only hit PHP-FPM for files that actually exist.
    try_files $uri =404;

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

Three things in this block matter.

The try_files $uri =404; guard. Without it, nginx happily hands a request like /wp-content/uploads/malicious.jpg/actually.php to PHP-FPM, which (depending on how SCRIPT_FILENAME is resolved) can execute an attacker-uploaded file as PHP. This is a class of bug known as the PHP-FPM path traversal vulnerability. The guard ensures the URL maps to a real file on disk before PHP-FPM sees it.

The fastcgi_pass unix:...sock line. On a single-server setup where nginx and PHP-FPM run on the same machine, a Unix domain socket is marginally faster than a TCP connection because it skips the three-way TCP handshake and the kernel network stack. The PHP-FPM documentation describes both syntaxes: listen = /run/php/php8.3-fpm.sock for a socket and listen = 127.0.0.1:9000 for TCP. For most WordPress sites the socket is the right default. Use TCP when PHP-FPM runs on a separate server, in a separate container, or when you need to load-balance across multiple PHP-FPM pools. The socket must be readable and writable by the nginx user (www-data on Debian and Ubuntu), which the default listen.owner = www-data and listen.mode = 0660 settings in the PHP-FPM pool config already handle.

The SCRIPT_FILENAME parameter. The include fastcgi_params; line pulls in the standard set of FastCGI variables shipped with nginx, but it does not set SCRIPT_FILENAME. PHP-FPM uses this parameter to decide which PHP file to execute, and if it is missing or wrong, PHP-FPM either returns "No input file specified" or executes the wrong file. The value $document_root$fastcgi_script_name combines the site's root directive with the path portion of the URL (e.g., /index.php), producing a real absolute path. Do not use $request_filename here unless you have read the nginx source and understand the edge cases; $document_root$fastcgi_script_name is the canonical and safe form.

Socket versus TCP: a short decision guide

Factor Unix socket TCP on 127.0.0.1
Latency Marginally lower (no TCP handshake) Slightly higher
Throughput at very high concurrency Lower (single socket file) Higher (multiple connections)
Works across machines No Yes
Works across containers Only with shared volume Yes
Default on Debian and Ubuntu Yes No

For a single-server WordPress install, the Unix socket is the default and the right choice. Switch to TCP if you run PHP-FPM in a separate Docker container or on a dedicated app server. The EasyEngine socket-vs-TCP comparison notes that on very high-concurrency servers TCP can scale better because the socket file becomes a single contention point, but for a typical WordPress site you will never reach the concurrency level where that matters.

Static asset caching

Nginx can set browser caching headers on static files so that repeat visitors do not re-download the same images, CSS, and JavaScript on every page load. Inside the main server block:

# Cache images, video, audio, fonts, and modern image formats for a year.
location ~* \.(?:jpg|jpeg|gif|png|avif|webp|ico|svg|mp4|mp3|ogg|ogv|webm)$ {
    expires 1y;
    access_log off;
}

# Cache CSS and JavaScript for a year.
location ~* \.(?:css|js)$ {
    expires 1y;
    access_log off;
}

# Cache web fonts for a year and add CORS so they load across subdomains.
location ~* \.(?:woff|woff2|ttf|otf|eot)$ {
    expires 1y;
    access_log off;
    add_header Access-Control-Allow-Origin *;
}

The expires 1y directive sets both Expires and Cache-Control: max-age=31536000 headers. A year is the industry standard for versioned static assets and is what SpinupWP's reference config uses in static-files.conf. The access_log off keeps your access log readable by excluding the 200-plus static requests per page load; if you need to debug static asset serving later, you can re-enable it temporarily. The CORS header on fonts is there so that a CDN or subdomain pointing at your uploads can load fonts without hitting a cross-origin block.

Gzip can be enabled globally in /etc/nginx/nginx.conf with gzip on; and a gzip_types list. On Debian and Ubuntu the default nginx.conf already includes a reasonable gzip block, so no per-site change is usually necessary. Brotli requires an extra module (libnginx-mod-brotli on Debian testing, or a custom build) and is a nice-to-have rather than a must-have.

Security rules nginx needs because .htaccess is ignored

These are the rules that Apache WordPress users get "for free" from the bundled .htaccess. On nginx they have to be explicit. Place them inside the main server block:

# Block direct access to hidden files like .htaccess, .htpasswd, .git.
# The exception allows Let's Encrypt renewals via /.well-known/.
location ~* /\.(?!well-known\/) {
    deny all;
}

# Block direct access to config files, logs, and includes by extension.
location ~ \.(ini|log|conf)$ {
    deny all;
}

# Block PHP execution inside the uploads directory.
# Works for single-site uploads (wp-content/uploads) and multisite (wp-content/blogs.dir/*/files).
location ~* /(?:uploads|files)/.*\.php$ {
    deny all;
}

# Block direct access to wp-config.php from outside.
location = /wp-config.php {
    deny all;
}

# Block xmlrpc.php unless you specifically need it (Jetpack, certain mobile apps).
# Comment out if you actively use XML-RPC.
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

Why each of these matters:

  • Hidden files. .git, .env, .DS_Store, and .htaccess itself should never be served. The regex /\.(?!well-known\/) blocks every path segment that starts with a dot, except for /.well-known/ (which RFC 8615 reserves for protocol metadata like Let's Encrypt's HTTP-01 challenge and security.txt). A common mistake I see is a blanket location ~ /\. block that breaks Let's Encrypt renewals silently.
  • PHP in uploads. WordPress allows logged-in users (with the upload_files capability) to upload images. If an attacker finds a way to upload a .php file (through a vulnerable plugin, a media-library bug, or a file-extension check that only looks at the MIME type), the file is served directly by nginx as executable PHP unless this rule is in place. Blocking .php inside /uploads/ and /files/ is the belt-and-braces defence that turns "code execution" into "404". The SpinupWP reference config ships this exact rule in exclusions.conf and the WordPress developer docs recommend the same.
  • wp-config.php. WordPress puts the database password, table prefix, and auth salts in wp-config.php. If a misconfigured PHP handler causes the file to be served as plain text instead of executed, every secret in the file is suddenly public. The explicit deny all; on /wp-config.php is a second line of defence on top of the .php location block.
  • xmlrpc.php. WordPress's XML-RPC endpoint is the historical target of brute-force login attacks and amplification attacks. If you do not use Jetpack, the WordPress mobile app, or a plugin that requires XML-RPC, block it. If you do use one of those, leave this rule commented out and rely on application-level hardening instead.

Hardening headers (X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security) are also worth adding. They are covered in WordPress security hardening and I will not duplicate the full list here.

Multisite: the extra rewrite rules

Single-site WordPress and WordPress subdomain multisite both work with just try_files $uri $uri/ /index.php?$args;. Subdirectory multisite is different: the subsite URL has a prefix segment that needs to be stripped before the request is handed to PHP. Without the extra rewrite block, requests to https://yoursite.nl/marketing/wp-admin/ end up as https://yoursite.nl/marketing/wp-admin/ and produce a 404 because there is no marketing/wp-admin directory on disk.

The canonical rewrite block, documented in the WordPress nginx recipes and shipped in SpinupWP's multisite-subdirectory.conf, goes inside the main server block, above the location / block:

# Subdirectory multisite rewrites: strip the subsite prefix before routing.
# Only applies if a request filename doesn't exist on disk.
if (!-e $request_filename) {
    rewrite /wp-admin$ $scheme://$host$request_uri/ permanent;
    rewrite ^(/[^/]+)?(/wp-.*) $2 last;
    rewrite ^(/[^/]+)?(/.*\.php) $2 last;
}

What each line does:

  • rewrite /wp-admin$ ... permanent; adds a trailing slash to requests that end in /wp-admin (no slash). Without this, WordPress occasionally emits a 301 loop on subsite admin URLs.
  • rewrite ^(/[^/]+)?(/wp-.*) $2 last; matches a leading subsite slug (/marketing) followed by any /wp-* path (/wp-admin/, /wp-content/, /wp-includes/, /wp-login.php) and strips the subsite prefix. After the rewrite, the request looks like a single-site request and the later blocks handle it normally.
  • rewrite ^(/[^/]+)?(/.*\.php) $2 last; does the same thing for any .php URL under a subsite prefix.
  • if (!-e $request_filename) guards the whole block so that real files on disk (images in /wp-content/uploads/, static assets) are served directly and never rewritten.

The WordPress developer docs use an if block here even though the nginx documentation warns against if inside location. The warning is real but does not apply at server-block scope, and the -e file-existence check is one of the cases where if is explicitly safe.

Subdomain multisite does not need this block. In subdomain mode, each subsite is a separate hostname (e.g., marketing.yoursite.nl), and nginx's server_name directive routes the request to the right server block without any URL rewriting. See WordPress multisite setup for the full multisite decision guide.

Debugging: where nginx and PHP-FPM write their logs

When something is broken, the answer is almost always in one of four log files. On Debian and Ubuntu they live at:

  • /var/log/nginx/error.log for nginx-level errors: bad rewrite rules, missing files, permission errors on the web root, SSL handshake failures.
  • /var/log/nginx/access.log for the full request log.
  • /var/log/php8.3-fpm.log for PHP-FPM pool errors: worker crashes, pool exhaustion, socket permission problems.
  • wp-content/debug.log inside the WordPress directory, if you have enabled WP_DEBUG_LOG in wp-config.php. This is where WordPress itself logs PHP warnings and errors.

Run sudo tail -f /var/log/nginx/error.log /var/log/php8.3-fpm.log in one terminal while you reload the site in another. Nine times out of ten the error message is explicit. Common patterns:

  • FastCGI sent in stderr: "Primary script unknown": SCRIPT_FILENAME is wrong or the file does not exist on disk. Check that the root directive points at the correct directory and that $document_root$fastcgi_script_name resolves to a real file.
  • connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied): the nginx user cannot read the PHP-FPM socket. Check listen.owner, listen.group, and listen.mode in /etc/php/8.3/fpm/pool.d/www.conf against the nginx user (www-data).
  • upstream sent too big header: a PHP script is generating a very large response header. Increase fastcgi_buffer_size and fastcgi_buffers in the PHP location block.
  • client intended to send too large body: the request is larger than client_max_body_size. The nginx default is 1 MB. WordPress uploads fail against this limit; set client_max_body_size 64m; in the server block to match the PHP upload_max_filesize.

For a broader field guide on reading logs, see enabling and reading the WordPress debug log.

Verify the final result

Once the config is in place, run these checks in order:

  1. Syntax check: sudo nginx -t. Must print syntax is ok and test is successful. If it does not, the output tells you the exact file and line that is wrong.
  2. Reload: sudo systemctl reload nginx. No output is the success case.
  3. Static file test: curl -sI https://yoursite.nl/wp-includes/js/wp-emoji-release.min.js | head -5. Expect HTTP/2 200 and a cache-control: max-age=31536000 header (the one-year static cache). If you get 404, the root is wrong; if you get HTTP/1.1 200 with no cache-control, the static-asset block is not matching.
  4. Permalink test: Set Settings > Permalinks to "Post name" in wp-admin. Visit https://yoursite.nl/sample-page/. It should render the page. If it 404s with an nginx error page, the try_files block is missing or wrong.
  5. wp-admin test: Log into https://yoursite.nl/wp-admin/. Pages should render with styles. If wp-admin loads as plain HTML with no CSS, wp-includes/ is not being served as static files (usually a regex that is too aggressive).
  6. Security rule test: curl -sI https://yoursite.nl/wp-config.php. Expect HTTP/2 403. curl -sI https://yoursite.nl/wp-content/uploads/test.php (even if the file does not exist): expect HTTP/2 403.
  7. Log check: Tail /var/log/nginx/error.log while browsing the site. There should be no warnings or errors during normal navigation.

You will know the server block is working correctly when all seven checks pass.

What a WordPress-on-nginx setup is NOT

Because this article collects a lot of small rules into one config, three misconceptions are worth flagging explicitly.

"WordPress's built-in rewrite rules work on Nginx like on Apache." They do not. WordPress writes .htaccess on permalink save; nginx ignores .htaccess entirely. The permalink rewrite has to be configured in nginx with try_files or an explicit rewrite rule. Clicking "Save" on Settings > Permalinks in wp-admin on an nginx site does not change anything the web server sees.

"The location ~ \.php$ block is optional if I'm using PHP-FPM." It is not optional. Without a location block that matches .php and calls fastcgi_pass, nginx serves .php files as plain text to the browser. The first time a visitor hits https://yoursite.nl/wp-config.php on a misconfigured server, they get your database password in their browser window. The PHP location block is what turns .php URLs into "execute via PHP-FPM" instead of "serve as static file". If you see raw PHP source code in the browser, this is the block that is missing.

"Nginx ignores .htaccess files, so security rules are unnecessary." This is the opposite of the correct conclusion. Apache's .htaccess is what blocks direct access to wp-config.php, blocks PHP execution in wp-content/uploads/, and denies access to .git and other hidden files. Because nginx ignores .htaccess, every one of those rules has to be ported into the nginx server block or the protection disappears. "No .htaccess" does not mean "no rules"; it means the rules have to live somewhere else.

Complete final configuration

Here is the assembled server block, in the order it should appear in /etc/nginx/sites-available/yoursite.nl. Symlink it into /etc/nginx/sites-enabled/ with sudo ln -s /etc/nginx/sites-available/yoursite.nl /etc/nginx/sites-enabled/, then sudo nginx -t && sudo systemctl reload nginx.

# Redirect all plain HTTP to HTTPS.
server {
    listen 80;
    listen [::]:80;
    server_name yoursite.nl www.yoursite.nl;
    return 301 https://yoursite.nl$request_uri;
}

# Redirect www to non-www over HTTPS.
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.yoursite.nl;

    ssl_certificate     /etc/letsencrypt/live/yoursite.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yoursite.nl/privkey.pem;

    return 301 https://yoursite.nl$request_uri;
}

# Main WordPress server block.
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                               # nginx 1.25.1+; older: add 'http2' to listen
    server_name yoursite.nl;

    root  /var/www/yoursite.nl/public;
    index index.php;

    # TLS. Issued via certbot; renewal handled by the certbot systemd timer.
    ssl_certificate     /etc/letsencrypt/live/yoursite.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yoursite.nl/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Per-site logs.
    access_log /var/log/nginx/yoursite.nl.access.log;
    error_log  /var/log/nginx/yoursite.nl.error.log;

    # Allow WordPress media uploads up to 64 MB (match PHP upload_max_filesize).
    client_max_body_size 64m;

    # Hide the nginx version in Server: headers.
    server_tokens off;

    # Security headers (basic set; see wordpress-security-hardening for more).
    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;

    # -----------------------------------------------------------------------
    # Multisite subdirectory rewrites. Delete this block for single-site or
    # subdomain multisite.
    # -----------------------------------------------------------------------
    # if (!-e $request_filename) {
    #     rewrite /wp-admin$ $scheme://$host$request_uri/ permanent;
    #     rewrite ^(/[^/]+)?(/wp-.*) $2 last;
    #     rewrite ^(/[^/]+)?(/.*\.php) $2 last;
    # }

    # WordPress permalink routing: fall through to index.php if no file matches.
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # Static asset caching: images, video, audio, SVG, modern formats.
    location ~* \.(?:jpg|jpeg|gif|png|avif|webp|ico|svg|mp4|mp3|ogg|ogv|webm)$ {
        expires 1y;
        access_log off;
    }

    # Static asset caching: CSS and JavaScript.
    location ~* \.(?:css|js)$ {
        expires 1y;
        access_log off;
    }

    # Static asset caching: web fonts, with CORS for cross-subdomain loading.
    location ~* \.(?:woff|woff2|ttf|otf|eot)$ {
        expires 1y;
        access_log off;
        add_header Access-Control-Allow-Origin *;
    }

    # Security: block hidden files except /.well-known/ (Let's Encrypt).
    location ~* /\.(?!well-known\/) {
        deny all;
    }

    # Security: block config, log, and includes files by extension.
    location ~ \.(ini|log|conf)$ {
        deny all;
    }

    # Security: block PHP execution inside uploads (single-site and multisite).
    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
    }

    # Security: block direct access to wp-config.php as a second line of defence.
    location = /wp-config.php {
        deny all;
    }

    # Security: block xmlrpc.php. Comment out if you use Jetpack or the mobile app.
    location = /xmlrpc.php {
        deny all;
        access_log off;
        log_not_found off;
    }

    # PHP handoff: pass to PHP-FPM via Unix socket.
    location ~ \.php$ {
        try_files $uri =404;                # reject requests for non-existent .php files
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_index index.php;
        fastcgi_read_timeout 300;           # allow up to 5 min for long imports
    }
}

Apply with:

sudo nginx -t && sudo systemctl reload nginx

For the other end of the server stack (full-page caching in nginx so PHP only runs on cache misses), continue with WordPress + Nginx FastCGI cache. If this server block is part of a migration from another host, the overall move is covered in how to migrate WordPress to a new host or domain. And when PHP workers start to pile up under traffic, the sizing work continues in PHP-FPM tuning for WordPress.

Need a WordPress fix or custom feature?

From error fixes to performance improvements, I build exactly what's needed—plugins, integrations, or small changes without bloat.

Explore web development

Search this site

Start typing to search, or browse the knowledge base and blog.