Un archivo .htaccess contiene reglas del servidor web Apache para un solo directorio de tu sitio: redirecciones, archivos bloqueados, protección con contraseña, caché y compresión. Solo funciona cuando Apache procesa realmente la petición, y nginx lo ignora por completo. A continuación tienes reglas que probamos en un banco de pruebas con nginx delante de Apache (una configuración habitual de los paneles de control): cada una con código listo para usar, la respuesta real de curl -I y la trampa que puede hacer que falle sin avisar.
Cómo lo probamos
El 10 de noviembre de 2026, en un banco de pruebas: contenedor Ubuntu 24.04, nginx 1.24.0 delante de Apache 2.4.58 (mpm_event, AllowOverride All), PHP-FPM 8.3.6, MariaDB 10.11, WordPress 6.4.3 con enlaces permanentes amigables. La configuración de nginx reproduce una plantilla habitual de los paneles de hosting: nginx sirve por sí mismo los archivos estáticos de una lista de extensiones y pasa todo lo demás a Apache. También probamos nginx sin Apache y un proxy que se comunica con Apache por HTTP simple. Cada regla se verificó con curl y la velocidad se midió con ab. Estos resultados describen el banco de pruebas, no los servidores de RichHost.
Dónde funciona .htaccess y dónde se ignora
Apache busca un .htaccess en cada directorio del camino hasta el archivo solicitado y lo vuelve a leer en cada petición. Por eso los cambios se aplican al instante, sin reiniciar, y por eso un solo error de sintaxis tumba todo el sitio: en el banco, una errata devolvía un error 500 Internal Server Error en todas las URL. El archivo está en la raíz del sitio (normalmente la carpeta public_html del dominio), y los gestores de archivos suelen ocultarlo porque su nombre empieza por un punto.
La trampa principal: nginx no lee .htaccess. En un servidor solo con nginx nuestras reglas de bloqueo no hicieron nada, y el banco sirvió tanto .env como una copia wp-config.php.bak con la contraseña de la base de datos. Con nginx más Apache es más sutil. Esta plantilla deja que nginx sirva directamente los archivos con extensiones como jpg, png, webp, css, js, txt, pdf, zip, html, htm y unas cuantas decenas más. Esas peticiones nunca llegan a Apache, así que para ellas .htaccess no existe. En el banco esto produjo cuatro sorpresas:
- una redirección desde un old-page.html existente no hacía nada, nginx devolvía el archivo antiguo con 200;
- una contraseña en un directorio protegía index.php, pero report.html en el mismo directorio se abría sin contraseña;
- una regla de bloqueo para *.zip devolvía 403 desde Apache, pero el archivo comprimido se descargaba sin problema a través de nginx;
- las reglas de caché para imágenes no se aplicaban, porque nginx pone él mismo
Cache-Control: max-age=315360000.
De ahí sale una prueba rápida: si la respuesta de un archivo muestra max-age=315360000 y una fecha Expires en 2037, lo sirvió nginx y tus reglas de .htaccess nunca lo vieron.
Cómo comprobar cualquier regla
Guarda una copia antes de editar (cp .htaccess .htaccess.bak) y, después de cada cambio, revisa las cabeceras de respuesta en lugar del navegador, que guarda en caché las redirecciones 301:
curl -I http://example.com/ curl -IL http://www.example.com/page/
La opción -L sigue toda la cadena de redirecciones. Si de repente todas las páginas devuelven 500, restaura la copia y abre el registro de errores del dominio: Apache escribe ahí el motivo exacto, por ejemplo Invalid command 'Order', perhaps misspelled or defined by a module not included in the server configuration.
Redirecciones
De HTTP a HTTPS y el bucle detrás de un proxy
La regla clásica de la mayoría de las guías:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Por HTTP funciona: HTTP/1.1 301, Location: https://example.com/hello.php. En esta plantilla, nginx también reenvía las peticiones HTTPS a Apache por HTTPS, así que Apache ve una conexión segura y responde 200. Pero si un proxy termina el TLS y habla con Apache por HTTP simple (Cloudflare en modo Flexible, un balanceador de carga), Apache considera insegura cada petición y redirige a la misma URL. En el banco, curl se detuvo con Maximum (50) redirects followed, y un navegador mostraría ERR_TOO_MANY_REDIRECTS. La solución es comprobar la cabecera en la que el proxy transmite el esquema original.
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Con esta regla, las tres configuraciones del banco respondieron bien: HTTP recibió 301 y las dos variantes HTTPS, 200. La cadena de consulta se conserva automáticamente: ?utm=1&b=2 llegó intacta a la URL HTTPS. En muchos paneles también puedes activar la redirección a HTTPS en los ajustes SSL del dominio; entonces se encarga nginx y no necesitas ninguna regla en .htaccess.
www y sin www en una sola redirección
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,R=301]
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
El orden importa. De esta forma, http://www.example.com/sample-page/ llegó a https://example.com/sample-page/ con una sola redirección. Con la regla HTTPS primero, la cadena creció a dos saltos. En sentido contrario, de sin www a www:
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
301 para una sola página
Redirect 301 /old-page.html https://example.com/new-page.html Redirect 301 /old-post/ https://example.com/hello-world/
Ambas líneas devolvieron 301 a la nueva URL cuando estaban por encima del bloque de WordPress. La misma redirección escrita como RewriteRule debajo del bloque no se activó: WordPress capturó la URL y devolvió 404. Y no olvides los archivos estáticos: mientras old-page.html seguía en el disco, nginx lo servía con 200, y la redirección solo funcionó después de borrar el archivo. Tienes más patrones, incluidos traslados masivos y cambios de dominio, en nuestra guía sobre la redirección 301 en .htaccess y Nginx.
Barra final
WordPress no necesita nada más: redirigió /sample-page a /sample-page/ por sí solo. La popular regla para “quitar la barra final”, combinada con esos enlaces permanentes, creó un bucle infinito: .htaccess quitaba la barra y WordPress la volvía a añadir. Para sitios sin WordPress cuyas URL deben terminar en barra:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1/ [R=301,L]
En el banco, /catalog/shoes?color=red pasó a /catalog/shoes/?color=red, mientras que los archivos reales como /hello.php no se tocaron.
El bloque predeterminado de WordPress
WordPress 6.4 escribe este bloque por sí mismo cuando guardas los ajustes de enlaces permanentes. Sin él, /hello-world/ devolvía 404 desde Apache en el banco; con él, 200.
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
Coloca todas tus reglas por encima de # BEGIN WordPress: WordPress reescribe todo lo que hay entre los marcadores, y una regla debajo del bloque nunca ve una petición que no corresponda a un archivo real.
Seguridad: bloqueo de archivos, listas de IP permitidas, contraseñas
Bloquear el acceso a archivos
<FilesMatch "^(\.env|wp-config\.php|xmlrpc\.php)$"> Require all denied </FilesMatch> <FilesMatch "\.(bak|old|orig|sql|zip|tar|gz|log)$"> Require all denied </FilesMatch>
Sin estas reglas, Apache servía .env con la contraseña de la base de datos, y wp-config.php.bak se descargaba como un archivo normal con la línea DB_PASSWORD incluida. El propio wp-config.php devolvía 200 con una página en blanco: PHP lo ejecuta y no imprime nada, así que el peligro real son las copias con otra extensión. Con las reglas, todas esas URL devolvieron 403, también xmlrpc.php, incluidas las peticiones POST. Los archivos estáticos vuelven a ser la excepción: backup.zip se seguía descargando a través de nginx, así que no guardes archivos comprimidos ni volcados de la base de datos en public_html.
La sintaxis antigua de Apache 2.2 (Order Allow,Deny y Deny from all) solo funciona con mod_access_compat. Cuando desactivamos ese módulo, el mismo archivo provocó un error 500 en todas las páginas del sitio, así que usa Require all denied.
Acceso solo desde tu IP
<Files wp-login.php> Require ip 203.0.113.10 </Files>
Detrás de nginx, Apache ve la dirección del proxy. Sin mod_remoteip, una regla con la IP del cliente bloqueó a todo el mundo en el banco, mientras que Require ip 127.0.0.1 dejó pasar a todos. Con mod_remoteip activado sobre la cabecera X-Real-IP (una configuración habitual), la regla funcionó exactamente: la dirección permitida recibió 200, otra 403, y una cabecera X-Real-IP falsificada no sirvió de nada, porque nginx la sobrescribe.
Protección con contraseña mediante .htpasswd
htpasswd -c -B /home/user/web/example.com/private/.htpasswd admin
AuthType Basic AuthName "Restricted area" AuthUserFile /home/user/web/example.com/private/.htpasswd Require valid-user
Guarda el archivo de contraseñas fuera de public_html y usa la ruta completa. Apache respondió 401 Unauthorized con WWW-Authenticate: Basic realm="Restricted area", y 200 con la contraseña correcta. Pero a través de nginx, report.html en el directorio protegido se abría sin contraseña. Si el directorio contiene archivos estáticos, la contraseña tiene que comprobarla nginx: algunos paneles ofrecen para ello una protección con contraseña en los ajustes del dominio, que escribe la regla en la configuración de nginx. En un hosting compartido, pídeselo al soporte de tu proveedor.
Caché y compresión
<IfModule mod_expires.c> ExpiresActive On ExpiresByType image/webp "access plus 1 year" ExpiresByType image/png "access plus 1 year" ExpiresByType text/css "access plus 1 month" ExpiresByType text/javascript "access plus 1 month" ExpiresByType application/javascript "access plus 1 month" </IfModule> <IfModule mod_deflate.c> AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json image/svg+xml </IfModule>
Apache añadió Cache-Control: max-age=31536000 al PNG y 30 días a jquery.min.js. Ojo con el tipo de JavaScript: en Ubuntu 24.04 los archivos .js se sirven como text/javascript, así que una regla solo con application/javascript no los cubre. La compresión marcó una clara diferencia: jquery.min.js pasó de 87.553 a 30.372 bytes, el CSS de bloques de WordPress de 110.147 a 14.472 y el HTML de la página de inicio de 83.776 a 14.533. Con nginx delante, ambos bloques solo afectan a lo que sirve Apache, es decir, al HTML: las imágenes, el CSS y el JS los cachea y comprime nginx.
Páginas de error personalizadas
ErrorDocument 404 /404.html ErrorDocument 403 /403.html
Usa una ruta, no una URL completa: con ErrorDocument 404 https://example.com/404.html Apache respondió 302 Found en lugar de 404, de modo que los buscadores ven una redirección en vez de un error. Para los archivos .php inexistentes no se usó la página, porque el propio PHP-FPM devuelve “File not found.”. En WordPress, el tema genera las páginas 404, así que ahí ErrorDocument 404 sobra.
Ajustes de PHP: php_value o .user.ini
La línea php_value upload_max_filesize 64M en .htaccess solo funciona con mod_php. Con PHP-FPM provocó un error 500 en todas las páginas con Invalid command 'php_value'. Envolverla en <IfModule mod_php.c> elimina el error, pero el valor no cambia. El lugar correcto es un archivo .user.ini en la raíz del sitio.
upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M
PHP no tomó los nuevos valores enseguida: user_ini.cache_ttl vale 300 segundos por defecto, y tras una edición el banco siguió mostrando el valor anterior durante otros 206 segundos. .user.ini funciona igual en un servidor solo con nginx. Si subir el límite de memoria no ayuda, busca la causa en el código; consulta nuestra guía sobre el error Allowed memory size exhausted.
¿.htaccess hace más lento un sitio?
Comparamos Apache con AllowOverride None y AllowOverride All usando ab (16 peticiones simultáneas, cinco rondas alternas, mediana). El archivo .htaccess tenía 37 líneas: www y HTTPS, una redirección, dos bloques de bloqueo, caché, compresión, ErrorDocument y el bloque de WordPress.
| Configuración de Apache | Archivo estático, pet/s | Script PHP mínimo, pet/s |
|---|---|---|
| AllowOverride None | 26.301 | 7.417 |
| Las mismas 37 líneas en la configuración del servidor | 24.267 | 7.614 |
| AllowOverride All con un .htaccess de 37 líneas | 14.817 | 6.080 |
Permitir .htaccess casi no cuesta nada por sí solo: en una ronda aparte, Apache con AllowOverride All pero sin ningún archivo de ese tipo sirvió 24.268 peticiones por segundo frente a 24.356 con None. Lo que cuesta es el análisis: Apache lee y analiza .htaccess de nuevo en cada petición, por lo que el rendimiento con archivos estáticos cayó en torno al 44 %, mientras que las mismas reglas en la configuración del servidor costaron alrededor del 8 %. Son unos 26 microsegundos por petición en un banco de 2 vCPU, mientras que generar una página de WordPress tardaba unos 85 milisegundos, así que en páginas dinámicas la diferencia desaparece. Durante las mediciones se ejecutaban otras pruebas en el contenedor, por lo que las cifras absolutas tienen ruido; compara solo las proporciones.
Conclusión: no renuncies a .htaccess en un hosting compartido, pero elimina las reglas que duplican lo que ya hacen nginx o el panel de control (caché de estáticos, HTTPS, bloqueo de archivos ocultos).
Chuleta: tarea, regla, comprobación
| Tarea | Regla | Cómo comprobarlo |
|---|---|---|
| De HTTP a HTTPS | RewriteCond %{HTTPS} off más la condición X-Forwarded-Proto |
curl -I http://…: 301 a https; curl -IL https://…: 200, sin bucle |
| De www a sin www | RewriteCond %{HTTP_HOST} ^www\.(.+)$ |
curl -IL http://www…: un único 301 |
| Mover una página | Redirect 301 /old/ https://…/new/ encima del bloque de WordPress |
301 con el Location correcto; archivo antiguo borrado |
| Barra final | Nada en WordPress, una regla !-f en otros casos |
curl -IL, sin bucle |
| Bloquear .env, copias de seguridad, xmlrpc.php | FilesMatch con Require all denied |
403; comprobar aparte los archivos comprimidos a través de nginx |
| Administración solo desde tu IP | Require ip más mod_remoteip en el servidor |
200 desde tu red, 403 con datos móviles |
| Contraseña en un directorio | AuthType Basic, .htpasswd fuera de public_html | 401 sin contraseña, también para .html y .pdf |
| Caché de archivos estáticos | mod_expires |
Cache-Control en el curl -I del archivo |
| Compresión | mod_deflate |
curl -I -H 'Accept-Encoding: gzip': Content-Encoding: gzip |
| Páginas de error | ErrorDocument 404 /404.html |
Estado 404, no 302 |
| Límites de PHP | .user.ini en lugar de php_value | phpinfo() o ini_get() pasados 5 minutos |
Preguntas frecuentes
¿Por qué el sitio muestra un error 500 después de editar .htaccess?
Apache no pudo interpretar una línea: una errata, una directiva de un módulo desactivado (php_value, Order) o un carácter suelto. Restaura la copia; la línea exacta aparece en el registro de errores.
¿Necesito .htaccess en un servidor solo con nginx?
No, nginx no lo lee. Esas mismas tareas se resuelven en la configuración de nginx, que en un hosting compartido cambia el administrador o el panel de control.
¿Qué permisos debe tener .htaccess?
Basta con 644: el propietario escribe y el servidor web lee. 777 nunca hace falta. Si Apache no puede leer el archivo en absoluto, todo el sitio devuelve un error 403 Forbidden y el registro muestra pcfg_openfile: unable to check htaccess file.
¿Por qué curl muestra la redirección y el navegador abre la página antigua?
Los navegadores recuerdan las redirecciones 301. Prueba en una ventana privada o vacía la caché, y usa R=302 mientras depuras.
Los sitios en RichHost se gestionan desde el panel de control propio con asistente de IA, y un certificado gratuito de Let’s Encrypt se activa en los ajustes del dominio. Consulta los planes en la página de tarifas de hosting.



