--- title: "Cómo proteger WordPress con Nginx en Ubuntu 24.04" description: "Endurece WordPress en Ubuntu 24.04: permisos correctos, wp-config seguro, reglas de Nginx, límite de accesos, Fail2ban, 2FA y verificación de integridad." category: "E-commerce y CMS" language: es source: https://cubepath.com/es/docs/e-commerce-y-cms/endurecimiento-de-seguridad-de-wordpress updated: 2026-09-25 --- # Cómo proteger WordPress con Nginx en Ubuntu 24.04 La mayoría de los WordPress comprometidos no caen por un fallo del núcleo, sino por plugins sin actualizar, contraseñas débiles, ataques de fuerza bruta contra `wp-login.php` o archivos PHP subidos a la carpeta de medios. En este tutorial endurecerás un WordPress que corre en Ubuntu 24.04 con Nginx y PHP-FPM: ajustarás propietarios y permisos para que el servidor web no pueda modificar el código, bloquearás en Nginx las rutas que se usan en los ataques, limitarás los intentos de acceso con Nginx y Fail2ban, añadirás autenticación en dos pasos y comprobarás la integridad de los archivos. ## Requisitos previos Para seguir esta guía necesitas: - Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath. - Un usuario no root con privilegios `sudo`. En los ejemplos se llama `your_user`. - WordPress funcionando en `/var/www/wordpress` con Nginx, PHP 8.3-FPM y MySQL o MariaDB, con HTTPS ya configurado. El bloque de servidor está en `/etc/nginx/sites-available/wordpress` y el dominio es `example.com`. - WP-CLI instalado en `/usr/local/bin/wp`. - UFW activo permitiendo solo SSH y web (`sudo ufw allow OpenSSH` y `sudo ufw allow 'Nginx Full'`). Haz una copia de seguridad de la base de datos y de `/var/www/wordpress` antes de empezar. ## Paso 1: Actualizar y reducir la superficie de ataque Los plugins y temas desactualizados son la primera vía de entrada. Revisa qué hay pendiente de actualizar: ```bash sudo -u www-data wp --path=/var/www/wordpress core check-update sudo -u www-data wp --path=/var/www/wordpress plugin list --update=available sudo -u www-data wp --path=/var/www/wordpress theme list --update=available ``` Actualízalo todo: ```bash sudo -u www-data wp --path=/var/www/wordpress core update sudo -u www-data wp --path=/var/www/wordpress plugin update --all sudo -u www-data wp --path=/var/www/wordpress theme update --all ``` Un plugin desactivado sigue teniendo sus archivos PHP accesibles desde la web, así que si tiene una vulnerabilidad sigue siendo explotable. Lista los plugins y temas inactivos: ```bash sudo -u www-data wp --path=/var/www/wordpress plugin list --status=inactive --field=name sudo -u www-data wp --path=/var/www/wordpress theme list --status=inactive --field=name ``` Borra los que no uses. Conserva un tema por defecto (por ejemplo `twentytwentyfive`) como respaldo si tu tema activo falla: ```bash sudo -u www-data wp --path=/var/www/wordpress plugin delete hello akismet ``` Revisa también la lista de usuarios administradores y elimina o degrada a los que ya no lo necesiten: ```bash sudo -u www-data wp --path=/var/www/wordpress user list --role=administrator --fields=ID,user_login,user_email ``` ## Paso 2: Ajustar propietarios y permisos Si todos los archivos pertenecen a `www-data`, cualquier fallo en un plugin permite a un atacante reescribir el código de WordPress. Un modelo más seguro es: - El código (núcleo, plugins, temas) pertenece a tu usuario y el grupo `www-data` solo puede leerlo. - Solo `wp-content/uploads` es escribible por el servidor web. - `wp-config.php` no es legible por otros usuarios del sistema. Aplica propietario y permisos: ```bash sudo chown -R your_user:www-data /var/www/wordpress sudo find /var/www/wordpress -type d -exec chmod 755 {} + sudo find /var/www/wordpress -type f -exec chmod 644 {} + sudo chmod 640 /var/www/wordpress/wp-config.php sudo chown -R www-data:www-data /var/www/wordpress/wp-content/uploads ``` Comprueba que no queda nada escribible por cualquier usuario: ```bash sudo find /var/www/wordpress -perm -o+w ``` El comando no debe mostrar nada. Con este modelo WordPress ya no puede instalar ni actualizar plugins desde el panel, porque `www-data` no puede escribir en `wp-content/plugins`. Las actualizaciones se harán con WP-CLI como `your_user`, como verás en el paso 3. A partir de ahora ejecuta WP-CLI como tu usuario, sin `sudo -u www-data`: ```bash wp --path=/var/www/wordpress plugin list ``` > Nota: algunos plugins necesitan escribir en su propio directorio dentro de `wp-content` (por ejemplo `wp-content/cache` en plugins de caché). Da permiso de escritura a `www-data` solo en esos directorios concretos, nunca en todo `wp-content`. ## Paso 3: Endurecer wp-config.php Varias constantes de WordPress reducen lo que un atacante puede hacer si consigue acceso al panel. Añádelas con WP-CLI: ```bash wp --path=/var/www/wordpress config set DISALLOW_FILE_EDIT true --raw wp --path=/var/www/wordpress config set DISALLOW_FILE_MODS true --raw wp --path=/var/www/wordpress config set FORCE_SSL_ADMIN true --raw wp --path=/var/www/wordpress config set WP_DEBUG_DISPLAY false --raw ``` - `DISALLOW_FILE_EDIT` elimina el editor de temas y plugins del panel, que permite ejecutar PHP arbitrario con una sola cuenta de administrador robada. - `DISALLOW_FILE_MODS` impide instalar o actualizar plugins y temas desde el panel, coherente con los permisos del paso 2. - `FORCE_SSL_ADMIN` obliga a usar HTTPS en el acceso y el panel. - `WP_DEBUG_DISPLAY` evita que los errores de PHP, con rutas y detalles internos, se muestren a los visitantes. Regenera las claves y *salts* de autenticación. Esto cierra todas las sesiones abiertas, incluida la de un posible intruso: ```bash wp --path=/var/www/wordpress config shuffle-salts ``` ```output Success: Shuffled the salt keys. ``` Como el panel ya no puede actualizar nada, programa las actualizaciones con el cron de tu usuario. Abre su crontab: ```bash crontab -e ``` Añade esta línea, que aplica cada noche las actualizaciones menores del núcleo (las de seguridad) y las de plugins y temas: ```text 0 4 * * * /usr/local/bin/wp --path=/var/www/wordpress core update --minor --quiet && /usr/local/bin/wp --path=/var/www/wordpress plugin update --all --quiet && /usr/local/bin/wp --path=/var/www/wordpress theme update --all --quiet ``` Las versiones mayores del núcleo (por ejemplo de 6.8 a 6.9) actualízalas a mano con `wp core update` después de probarlas. ## Paso 4: Bloquear rutas peligrosas en Nginx Varias rutas de WordPress no deberían ser accesibles desde internet. Agrupa las reglas en un *snippet* para poder reutilizarlo en otros sitios: ```bash sudo nano /etc/nginx/snippets/wordpress-security.conf ``` ```nginx # Nunca ejecutar PHP subido a la carpeta de medios location ~* ^/wp-content/uploads/.*\.(?:php|phtml|phar)$ { deny all; } # Archivos que revelan la versión o configuración location ~* ^/(?:wp-config\.php|wp-config-sample\.php|readme\.html|license\.txt)$ { deny all; } # XML-RPC: objetivo habitual de fuerza bruta y amplificación location = /xmlrpc.php { deny all; access_log off; log_not_found off; } # Archivos ocultos (.git, .env, .htaccess), salvo la validación de Let's Encrypt location ~ /\.(?!well-known) { deny all; } ``` > Advertencia: bloquear `xmlrpc.php` rompe la app móvil de WordPress y las conexiones de Jetpack. Si usas alguno de los dos, elimina ese bloque y confía en Fail2ban (paso 6) para frenar los abusos. Incluye el *snippet* en tu bloque de servidor HTTPS: ```bash sudo nano /etc/nginx/sites-available/wordpress ``` Nginx evalúa los `location` con expresiones regulares en el orden en que aparecen, así que la línea `include` debe ir **antes** del bloque `location ~ \.php$`; si no, la regla genérica de PHP ejecutaría los archivos de `uploads` antes de llegar a la de bloqueo: ```nginx server { # ... listen, server_name, ssl_certificate, root, index ... include snippets/wordpress-security.conf; location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } } ``` Recarga Nginx y comprueba que las reglas funcionan. Crea un archivo PHP de prueba en `uploads` e intenta ejecutarlo: ```bash sudo nginx -t && sudo systemctl reload nginx echo ' /dev/null curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/test.php curl -s -o /dev/null -w '%{http_code}\n' https://example.com/xmlrpc.php sudo rm /var/www/wordpress/wp-content/uploads/test.php ``` ```output 403 403 ``` ## Paso 5: Limitar los intentos de acceso en Nginx Los bots prueban miles de contraseñas por hora contra `wp-login.php`. Nginx puede limitar cuántas peticiones acepta de cada IP a esa ruta antes de que lleguen a PHP. Define la zona de límite en el contexto `http`: ```bash sudo nano /etc/nginx/conf.d/wordpress-ratelimit.conf ``` ```nginx limit_req_zone $binary_remote_addr zone=wplogin:10m rate=10r/m; limit_req_status 429; ``` Esto permite 10 peticiones por minuto a cada IP. Añade al final del *snippet* del paso 4 un bloque específico para la página de acceso: ```bash sudo nano /etc/nginx/snippets/wordpress-security.conf ``` ```nginx location = /wp-login.php { limit_req zone=wplogin burst=5 nodelay; include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } ``` `burst=5` absorbe los picos legítimos (cargar la página y enviar el formulario). Recarga y lanza 20 peticiones seguidas: ```bash sudo nginx -t && sudo systemctl reload nginx for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' https://example.com/wp-login.php; done; echo ``` ```output 200 200 200 200 200 200 429 429 429 429 429 429 429 429 429 429 429 429 429 429 ``` ## Paso 6: Bloquear IP atacantes con Fail2ban El límite de Nginx frena a un bot, pero no le impide volver. Fail2ban lee el registro de acceso y bloquea en el firewall las IP que acumulan intentos fallidos. Un acceso fallido a WordPress devuelve `200` (vuelve a mostrar el formulario), mientras que uno correcto devuelve `302` (redirige al panel); el filtro se basa en esa diferencia. Instala Fail2ban: ```bash sudo apt install -y fail2ban ``` Crea el filtro: ```bash sudo nano /etc/fail2ban/filter.d/wordpress-login.conf ``` ```ini [Definition] failregex = ^ -.*"POST /wp-login\.php HTTP/[0-9.]+" 200 ^ -.*"POST /xmlrpc\.php HTTP/[0-9.]+" ignoreregex = ``` Crea la *jail* que lo usa: ```bash sudo nano /etc/fail2ban/jail.d/wordpress.conf ``` ```ini [wordpress-login] enabled = true port = http,https filter = wordpress-login logpath = /var/log/nginx/access.log maxretry = 5 findtime = 10m bantime = 1h ``` Una IP que falle 5 veces en 10 minutos queda bloqueada una hora. Si tu sitio usa un registro de acceso propio, cambia `logpath`. Antes de activarlo, prueba el filtro contra el registro real: ```bash sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf ``` ```output Lines: 18342 lines, 0 ignored, 37 matched, 18305 missed ``` Reinicia Fail2ban y comprueba el estado de la *jail*: ```bash sudo systemctl restart fail2ban sudo fail2ban-client status wordpress-login ``` ```output Status for the jail: wordpress-login |- Filter | |- Currently failed: 2 | |- Total failed: 2 | `- File list: /var/log/nginx/access.log `- Actions |- Currently banned: 0 |- Total banned: 0 `- Banned IP list: ``` Si te bloqueas a ti mismo, desbloquea tu IP con `sudo fail2ban-client set wordpress-login unbanip your_ip`. > Importante: si el sitio está detrás de un proxy o CDN, el registro de Nginx muestra la IP del proxy y Fail2ban bloquearía al proxy entero. Configura antes el módulo `real_ip` de Nginx con los rangos de tu proveedor para registrar la IP real del visitante. ## Paso 7: Activar la autenticación en dos pasos Aunque una contraseña se filtre, un segundo factor impide el acceso. El plugin **Two Factor**, mantenido por colaboradores del núcleo de WordPress, admite aplicaciones TOTP (Google Authenticator, Aegis, 1Password), llaves de seguridad y códigos de respaldo. Como el panel ya no puede instalar plugins, instálalo con WP-CLI: ```bash wp --path=/var/www/wordpress plugin install two-factor --activate ``` Cada usuario lo activa desde **Usuarios > Perfil**, en la sección **Two-Factor Options**: escanea el código QR con su aplicación, introduce el código de verificación y genera códigos de respaldo. Empieza por todas las cuentas de administrador y comprueba que puedes cerrar sesión y volver a entrar con el segundo factor antes de aplicarlo al resto. ## Paso 8: Añadir cabeceras de seguridad Las cabeceras HTTP indican al navegador que aplique protecciones adicionales frente a *clickjacking*, detección de tipos MIME y fugas de la URL de origen. Añádelas al principio del *snippet*: ```bash sudo nano /etc/nginx/snippets/wordpress-security.conf ``` ```nginx add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Strict-Transport-Security "max-age=31536000" always; ``` `Strict-Transport-Security` (HSTS) obliga al navegador a usar siempre HTTPS con tu dominio durante un año; añádela solo cuando estés seguro de que todo el sitio funciona por HTTPS. Una `Content-Security-Policy` estricta suele romper el editor de bloques y muchos plugins, así que si quieres una, pruébala primero con `Content-Security-Policy-Report-Only`. Nginx solo hereda los `add_header` del nivel superior en los `location` que no definen los suyos. Si algún `location` de tu sitio tiene su propio `add_header` (por ejemplo `X-Cache-Status`), repite ahí estas cabeceras. Recarga y compruébalas: ```bash sudo nginx -t && sudo systemctl reload nginx curl -sI https://example.com/ | grep -iE 'x-content-type|x-frame|referrer|strict-transport' ``` ```output x-content-type-options: nosniff x-frame-options: SAMEORIGIN referrer-policy: strict-origin-when-cross-origin strict-transport-security: max-age=31536000 ``` ## Paso 9: Verificar la integridad de los archivos WP-CLI puede comparar cada archivo del núcleo y de los plugins del repositorio oficial con las sumas de verificación publicadas en WordPress.org. Es la forma más fiable de detectar código modificado o archivos añadidos por un atacante: ```bash wp --path=/var/www/wordpress core verify-checksums wp --path=/var/www/wordpress plugin verify-checksums --all ``` ```output Success: WordPress installation verifies against checksums. Success: Verified 8 of 8 plugins. ``` Cualquier archivo marcado como `File doesn't verify against checksum` o `File should not exist` merece una revisión inmediata. Los plugins de pago o personalizados no están en WordPress.org y aparecerán como no verificables: compáralos con una copia descargada del proveedor. Busca además archivos PHP en `uploads`, donde nunca debería haberlos: ```bash sudo find /var/www/wordpress/wp-content/uploads -type f -name '*.php' ``` Programa la verificación semanal en el crontab de tu usuario (`crontab -e`); cron te enviará la salida por correo si el servidor tiene un agente de correo configurado: ```text 0 5 * * 1 /usr/local/bin/wp --path=/var/www/wordpress core verify-checksums --quiet && /usr/local/bin/wp --path=/var/www/wordpress plugin verify-checksums --all --quiet ``` ## Paso 10: Limitar los privilegios de la base de datos El usuario de MySQL de WordPress solo necesita acceso a su propia base de datos, nunca privilegios globales. Comprueba sus permisos (sustituye `wpuser` por el usuario de `DB_USER` en `wp-config.php`): ```bash sudo mysql -e "SHOW GRANTS FOR 'wpuser'@'localhost';" ``` ```output +-------------------------------------------------------------+ | Grants for wpuser@localhost | +-------------------------------------------------------------+ | GRANT USAGE ON *.* TO `wpuser`@`localhost` | | GRANT ALL PRIVILEGES ON `wordpress`.* TO `wpuser`@`localhost` | +-------------------------------------------------------------+ ``` Si ves `GRANT ALL PRIVILEGES ON *.*` o el usuario es `root`, crea un usuario dedicado con acceso solo a la base de datos de WordPress y actualiza `wp-config.php`. Comprueba también que MySQL solo escucha en local: ```bash sudo ss -tlnp | grep -E ':3306|:33060' ``` ```output LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=812,fd=23)) ``` ## Solución de problemas - **El panel pide credenciales FTP al actualizar**: es el efecto esperado de los permisos del paso 2. Con `DISALLOW_FILE_MODS` el botón de actualizar desaparece; actualiza con WP-CLI como `your_user`. - **No se pueden subir imágenes**: `wp-content/uploads` debe pertenecer a `www-data`. Repite el último `chown` del paso 2. - **Fail2ban no bloquea nada**: comprueba con `fail2ban-regex` que el filtro encuentra líneas y que `logpath` apunta al registro correcto. Revisa `sudo journalctl -u fail2ban`. - **Te has quedado sin acceso por la autenticación en dos pasos**: desactiva el plugin con `wp --path=/var/www/wordpress plugin deactivate two-factor`, entra y vuelve a configurarlo. ## Conclusión Has reducido la superficie de ataque de WordPress eliminando código sin uso, has separado el código de los datos escribibles, has endurecido `wp-config.php`, has bloqueado en Nginx las rutas que se usan en los ataques, has frenado la fuerza bruta con `limit_req` y Fail2ban, has añadido un segundo factor y sabes verificar la integridad de los archivos. Como siguientes pasos, programa copias de seguridad automáticas de la base de datos y de `wp-content` fuera del servidor, revisa periódicamente los usuarios administradores y considera un WAF delante del sitio si recibes ataques frecuentes.