- Para una redirección fija,
return 301basta: una directiva, sin regex.rewrite ... permanentqueda para cuando necesitas capturar parte de la ruta. - Un
returncon URL fija pierde la query string. Para conservarla, añade$is_args$args;rewritela añade solo. - Un bloque
serverpor versión de la web y un único salto desde cualquier entrada. - En
location, el exacto gana, después el prefijo más largo y después la primera regex que encaje. Una regex puede anular un prefijo que creías protegido. - Para cientos o miles de redirecciones, un
mapsobre$urien un archivo aparte; con 30.000 reglas tuve que subir los límites de hash. - Se depura con
nginx -t,curl -sILy, si hace falta,rewrite_log. Todos los ejemplos están probados con un Nginx real.
La redirección de Nginx más habitual cabe en una línea, y casi todos los fallos que he visto vienen de lo que esa línea no dice: qué pasa con la query string, qué hace el servidor con la barra final o cuántos saltos da una URL antes de llegar a su destino.
Esta guía cubre return, rewrite, location, map, dominios antiguos, 410, el uso de if, los errores que se repiten y cómo depurar. Probé cada ejemplo con Nginx 1.24.0 (Ubuntu) en puertos de prueba y curl. En los listados escribo listen 80 y listen 443; en mi entorno los cambié por puertos altos, desactivé port_in_redirect para que el Location no muestre ese puerto y no probé IPv6.

return o rewrite: cuál usar y qué devuelve cada uno
Qué código elegir (301, 302, 307, 308) por razones de SEO lo explico en la guía de tipos de redirecciones SEO. Aquí me quedo con cómo se escribe en Nginx. Según la documentación de Google, 301 y 308 son una señal fuerte de que el destino debe ser la URL canónica; 302 y 307, una señal débil.
return detiene el procesamiento y devuelve un código. Para 301, 302, 303, 307 y 308 acepta una URL de destino con variables; si das una ruta local (/destino), Nginx forma la URL completa con el esquema de la petición. rewrite aplica una regex sobre la URI, ejecuta sus directivas en orden y, si el reemplazo empieza por http://, https:// o $scheme, devuelve la redirección. Con el flag permanent da 301 y con redirect da 302.
| Situación | Directiva | Motivo |
|---|---|---|
| Una URL concreta a otra, o un dominio entero | return 301 URL | Sin regex que evaluar ni capturas que gestionar |
Cambiar un patrón de ruta (/blog/(.*) a /articulos/$1) | rewrite ... permanent | Captura grupos y añade la query string sola |
| Muchas reglas independientes | map + return | Una tabla en archivo aparte |
| Código 307 o 308 | return | rewrite solo emite 301 o 302 |

Query string: lo que conserva cada forma
location = /fija { return 301 /destino; }
location = /conserva { return 301 /destino$is_args$args; }
location ~ ^/viejo/(.*)$ { return 301 /nuevo/$1$is_args$args; }
rewrite ^/antiguo/(.*)$ /nuevo/$1 permanent;
rewrite ^/limpia/(.*)$ /nuevo/$1? permanent;
location /seccion/ { return 301 /nueva-seccion/; }
Lo que devolvió mi Nginx con curl -sI:
/fija?a=1 -> 301 Location: http://example.com/destino
/conserva?a=1&b=2 -> 301 Location: http://example.com/destino?a=1&b=2
/viejo/pagina?a=1 -> 301 Location: http://example.com/nuevo/pagina?a=1
/antiguo/pagina?a=1 -> 301 Location: http://example.com/nuevo/pagina?a=1
/limpia/pagina?a=1 -> 301 Location: http://example.com/nuevo/pagina
/seccion/sub/pagina -> 301 Location: http://example.com/nueva-seccion/
La documentación dice que, si el reemplazo de rewrite lleva argumentos nuevos, los antiguos se añaden detrás, y que un ? final lo evita. Un location de prefijo con return no arrastra el resto de la ruta: /seccion/sub/pagina acaba en /nueva-seccion/. Si quieres conservar el sufijo, necesitas captura (rewrite o un location ~).
Un bloque server por cada versión de la web
La documentación de Nginx descarta el if ($host = ...) para redirigir entre www y no-www («wrong, cumbersome, and ineffective») y propone un server separado con return 301. Para las cuatro versiones de un dominio (http y https, con y sin www) bastan tres bloques:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/ssl/example.com.pem;
ssl_certificate_key /etc/ssl/example.com.key;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com.pem;
ssl_certificate_key /etc/ssl/example.com.key;
root /var/www/example.com;
}
El primero recoge todo el tráfico HTTP, con o sin www, y lo manda a la versión canónica en un solo salto. El segundo cubre HTTPS con www, que necesita un certificado válido para que el navegador vea la redirección. $request_uri conserva ruta y query string tal como llegaron.

http://example.com/blog/post.html?utm=1 -> 301 https://example.com/blog/post.html?utm=1
https://www.example.com/blog/?a=1&b=2 -> 301 https://example.com/blog/?a=1&b=2
https://example.com/blog/ -> 200
Nginx compara el Host con server_name en este orden: nombre exacto, comodín inicial, comodín final y primera regex. Si nada encaja, responde el servidor por defecto del puerto, una propiedad de listen y no del nombre. En mi prueba, un Host desconocido cayó en el primer bloque y se fue a https://example.com/. Si en ese bloque pones return 301 https://$host$request_uri, Nginx devuelve el Host que le manden: con Host: sitio-ajeno.test obtuve Location: https://sitio-ajeno.test/algo?q=1. Escribe el dominio canónico a mano. Para elegir cuál debe ser la versión canónica, tienes la guía de versiones www, no-www, http y https y la de etiquetas canonical.
location: exactos, prefijos y regex, y su orden
Muchas redirecciones «que no funcionan» son un location que nunca se ejecuta. El orden de la documentación: Nginx busca primero entre los prefijos y recuerda el más largo; si ese prefijo lleva ^~, se detiene ahí. Si no, evalúa las regex en el orden en que aparecen y se queda con la primera que encaje; si ninguna encaja, usa el prefijo recordado. Un = exacto termina la búsqueda al instante. Todos los tipos comparan solo la ruta, sin la query string.
location = /inicio { return 200 "exacta (= /inicio)\n"; }
location /promo/ { return 200 "prefijo /promo/\n"; }
location ^~ /imagenes/ { return 200 "prefijo ^~ /imagenes/\n"; }
location ~ \.html$ { return 200 "regex ~ .html\n"; }
location ~* \.(jpg|png)$ { return 200 "regex ~* jpg|png\n"; }
location / { return 200 "prefijo /\n"; }
| Petición | Responde | Por qué |
|---|---|---|
/inicio y /inicio?x=1 | exacta | La query string no cuenta |
/inicio/ y /Inicio | prefijo / | La barra y las mayúsculas cambian la URL |
/promo/oferta.html | regex ~ .html | La regex gana al prefijo sin ^~ |
/imagenes/foto.jpg | prefijo ^~ | ^~ impide evaluar las regex |
/otra/foto.JPG | regex ~* | ~* ignora mayúsculas |

location y un caso real: /promo/oferta.html no llega al prefijo /promo/.Si redirijo una sección con location /promo/ y existe una regex por extensión, las URL con .html de esa sección se saltan mi redirección. Con ^~ en el prefijo se arregla.
Barra final: añadirla o quitarla sin bucles
Elige una convención y aplícala a todo el sitio. Para añadir la barra a las rutas sin extensión, una regla basta:
rewrite ^([^.?]*[^/])$ $1/ permanent;
En mi prueba, /blog pasó a /blog/, /blog?a=1 a /blog/?a=1 y /blog/post.html o /logo.jpg respondieron 200 sin tocarse.
Quitarla es donde aparece el problema. Con rewrite ^/(.+)/$ /$1 permanent; y un directorio real en disco, las URL /blog/ y /blog se mandaron la una a la otra sin parar: curl -L llegó al límite de diez saltos. El motivo es que Nginx, por su cuenta, redirige un directorio sin barra a su versión con barra. Lo observé en la práctica; en la documentación aparece de forma explícita solo para location de prefijo con proxy_pass, fastcgi_pass y similares. La solución que probé es servir el directorio como archivo con try_files:
rewrite ^/(.+)/$ /$1 permanent;
location / {
try_files $uri $uri/index.html =404;
}
Con ella, /blog/ saltó a /blog y este respondió 200. Un apunte sobre mi entorno: esa redirección automática incluye el puerto si no es el estándar (me salió https://example.com:8362/blog/); en producción, en 80 o 443, no aparece.

try_files evita el bucle.map para tablas de redirecciones grandes
Con decenas de redirecciones, un location por regla se vuelve inmanejable. map crea una variable que depende de otra y se evalúa solo cuando se usa. Mi diseño usa dos map, un único salto y los tres bloques de antes:
map $uri $nueva_ruta {
default "";
include /etc/nginx/redirects.map;
}
map $nueva_ruta $destino {
"" $request_uri;
default $nueva_ruta$is_args$args;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$destino;
}
# (el server de https://www... igual, con su ssl_certificate)
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate ...
root /var/www/example.com;
if ($nueva_ruta) {
return 301 https://example.com$destino;
}
}
Y el archivo redirects.map, con claves exactas y una regex con captura con nombre:
/old-page/ /new-page/;
/blog/viejo-post/ /blog/nuevo-post/;
/producto-retirado/ /tienda/;
~^/categoria/(?<c>[^/]+)/$ /blog/$c/;
map busca por este orden: cadena exacta, máscara de prefijo, máscara de sufijo, primera regex y default. Resultados reales, siempre a un salto:
http://www.example.com/old-page/?utm=1 -> https://example.com/new-page/?utm=1
https://www.example.com/categoria/seo/ -> https://example.com/blog/seo/
http://www.example.com/caf%C3%A9/?q=a%20b -> https://example.com/caf%C3%A9/?q=a%20b (sin regla)
http://example.com/producto-retirado -> https://example.com/producto-retirado (sin barra: sin regla)

map encadenados: el primero decide si hay regla; el segundo construye el destino con su query string.Clave en $uri, no en $request_uri
Con $request_uri como clave, /old-page/ daba 301 pero /old-page/?utm=1 respondía 200 sin redirigir: incluye la query string y la clave exacta no encaja. $uri es la ruta normalizada y sin argumentos, aunque decodificada; por eso el destino de las URL sin regla sale de $request_uri y conserva los %C3%A9 tal cual.
Límites de hash con tablas largas
Generé un archivo de 30.000 reglas con claves de hasta 70 caracteres. Con los valores por defecto, nginx -t falló:
[emerg] could not build map_hash, you should increase map_hash_bucket_size: 64
Subir solo map_hash_bucket_size a 128 convirtió el error en un aviso (could not build optimal map_hash). Con estos valores, el aviso desapareció:
map_hash_bucket_size 512;
map_hash_max_size 65536;
Con esa tabla, validar y arrancar tardó algo más de medio segundo y la regla de prueba redirigió bien. No he medido latencia por petición. Antes de tocar producción, comprueba cada regla con el script de la sección de depuración.
Dominio antiguo y páginas retiradas
Dominio antiguo a dominio nuevo
El nombre .viejo.com (con punto inicial) cubre viejo.com y todos sus subdominios. Si la estructura cambió, pon el rewrite específico antes del return general (en mi prueba, /blog/post-uno/?utm=1 acabó en /articulos/post-uno/?utm=1 y el resto conservó ruta y query):
server {
listen 80;
listen 443 ssl;
server_name .viejo.com;
ssl_certificate /etc/ssl/example.com.pem;
ssl_certificate_key /etc/ssl/example.com.key;
rewrite ^/blog/(.*)$ https://nuevo.com/articulos/$1 permanent;
return 301 https://nuevo.com$request_uri;
}
El dominio antiguo necesita un certificado válido para su HTTPS mientras dure la redirección; en mi prueba usé curl -k con uno autofirmado, así que eso no lo validé. En una migración real la correspondencia debe ser URL a URL (mi servicio de migraciones SEO parte de ahí), y mandar todo a la home no lo es.
410 con return
Para una URL retirada para siempre, return 410 es una línea. Para una sección entera, un prefijo con ^~; para una lista, un map con if a nivel de server:
location = /producto-retirado/ { return 410; }
location ^~ /tienda/descatalogado/ { return 410; }
error_page 410 /410.html;
location = /410.html { internal; }
map $uri $retirada {
default 0;
/producto-retirado/ 1;
~^/tienda/descatalogado/ 1;
}
if ($retirada) { return 410; } # dentro del server
Ambas devolvieron 410 Gone; la primera con 410.html como cuerpo y con /410.html inaccesible desde fuera (404). La documentación de Google trata todos los 4xx igual, salvo el 429, y no distingue 404 de 410. Elijo 410 porque comunica la intención, no porque prometa una desindexación más rápida. El repaso completo de códigos está en la guía de códigos de respuesta del servidor.
if: cuándo sí y cuándo no
La wiki de Nginx tiene un texto, «If is Evil», sobre if dentro de location: en algunos casos hace algo distinto de lo esperado y puede provocar un fallo del proceso; las únicas acciones 100 % seguras ahí son return ... y rewrite ... last; y recomienda evitarlo cuando haya alternativa (try_files, o moverlo a nivel de server). La documentación del módulo da la razón de fondo: dentro de un if, la petición pasa a tener la configuración de ese bloque.
Ese efecto se ve con facilidad. Esta configuración añade una cabecera en el location y otra dentro de un if:
location /con-if {
add_header X-Capa "location";
if ($arg_debug) {
add_header X-Debug "si";
}
return 200 "ok\n";
}
/con-if -> X-Capa: location
/con-if?debug=1 -> X-Debug: si (X-Capa ha desaparecido)
Mi regla: uso if solo con return dentro y, a ser posible, a nivel de server, como en el if ($nueva_ruta) del apartado de map. No lo uso para decidir el host (para eso están los server_name) ni para encadenar condiciones.
Errores típicos

Bucle detrás de un proxy o CDN que termina el TLS
Si un balanceador o un CDN que termina el TLS habla HTTP plano con tu Nginx, $scheme vale siempre http en el backend, y un if ($scheme = http) redirige también lo que ya venía por HTTPS. Lo reproduje con dos servidores:
$ curl -sSL -o /dev/null --max-redirs 5 https://example.com/blog/
curl: (47) Maximum (5) redirects followed
La solución es mirar la cabecera que añade el proxy:
map $http_x_forwarded_proto $esquema_publico {
default $scheme;
https https;
}
# en el backend:
if ($esquema_publico = http) { return 301 https://example.com$request_uri; }
Así, la petición por HTTPS dio 200 sin saltos y la HTTP directa dio 301. Sin probar: el backend solo debería fiarse de esa cabecera si viene del proxy.
El return del server se ejecuta antes que cualquier location
Las directivas de nivel server se ejecutan antes de buscar el location. Con un return 301 de server y un location para /.well-known/acme-challenge/, el desafío respondía 301 en lugar de 200. Moviendo el return a un location /, el desafío dio 200 y el resto 301. Qué hace certbot ante ese 301 no lo he probado.
El 301 que no se puede deshacer
Nginx no añade cabeceras de caché a un return 301 (lo comprobé con curl -sI). Qué hace después cada navegador no lo he medido. Mi práctica: un 302, o un 301 de vida corta mientras compruebo la regla, y el definitivo cuando todo es correcto:
location = /b {
add_header Cache-Control "max-age=300";
return 301 /destino;
}
La cabecera aparece en el 301. Si hay caché en CDN delante, purga tras cambiar una regla.
server_name duplicado: solo un aviso
Dos server con el mismo server_name y puerto no hacen fallar nginx -t: emite [warn] conflicting server name "example.com" on 0.0.0.0:8361, ignored y las reglas del segundo bloque no se aplican (lo comprobé). Lee los avisos, no solo el «test is successful».
Capturas sobre $uri y caracteres codificados
Con una captura sobre $uri dentro de un return, /a/dos%20palabras produjo Location: https://ejemplo.com/nuevo/dos palabras (espacio sin codificar) y /a/a%2Fb produjo .../a/b, que cambia el significado. Con rewrite a una URL absoluta, Nginx escapó %20 y %C3%A9, pero %2F acabó como /. Con una captura sobre $request_uri ("^/c/(?<resto>[^?]*)") las tres salieron intactas.
Depurar y auditar una redirección

Primero nginx -t antes de recargar. Mensajes reales de mis errores de prueba:
unexpected "}" (falta el ; antes de la llave)
unknown directive "retrun" (errata)
"map" directive is not allowed here (map va en http, no en server)
duplicate location "/a"
Después, la cadena completa con curl. Con -w sacas el número de saltos y el destino final:
$ curl -sIL -o /dev/null -w 'saltos=%{num_redirects} final=%{http_code} %{url_effective}\n' http://www.example.com/old-page/?utm=1
saltos=1 final=200 https://example.com/new-page/?utm=1
Para una tabla de map, un bucle que comprueba cada regla exacta (un solo salto, Location esperada, destino con 200). Al añadir una regla que apuntaba a una URL inexistente, la marcó:
grep -E '^/' redirects.map | tr -d ';' | while read -r antigua nueva; do
r=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "https://example.com$antigua")
f=$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$nueva")
[ "$r" = "301 https://example.com$nueva" ] && [ "$f" = "200" ] && e=OK || e=FALLO
echo "$e $antigua -> $r (destino: $f)"
done
OK /old-page/ -> 301 https://example.com/new-page/ (destino: 200)
FALLO /roto/ -> 301 https://example.com/no-existe/ (destino: 404)
Si el comportamiento sigue sin ser el esperado, activa rewrite_log on; con error_log a nivel notice. Registra las reglas rewrite, no los return:
"^/antiguo/(.*)$" matches "/antiguo/x"
rewritten redirect: "/nuevo/x?a=1"
"^/antiguo/(.*)$" does not match "/fijo"
Para lo que ven los rastreadores, la fuente es el access_log: filtra los 301 y 404 (método en la guía de análisis de logs SEO).
Nginx y Apache: diferencias que cambian el resultado
La sintaxis de Apache está en la guía de redirecciones en Apache; aquí solo la diferencia que más sorprende. Probé Redirect 301 /seccion/ /nueva-seccion/ en Apache 2.4 y /seccion/a/b?x=1 acabó en /nueva-seccion/a/b?x=1: conserva el resto de la ruta y la query string. El equivalente de Nginx, location /seccion/ { return 301 /nueva-seccion/; }, los pierde, como vimos arriba. Además, Apache admite .htaccess por directorio; en Nginx todo vive en la configuración central y se aplica con nginx -s reload.
Qué hago yo en mi web
Mi web corre sobre Apache, no sobre Nginx. Su .htaccess tiene 33 líneas y ninguna de ellas es una redirección: solo dos RewriteRule que devuelven 403 para tools/ y .git/, más ErrorDocument hacia una página 404 que responde 404 con noindex. No tengo configuración de Nginx propia que enseñarte. Escribo esta guía porque me llegan clientes con VPS y WordPress sobre Nginx.
Lo que sí muestra el código:
- El dominio canónico es
https://cristofercruz.net, sin www, definido enSITE_URL. Cada página emite surel="canonical"con él. - Las 24 URL de
sitemap.xmlusan ese dominio y terminan en barra; el sitemap del blog se genera conSITE_URLyrobots.txtdeclara ambos con el mismo dominio. - No hay
http://escrito a mano en los PHP, XML ni JSON del sitio, salvo los espacios de nombres XML. - El README anota las redirecciones https y no-www como «pendiente de verificar en el hosting»: dependen del servidor y no están en el repositorio. No puedo afirmar cómo responde hoy http://www a mi dominio.
- Para retirar contenido uso PHP, no el servidor. El antiguo
/blog/google-io-2025/responde 410 conX-Robots-Tag: noindexy una página de «Artículo retirado».
Además tengo un generador de redirecciones para Apache, Nginx e IIS. Ejecuté su código y probé la salida de Nginx contra el servidor real, y encontré el problema de las capturas sobre $uri: /viejo/dos%20palabras salía con un espacio y /viejo/a%2Fb con una barra. Ya lo he corregido. Para las reglas de directorio con sufijo en rutas ASCII, el generador escribe ahora if ($request_uri ~ "^/viejo/(?<redirect_tail_0>[^?]*)"), con la captura cortada antes de la query string, que se añade aparte con $is_args$args. Las páginas exactas siguen usando $uri, y también las rutas de origen con caracteres Unicode, porque $request_uri llega codificado y no coincidiría. Con la salida nueva, curl contra Nginx real devolvió estas cabeceras Location:
/viejo/dos%20palabras -> 301 /nuevo/dos%20palabras
/viejo/a%2Fb?x=1 -> 301 /nuevo/a%2Fb?x=1
/viejo/caf%C3%A9/ -> 301 /nuevo/caf%C3%A9/
/pagina-vieja?utm=1 -> 301 /nuevo/?utm=1
Queda una limitación: en una regla cuyo origen tiene Unicode, la captura sigue siendo sobre $uri. En mi prueba con /vieja-ñ/, la URL /vieja-%C3%B1/a%20b acabó en /nuevo/a b, con el espacio sin codificar. El generador ya avisa de que hay que revisar espacios y Unicode antes de publicar, y para esos casos sigue siendo mejor un map con destinos escritos a mano.
Preguntas frecuentes
¿Cómo evito cadenas http a https a no-www a ruta nueva?
Haz que cada bloque no canónico redirija directo al destino final. Con el map, cualquier entrada llegó en un salto; con una redirección por capa, mi prueba dio tres.
¿Puedo usar if para redirigir?
Sí, si lo único que contiene es return y, mejor, a nivel de server. Dentro de un location, la wiki de Nginx solo da por 100 % seguros return y rewrite ... last.
¿Cuántas reglas aguanta un map?
Probé 30.000 con claves de hasta 70 caracteres; necesité map_hash_bucket_size 512 y map_hash_max_size 65536. No he medido latencia, así que no te doy un límite.
¿301 o 308?
Google trata el 308 como equivalente al 301 al rastrear. La diferencia es que el 308 prohíbe cambiar el método de la petición. Nginx emite 308 con return desde la versión 1.13.0; rewrite solo emite 301 o 302.
Mi redirección no se aplica, ¿qué reviso?
Avisos de nginx -t (¿server_name duplicado?), si recargaste, si una regex gana al prefijo, si un return de server se ejecuta antes que tu location y qué URL llega realmente, con curl -sIL.
Referencias y fuentes
- Module ngx_http_rewrite_module nginx.org
- Module ngx_http_core_module nginx.org
- Module ngx_http_map_module nginx.org
- Converting rewrite rules nginx.org
- If is Evil... when used in location context Nginx Wiki
- Redirects and Google Search Google Search Central
- HTTP status codes, and network and DNS errors Google Search Central
- 301 Moved Permanently MDN Web Docs




