Runbook: Redirect an old subdomain to a new domain via Mailcow's nginx

Outcome: Redirect all HTTP and HTTPS traffic from an old subdomain to a new domain, using mailcow’s nginx and acme for SSL — so the redirect works reliably for all visitors regardless of browser HTTPS enforcement.

When to use: When you’ve moved a self-hosted service (e.g. Discourse) to a new domain and want the old URL to keep working as a redirect. DNS-provider URL redirects (e.g. Namecheap) are not a reliable substitute — they typically fail silently on HTTPS because they don’t hold a valid SSL certificate for the old domain.

Prerequisites:

  • Mailcow is running at /opt/mailcow-dockerized on your server
  • The old subdomain (=OLD_SUBDOMAIN=) still has its DNS A/AAAA record pointing to =OLD_MAIL_HOST=
  • The new domain (=NEW_DOMAIN=) is already live and working
  • All commands are run as root from /opt/mailcow-dockerized unless otherwise noted

Step 1 — Add the old subdomain to ADDITIONAL_SAN

Mailcow’s acme container manages all SSL certificates. The old subdomain needs a valid cert so that HTTPS visitors receive the redirect rather than a TLS error.

nano /opt/mailcow-dockerized/mailcow.conf

Find the ADDITIONAL_SAN line and add =OLD_SUBDOMAIN=:

ADDITIONAL_SAN=...,=OLD_SUBDOMAIN=

Then force-recreate the acme container and watch for success:

cd /opt/mailcow-dockerized
docker compose up -d --force-recreate acme-mailcow
docker compose logs -f acme-mailcow

You’re looking for:

=OLD_SUBDOMAIN= verified!
Certificate signed!

:bulb: The AUTH failed: ERR AUTH messages in the acme logs are a harmless mailcow quirk — Redis has no password configured. Ignore them.


Step 2 — Create the nginx redirect config

nano /opt/mailcow-dockerized/data/conf/nginx/=NGINX_CONF=.conf

Paste in:

# Redirect HTTP → HTTPS → new domain
server {
    listen 80;
    listen [::]:80;
    server_name =OLD_SUBDOMAIN=;

    location ^~ /.well-known/acme-challenge/ {
        allow all;
        default_type "text/plain";
        root /web;
    }

    location / {
        return 301 https://=NEW_DOMAIN=$request_uri;
    }
}

# Redirect HTTPS → new domain
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name =OLD_SUBDOMAIN=;

    ssl_certificate /etc/ssl/mail/cert.pem;
    ssl_certificate_key /etc/ssl/mail/key.pem;

    location / {
        return 301 https://=NEW_DOMAIN=$request_uri;
    }
}

:warning: The HTTPS server block is essential. Modern browsers force HTTPS, so without it, HTTPS visitors will get a TLS error and never see the redirect at all.


Step 3 — Test and reload nginx

cd /opt/mailcow-dockerized
docker compose exec nginx-mailcow nginx -t && \
docker compose exec nginx-mailcow nginx -s reload

The -t flag tests the config first — if there are errors, nginx will report exactly which line before anything is changed.


Step 4 — Verify the redirects are working

# HTTP → new domain (expect 301)
curl -I http://=OLD_SUBDOMAIN=

# HTTPS → new domain (expect 301)
curl -I https://=OLD_SUBDOMAIN=

Both should return:

HTTP/1.1 301 Moved Permanently
Location: https://=NEW_DOMAIN=/

Troubleshooting

TLS error / connection hangs on HTTPS — The cert hasn’t been issued yet for =OLD_SUBDOMAIN=. Confirm the domain is in ADDITIONAL_SAN (Step 1) and re-run the acme container. Watch the logs until you see Certificate signed!.

nginx -t fails — Check for a conflicting .conf file in /opt/mailcow-dockerized/data/conf/nginx/ that also has =OLD_SUBDOMAIN= as a server_name. Rename the old one to .conf.disabled to deactivate it:

mv /opt/mailcow-dockerized/data/conf/nginx/oldfile.conf \
   /opt/mailcow-dockerized/data/conf/nginx/oldfile.conf.disabled

curl returns 000 — The SSL certificate doesn’t cover =OLD_SUBDOMAIN= yet. See the TLS error fix above.

Redirect goes to the wrong place — Double-check the return 301 line in both server blocks points to https://=NEW_DOMAIN= and not to $host (which would loop back to the old domain).


Cleanup — when the old domain is no longer needed

Once traffic to =OLD_SUBDOMAIN= has fully died off (check your analytics), you can clean up:

  1. Remove =OLD_SUBDOMAIN= from ADDITIONAL_SAN in mailcow.conf
  2. Delete or disable the nginx config:
mv /opt/mailcow-dockerized/data/conf/nginx/=NGINX_CONF=.conf \
   /opt/mailcow-dockerized/data/conf/nginx/=NGINX_CONF=.conf.disabled
  1. Reload nginx and force-recreate acme to drop the domain from the cert:
cd /opt/mailcow-dockerized
docker compose exec nginx-mailcow nginx -s reload
docker compose up -d --force-recreate acme-mailcow

:link: Related topics: