WordPress multisite domain mapping: pointing external domains to subsites

An end-to-end walkthrough of mapping an external domain like client1.com to a WordPress multisite subsite originally created as client1.network.com: the DNS records, the per-domain nginx server block, the Let's Encrypt certificate, the Network Admin URL change, the COOKIE_DOMAIN fix that prevents login redirect loops, and the URL search-replace that keeps internal links consistent. No plugin required since WordPress 4.5.

Goal

By the end of this article you will have mapped an external domain like client1.com to a WordPress multisite subsite that was originally created inside the network at client1.network.com. Visitors who hit https://client1.com will get the subsite over HTTPS, the WordPress admin will work on both the mapped and the original URL without login redirect loops, and internal content links will point at the canonical mapped domain.

Native multisite domain mapping has been part of WordPress core since version 4.5, released on April 12, 2016. The old WPMU Domain Mapping plugin is obsolete, was closed on October 5, 2025 at the author's request, and is no longer available for download from the plugin directory. Do not install it on a modern WordPress site.

Prerequisites

This article overrides the default wordpress category audience. Mapping a domain involves DNS, nginx config, and TLS certificate issuance, so you need shell access to your server. If you are on managed hosting without SSH, follow your host's own domain-mapping instructions: most managed platforms (Kinsta, WP Engine, Pressable, and similar) handle the nginx and SSL pieces behind their dashboard and only expose the Network Admin and wp-config.php steps.

You need:

  • A working WordPress multisite network. This article assumes you already have a running network (WordPress 6.x with MULTISITE and SUBDOMAIN_INSTALL already defined in wp-config.php). If you do not, start with the WordPress multisite setup guide first.
  • A subsite already created inside the network. Create the subsite through Network Admin → Sites → Add New before mapping, using a placeholder URL like client1.network.com. This guarantees that a wp_blogs row, a per-site database table set, and the admin user linkage already exist.
  • Root or sudo access to a Debian 12, Ubuntu 22.04, or Ubuntu 24.04 server running nginx 1.25.1 or later and PHP-FPM 8.3. The paths in this article follow Debian-family conventions (/etc/nginx/sites-available/, /etc/letsencrypt/live/, www-data).
  • Control over DNS for the external domain. You must be able to add an A record (or AAAA for IPv6) at the DNS provider for client1.com.
  • certbot installed with the nginx plugin (certbot and python3-certbot-nginx packages on Debian and Ubuntu). Certbot automates the Let's Encrypt ACME challenge and writes the certificate files to /etc/letsencrypt/live/<domain>/.
  • WP-CLI installed on the server. Used in the final step to run the URL search-replace safely over serialized data.
  • A full backup of the database and the wp-content directory. The mapping process edits wp_blogs, wp_options on the subsite, and wp-config.php. Having a rollback path costs nothing and saves everything.

What native domain mapping does and does not do

Before WordPress 4.5, mapping a subsite to a custom domain required the WPMU Domain Mapping plugin. WordPress 4.5 merged the feature into core: the Network Admin dashboard now accepts any domain in a subsite's Site Address (URL) field, and WordPress's routing layer resolves incoming requests for that domain to the correct subsite.

What WordPress took over is limited to the routing decision. Everything outside WordPress's own boundary still has to be configured by you:

  • DNS. WordPress does not touch your DNS. You add the A record at your provider.
  • The web server. WordPress does not generate nginx or Apache config. You add the server block.
  • TLS certificates. WordPress does not issue certificates. You run certbot.
  • Cookies. WordPress's default cookie behavior breaks when a single session has to span multiple top-level domains. You set COOKIE_DOMAIN explicitly in wp-config.php.

The official WordPress multisite domain mapping documentation lists these four prerequisites but is thin on how to actually do them. That is what this article covers.

Two things native domain mapping does not require:

  • No plugin. Any article or forum reply that tells you to install WPMU Domain Mapping is outdated. The plugin is closed, unmaintained for eight years, and its redirect logic conflicts with core's own domain resolution on modern WordPress.
  • No sunrise.php. The sunrise.php drop-in is only needed when you want multiple domains to resolve to the same subsite (vanity aliases, regional variants, canonical redirects implemented in PHP). For the one-domain-per-subsite case this article covers, you do not need it and enabling SUNRISE adds complexity with no benefit.

Step 1: Add DNS records for the new domain

At your DNS provider, create an A record for client1.com pointing to the server's public IPv4 address. If the network already lives behind a CDN or reverse proxy (Cloudflare, a load balancer), point the record at the same target the primary network domain already uses.

Type   Name   Value                 TTL
A      @      203.0.113.10          300
A      www    203.0.113.10          300
AAAA   @      2001:db8::10          300

Keep the TTL short (300 seconds) during the cutover so you can correct a mistake quickly. You can raise it to 3600 or higher once the mapping is verified.

Expected output: After DNS propagation (usually 1 to 10 minutes at TTL 300), dig +short client1.com returns the server IP.

# Verify DNS propagation from the server itself
dig +short client1.com

If dig returns nothing or the old target, wait a minute and retry. Do not proceed to step 2 until DNS resolves correctly. If you configure nginx and request a certificate before DNS is pointing at the server, certbot's HTTP-01 challenge will fail because Let's Encrypt verifies by fetching http://client1.com/.well-known/acme-challenge/... from the public internet.

Step 2: Configure the nginx server block for the mapped domain

Each mapped domain needs its own nginx server block because each domain gets its own TLS certificate. Wildcard certificates only cover one level of subdomains under a single apex (*.network.com covers client1.network.com but not client1.com), so a multi-domain network almost always runs separate certificates issued per mapped domain and selected at connection time via Server Name Indication (SNI).

Create /etc/nginx/sites-available/client1.com with the following content. The root directive points at the same wp-content parent as the rest of the network, because in a WordPress multisite every subsite shares one physical filesystem.

# Port 80: respond to HTTP for the ACME challenge, then redirect
server {
    listen 80;
    listen [::]:80;
    server_name client1.com www.client1.com;

    # Let certbot answer the HTTP-01 challenge before the redirect fires
    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://client1.com$request_uri;
    }
}

# Port 443: the actual mapped subsite
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name client1.com www.client1.com;

    # Point at the network root. WordPress figures out the subsite
    # from the request's Host header, not from the filesystem path.
    root /var/www/network.com/public;
    index index.php;

    # TLS certificate: filled in by certbot in the next step
    ssl_certificate     /etc/letsencrypt/live/client1.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/client1.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Permalink routing: identical to the main network's server block
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP-FPM pass
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    # Block direct access to sensitive files
    location ~ /\.ht { deny all; }
    location = /wp-config.php { deny all; }
}

Do not add the certificate lines yet if the files do not exist: certbot will add them in step 3. For now, comment out the four ssl_* lines and leave a plain server block on port 80 so certbot can bootstrap the first certificate. Symlink the file into sites-enabled and reload nginx:

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

Expected output: nginx -t reports syntax is ok and test is successful. systemctl reload nginx returns no output. A curl -I http://client1.com/ from any machine with public internet access returns either a 301 redirect to HTTPS (once the certificate is in place) or hits the port-80 block and serves a WordPress page without valid routing (because WordPress does not yet know about the mapped domain).

Step 3: Issue a TLS certificate with certbot

With the port-80 block live and DNS resolving, issue a certificate for the mapped domain:

sudo certbot --nginx -d client1.com -d www.client1.com

Certbot uses the HTTP-01 challenge, writes a temporary file under /.well-known/acme-challenge/, asks Let's Encrypt to verify it over HTTP, and on success edits your nginx config to enable HTTPS with the new certificate. It also installs an auto-renewal cron (or systemd timer) that runs twice a day.

Expected output: Certbot prints the path to the issued certificate and reloads nginx automatically.

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/client1.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/client1.com/privkey.pem
This certificate expires on 2026-07-18.

Let's Encrypt's per-registered-domain rate limit is 50 new certificates every seven days, which is rarely a concern for typical multisite networks but worth knowing if you are mass-migrating tens of client domains. Use the staging environment (--staging) for testing to avoid burning real issuances.

Step 4: Set the mapped URL in Network Admin

Log in to the network admin at https://network.com/wp-admin/network/. Navigate to Sites → All Sites, find the subsite currently at client1.network.com, and click Edit.

On the Info tab, change the Site Address (URL) from https://client1.network.com to https://client1.com. Save.

WP-CLI does the same thing in one command if you prefer scripting:

# Find the subsite's blog_id
wp site list --field=url --field=blog_id

# Update the Site Address (URL) and domain for blog_id 2
wp site meta update 2 url https://client1.com/
wp db query "UPDATE wp_blogs SET domain = 'client1.com', path = '/' \
             WHERE blog_id = 2"

After this change, WordPress's routing layer recognizes client1.com as belonging to that subsite. But the existing content on the subsite still contains hardcoded references to the old URL, and the cookie domain is still wrong. Steps 5 and 6 fix those.

WordPress's default behavior is to set the auth cookie on the primary network domain (network.com in this example). When a visitor logs in on a mapped domain like client1.com, WordPress sets the cookie correctly for that request but then tries to validate it against the network domain on the next request, and the login appears to fail silently. This is the classic multisite redirect loop on mapped domains.

The fix is to tell WordPress to scope the cookie to the incoming host, which on a mapped subsite will be the mapped domain. Add this line to wp-config.php above the /* That's all, stop editing! Happy publishing. */ marker, immediately after the multisite defines:

// Let the cookie domain follow the incoming request host so that
// logins work on any mapped domain in the network.
define( 'COOKIE_DOMAIN', $_SERVER['HTTP_HOST'] );

The official WordPress multisite domain mapping documentation recommends exactly this configuration for the same reason: "If login errors or cookie-blocking issues occur, add define( 'COOKIE_DOMAIN', $_SERVER['HTTP_HOST'] ); after existing network code." An alternative you will see in older articles is setting COOKIE_DOMAIN to an empty string, which also works but is slightly less strict (the browser infers the cookie domain from the response host, so functionally they are close). Use the $_SERVER['HTTP_HOST'] form; it is what the WordPress core documentation shows.

Expected output: Reload the subsite admin at https://client1.com/wp-admin/, log out, and log back in. The login succeeds without a redirect loop.

Step 6: Run a URL search-replace on the subsite

The subsite's database still contains the old URL baked into option values (siteurl, home), post content (image URLs, internal links), and serialized widget and theme settings. A naive UPDATE ... SET content = REPLACE(...) breaks PHP-serialized data by corrupting the length prefixes. Always use a serialization-aware tool. WP-CLI's search-replace is the safest option and the only one the WordPress developer documentation recommends.

Run it scoped to the target subsite with --url:

# Dry run first: see exactly which tables will be touched
wp search-replace 'https://client1.network.com' 'https://client1.com' \
    --url=https://client1.com \
    --all-tables-with-prefix \
    --skip-columns=guid \
    --dry-run

# Commit the changes
wp search-replace 'https://client1.network.com' 'https://client1.com' \
    --url=https://client1.com \
    --all-tables-with-prefix \
    --skip-columns=guid

A few flag notes:

  • --url=https://client1.com scopes WP-CLI to the subsite you are mapping, not the primary network site. Without this flag WP-CLI operates on blog ID 1 and misses the subsite's own tables.
  • --all-tables-with-prefix includes the subsite's wp_2_* tables (where 2 is the subsite's blog_id).
  • --skip-columns=guid preserves the guid column on posts. WordPress uses guid as the permanent post identifier that feed readers and external systems rely on; changing it breaks subscriptions and should never be part of a URL replace.
  • --dry-run on the first pass prints the replacement count per table without writing anything. Always run it first.

Expected output: The dry run reports something like Success: 147 replacements to be made across 4 to 8 tables. The committed run reports the same counts as replacements made.

Verify the mapped domain works end-to-end

Run through this quick checklist before considering the mapping done:

  1. DNS: dig +short client1.com returns the server IP.
  2. HTTPS: curl -I https://client1.com/ returns a 200 OK with a valid certificate and the WordPress response headers (link: <https://client1.com/wp-json/>; rel="https://api.w.org/").
  3. Redirect: curl -I http://client1.com/ returns a 301 to https://client1.com/.
  4. Frontend: Visiting https://client1.com/ in a browser renders the subsite's homepage with assets served from https://client1.com/wp-content/... (not https://client1.network.com/wp-content/...).
  5. Admin: https://client1.com/wp-admin/ loads the subsite dashboard after login. Logging out and back in does not trigger a redirect loop.
  6. Old URL: Visiting https://client1.network.com/ either still works as an alias (WordPress accepts both domains for the subsite until you explicitly redirect one to the other) or redirects to the mapped domain depending on your nginx config.

If all six pass, the mapping is done. Repeat the whole flow for each additional subsite domain.

Subdomain versus subdirectory networks: what changes

Domain mapping works the same way on both network types in terms of the Network Admin UI change and the COOKIE_DOMAIN fix. The differences are in DNS and nginx:

Subdomain network (SUBDOMAIN_INSTALL = true):

  • Before mapping, subsites live at client1.network.com.
  • Wildcard DNS (*.network.com) and a wildcard certificate on the primary network often already exist to handle on-demand subsite creation.
  • The mapped domain (client1.com) is completely independent of the wildcard. Its server block and certificate have nothing to do with *.network.com.
  • This is the easiest setup to map from.

Subdirectory network (SUBDOMAIN_INSTALL = false):

  • Before mapping, subsites live at network.com/client1/.
  • There is no wildcard DNS because subsites do not have their own hostnames. DNS is simpler.
  • The nginx server block for the main network contains the WPMU rewrite block that routes /client1/ paths to the right subsite. The mapped domain's server block does not need this rewrite: once WordPress sees the mapped domain, it resolves straight to the subsite without needing the path-prefix routing.
  • WordPress's core routing still works, but the content you migrate from network.com/client1/ to client1.com has a path component in the original URL that the search-replace in step 6 has to handle. The wp search-replace invocation above handles this automatically as long as you pass the full URL with path ('https://network.com/client1' to 'https://client1.com').

Common misconception: "domain mapping works the same for subdomain and subdirectory networks." It does at the Network Admin level, but the nginx routing underneath is meaningfully different, and the search-replace strings you pass to WP-CLI are different. Test a subdirectory-to-external-domain mapping on a staging environment before doing it in production.

Troubleshooting: redirect loops and lost admin access

Login succeeds but immediately kicks you back to the login screen. This is the classic missing COOKIE_DOMAIN symptom. Confirm the define is in wp-config.php, confirm there is no stale COOKIE_DOMAIN with a hardcoded domain left over from a previous install, clear the browser cookies for both the mapped and the network domain, and log in again. If the problem persists, check that your caching layer (Varnish, Cloudflare, an object cache) is varying by host header; otherwise a cached login response for one domain can land on another and confuse the browser.

Too many redirects (ERR_TOO_MANY_REDIRECTS). Usually means the nginx server block is redirecting HTTPS to itself. Check for a return 301 https://... line that is inside the port-443 block instead of the port-80 block. Also check that WordPress's siteurl and home options (now pointing at the mapped HTTPS URL after step 6) do not conflict with an HTTP-to-HTTPS redirect in nginx.

Network Admin redirects to the mapped domain instead of the network domain. This happens when DOMAIN_CURRENT_SITE in wp-config.php has been changed to the mapped domain by mistake. DOMAIN_CURRENT_SITE must always be the primary network domain (the one you used when the network was first created), never a mapped subsite domain. If it has been changed, fix it back to the network domain and the Network Admin login will work again.

The mapped domain serves the main network site instead of the subsite. WordPress did not resolve the mapped domain to a blog ID. Check wp_blogs directly: the domain column for the subsite's row must be exactly client1.com (no protocol, no trailing slash, no www.). Mismatches there are the single most common cause of "mapping does nothing" reports.

Images and stylesheets still load from the old URL. The search-replace in step 6 did not cover all tables. Re-run wp search-replace --url=https://client1.com --dry-run to see which tables still contain the old URL, and widen the replace if needed (some plugin tables live outside the wp_2_* prefix and need their own --all-tables pass).

Admin dashboard links go to the wrong domain after mapping. Core ticket #47630 tracked this class of issue: some admin-bar links use network_admin_url() which always points at the network domain, which is correct behavior but confusing when you expected them to stay on the mapped domain. The network admin genuinely lives on the primary network domain and is not mappable; a client who logs in to edit their subsite should use https://client1.com/wp-admin/ (not the Network Admin) for day-to-day work.

Complete final configuration

For reference, here is the full set of files after all six steps, so you do not have to reassemble them from the section-by-section code blocks:

/etc/nginx/sites-available/client1.com:

server {
    listen 80;
    listen [::]:80;
    server_name client1.com www.client1.com;

    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://client1.com$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name client1.com www.client1.com;

    root /var/www/network.com/public;
    index index.php;

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

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\.ht { deny all; }
    location = /wp-config.php { deny all; }
}

The multisite block of wp-config.php (only the domain-mapping-relevant lines shown):

define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', true );
define( 'DOMAIN_CURRENT_SITE', 'network.com' );  // always the primary network
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );

// Lets the auth cookie follow the mapped domain so logins work on
// every subsite URL in the network.
define( 'COOKIE_DOMAIN', $_SERVER['HTTP_HOST'] );

Database state for the mapped subsite:

-- wp_blogs row for the mapped subsite
SELECT blog_id, domain, path FROM wp_blogs WHERE blog_id = 2;
-- Expected: 2 | client1.com | /

-- siteurl and home options on the subsite
SELECT option_name, option_value FROM wp_2_options
WHERE option_name IN ('siteurl', 'home');
-- Expected: both set to https://client1.com

For each additional mapped domain, repeat steps 1 through 6 with the new domain substituted. The wp-config.php block stays the same, one COOKIE_DOMAIN define is enough for the whole network. Only the nginx server block and the per-subsite database records differ between mapped domains.

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.