Nginx 301 Redirects with a Map: One-to-Many Slug Migrations (Working Example)
Tue Aug 26th, 2025 — 58 days ago

How to set Up Nginx 301 Redirects with an Nginx Map Example

Problem

  • You migrated routes (e.g., /blog/* to /blogs/*) and renamed some slugs; you need fast 301s at the edge.
  • You want a scalable way to manage many old→new paths without app code changes.
  • Errors like nginx: [emerg] "map" directive is not allowed here or “unexpected end of file, expecting” ; or } are blocking you.

Solutions

  • Define a map in the global http {} block, not inside any server {}; use $uri so query strings don’t break matches.
# /etc/nginx/nginx.conf (inside http { ... }, outside any server { ... })
map_hash_bucket_size 256;

map $uri $new_uri {
    default "";
    include /etc/nginx/redirects.map;
}
  • Create /etc/nginx/redirects.map with one old → new path per line, and make sure to end each line with ;.
# /etc/nginx/redirects.map
/resume      /about;
/resume/     /about/;
/blogs/react-nextjs-comparison/    /blogs/next-js-vs-react-js/;
  • Trigger the redirect early in the target server {} block (place before location /); preserve query strings.
# inside server { ... } for learnprogramming.us
if ($new_uri) {
    return 301 https://$host$new_uri$is_args$args;
}
  • Bulk path pattern (directory move) can use a simple regex location in the same server block.
# /blog/* → /blogs/*
location ~ ^/blog/(.*)$ {
    return 301 /blogs/$1;
}
  • Test the Nginx configuration (with nginx -t), reload or restart Nginx, and finally use cURL to test if the 301 worked:
sudo nginx -t && sudo nginx -s reload
curl -I https://yourdomain.tld/resume

The final result from curl should look something like this with a 301 HTTP response:

HTTP/2 301
server: nginx/1.24.0
date: Tue, 26 Aug 2025 15:52:07 GMT
content-type: text/html
content-length: 169
location: https://yourdomain.tld/about
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
permissions-policy: geolocation=(), microphone=(), camera=()
strict-transport-security: max-age=31536000; includeSubDomains; preload

Nginx 301 Moved Permanently

  • Use return 301 for permanent moves; clients and crawlers update links.
if ($new_uri) {
    return 301 https://$host$new_uri$is_args$args;
}

Nginx Map Examples

  • Exact match entries live in redirects.map; add both with and without trailing slash if your site canonicalizes slashes.
/old-slug         /new-slug;
/old-slug/        /new-slug/;

Nginx Map Directive: $new_uri Example

The $new_uri variable comes from the map directive. You set it up globally in the http {} block, then test it inside the right server {} block to trigger redirects. Without the if ($new_uri) test, the map never actually fires.

Step 1 — Define the map globally in http {}

http {
    # Required for larger maps
    map_hash_bucket_size 256;

    # Define redirects
    map $uri $new_uri {
        default "";
        include /etc/nginx/redirects.map;
    }

    # ... your server blocks here ...
}

Step 2 — Add the redirect check inside your server {}

This must be placed before any generic location / block, otherwise the catch-all proxy will swallow the request first.

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # Check the map; if there's a match, return a 301
    if ($new_uri) {
        return 301 https://$host$new_uri$is_args$args;
    }

    # Other location blocks here
    location / {
        proxy_pass http://127.0.0.1:9000;
    }
}

Step 3 — Create /etc/nginx/redirects.map

# Old path        New path
/resume           /about;
/resume/          /about/;
/old-slug         /new-slug;
/old-slug/        /new-slug/;

Why $new_uri is needed:

  • The map sets up a lookup table from old paths to new ones.
  • $new_uri is just a variable until you explicitly test it with if ($new_uri).
  • Placing it at the top of your server {} ensures it triggers before requests fall through to your backend.
  • Adding $is_args$args ensures query strings like ?utm=foo are preserved in the redirect.

Things to Consider

  • map must be declared once at http scope; it becomes available to all servers.
  • Prefer $uri over $request_uri so ?utm=... does not break lookups.
  • Put the if ($new_uri) block before broad location matches like location /.
  • Keep sitemap and internal links pointing only to new URLs to speed consolidation.
  • Use 301 (permanent) to pass link equity; 302 is temporary and not ideal here.

Nginx Map Regex Example

Sometimes you need pattern-based redirects instead of exact matches. The map directive in Nginx supports regular expressions when you prefix the key with ~.

Example: Redirect all .php pages to .html equivalents

http {
    map $uri $new_uri {
        default "";

        # Regex match (must start with ~)
        ~^/old-section/(.*)\.php$   /new-section/$1.html;
    }
}

server {
    listen 443 ssl http2;
    server_name example.com;

    if ($new_uri) {
        return 301 https://$host$new_uri$is_args$args;
    }

    location / {
        proxy_pass http://127.0.0.1:9000;
    }
}

Explanation:

  • ~^/old-section/(.*)\.php$ is a regex key.
  • The (.*) captures whatever comes after /old-section/ and before .php.
  • $1 reuses that capture in the replacement (/new-section/$1.html).
  • Any /old-section/foo.php request will redirect permanently to /new-section/foo.html.

When to use Regex in a map:

  • Redirecting file extensions (e.g., .php → .html or .asp → .aspx).
  • Moving whole path segments where only part of the slug changes.
  • Bulk restructuring beyond one-to-one mapping.

NOTE: Regex maps are more CPU-intensive than simple lookups. Only use them when exact matches aren’t practical.


Gotchas

  • nginx: [emerg] "map" directive is not allowed here means your map is inside a server {}; move it to http {}.
  • unexpected end of file, expecting ";" or "}" usually means a missing ; in redirects.map.
  • Trailing slash mismatches (/resume vs /resume/) do not match unless you list both.
  • Using $request_uri causes misses when query strings are present; use $uri.
  • Ordering matters; a catch-all location / can swallow requests before the redirect fires.
  • Reload without testing can mask errors; always run sudo nginx -t first.
  • Getting 200 in curl but 404 in browser may indicate the map didn’t match and the request fell through to the app.

Regex Backslash and Trailing Slash Targets

  • Redirect targets must match your site’s canonical slash style.

    • If your canonical is /about/, map old URLs to /about/.
    • If your canonical is /about, map to /about.
  • Mixing them causes redirect chains (e.g., /resume/ → /about → /about/).

  • Worse, a missing slash can break subpaths entirely (e.g., /resume/ redirecting to /about could produce /aboutsomething instead of /about/something).

  • Always test with curl -I to confirm the final Location matches your site’s canonical URLs.

curl -I https://example.com/resume/

Expected output if canonical target is /about/:

HTTP/2 301
location: https://example.com/about/

Regex Map Example for Preserving Slashes

If you need to redirect an entire section while preserving any subpath after it, use a regex in your map file. This avoids broken paths when slashes are involved.

# /etc/nginx/redirects.map

# Regex match must start with ~
~^/resume/(.*)$    /about/$1;

Explanation:

  • ~^/resume/(.*)$ matches any URL starting with /resume/ and captures the rest of the path into $1.

  • /about/$1 ensures the trailing content is preserved, so:

    • /resume/ → /about/
    • /resume/foo → /about/foo
    • /resume/foo/bar → /about/foo/bar
  • This prevents the common mistake where /resume/anything would otherwise collapse into /aboutanything.


Sources


Further Investigation

  • Explore include to load multiple files (e.g., include /etc/nginx/redirects.d/*.map;) for large rule sets.
  • Review the ssi (server side includes) module if you truly need HTML includes, not redirects.
  • Audit variables with the official “nginx variables list” to understand $uri, $args, $is_args, and $request_uri.
  • Use a debug endpoint temporarily to print $uri and $request_uri for tricky cases.

TL;DR

This section includes everything discussed above:

  • map at http {} scope
  • redirects.map include
  • $new_uri check inside the server {}
  • regex location for bulk redirects (/blog/* → /blogs/*)
  • trailing slash considerations
  • typical blog site basics (SSL, headers, proxy to app)

Use a map at http {} scope, a lookup file (/etc/nginx/redirects.map) for one-to-one redirects, and an early return 301 in the target server {}. Below is a complete production-ready nginx.conf for a typical blog site:

# /etc/nginx/nginx.conf

user nginx;
worker_processes auto;
pid /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    sendfile on;

    # Logs
    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    # Connection tuning
    keepalive_timeout 65s;

    # Redirect map (global, outside any server)
    map_hash_bucket_size 256;
    map $uri $new_uri {
        default "";
        include /etc/nginx/redirects.map;
    }

    # HTTP → HTTPS redirect
    server {
        listen 80;
        server_name example.com www.example.com;
        return 301 https://$host$request_uri;
    }

    # Main HTTPS server
    server {
        listen 443 ssl http2;
        server_name example.com www.example.com;

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

        # Security headers
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

        client_max_body_size 32m;

        # Apply slug-based redirects from map
        if ($new_uri) {
            return 301 https://$host$new_uri$is_args$args;
        }

        # Bulk path migration (old Angular /blog/* → new Astro /blogs/*)
        location ~ ^/blog/(.*)$ {
            return 301 /blogs/$1;
        }

        # Pass everything else to app backend
        location / {
            proxy_pass http://127.0.0.1:9000;
            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 30s;
            proxy_send_timeout 30s;
        }

        # Example: convenience redirect for sitemap
        location = /sitemap.xml {
            return 301 /sitemap-index.xml;
        }
    }
}

/etc/nginx/redirects.map

# Old path         New path
/resume            /about;
/resume/           /about/;
/old-slug          /new-slug;
/old-slug/         /new-slug/;

Notes

  • map must live in http {}, not server {}.
  • $uri strips query strings, so /resume?utm=foo still matches /resume.
  • $is_args$args preserves query strings when redirecting.
  • Always end map entries with ;.
  • For slugs with and without trailing slashes, add both.
  • Place the if ($new_uri) block before any catch-all location /.