Cómo alojar varios sitios WordPress en un solo servidor con nginx
Guía para alojar varios WordPress en un servidor Linux con nginx, PHP-FPM y MariaDB: aislamiento por sitio, HTTPS y publicación segura a través del firewall.
Tener los sitios web de una organización repartidos entre proveedores de hosting distintos es cómodo al principio, pero con el tiempo sale caro: cada sitio tiene su panel, sus credenciales, sus copias de seguridad (o la falta de ellas) y su propia forma de hacer las cosas. Cuando algo falla, dependes de terceros para saber qué pasó.
Una alternativa sólida es alojar todos los sitios en un único servidor Linux propio con nginx. Con una buena estructura, cada sitio queda aislado de los demás, la configuración es homogénea y añadir un sitio nuevo se convierte en un procedimiento de diez minutos.
En esta guía verás cómo montar un servidor así para varios sitios WordPress, con nginx, PHP-FPM y MariaDB, cómo aislar cada sitio, cómo activar HTTPS con Let's Encrypt y cómo publicarlo en internet a través del firewall perimetral sin exponer más de lo necesario.
Cómo sabe nginx qué sitio servir
Todos los sitios comparten la misma IP y los mismos puertos (80 y 443). Lo que los diferencia es el encabezado Host que envía el navegador en cada petición: si alguien visita ejemplo.com, la petición llega con Host: ejemplo.com.
nginx compara ese valor con la directiva server_name de cada bloque server {} y entrega la petición al bloque que coincide. Si ninguno coincide, la petición va al servidor por defecto, un detalle que conviene controlar (lo veremos más adelante).
La arquitectura queda así:
Internet
│ 80/443
▼
Firewall perimetral ── NAT de destino (VIP) solo a 80 y 443
│
▼
Servidor Linux (DMZ)
└─ nginx ── decide el sitio según el encabezado Host
├─ sitio1 → pool PHP-FPM "sitio1" → base de datos sitio1_db
├─ sitio2 → pool PHP-FPM "sitio2" → base de datos sitio2_db
└─ sitio3 → pool PHP-FPM "sitio3" → base de datos sitio3_db
El principio que guía todo: aislamiento por sitio
El error más habitual al montar un servidor multisitio es que todos los sitios compartan el mismo usuario de sistema, el mismo proceso de PHP y el mismo usuario de base de datos. Funciona, pero basta con que un plugin vulnerable comprometa un sitio para que el atacante pueda leer y modificar todos los demás.
Por eso, cada sitio debe tener:
| Recurso | Qué aporta |
|---|---|
| Un usuario de sistema propio | Los archivos de un sitio no son escribibles por los demás. |
| Un pool de PHP-FPM propio | El código PHP de un sitio se ejecuta con su usuario y no puede salir de su carpeta. Además, un sitio saturado no deja sin recursos al resto. |
| Una base de datos y un usuario propios | Las credenciales de un sitio solo dan acceso a sus datos. |
| Sus propios logs | Diagnosticar un problema no implica revisar el tráfico de todos. |
1. Instalar el stack
Los ejemplos usan Debian o Ubuntu. Instala nginx, MariaDB, PHP-FPM con las extensiones que necesita WordPress y certbot:
sudo apt update
sudo apt install nginx mariadb-server php-fpm php-mysql php-curl php-gd \
php-mbstring php-xml php-zip php-intl php-imagick certbot python3-certbot-nginx
Después, asegura MariaDB (elimina usuarios anónimos, la base de datos de pruebas y el acceso remoto de root):
sudo mysql_secure_installation
Toma nota de la versión de PHP instalada, porque aparece en varias rutas:
php -v
ls /etc/php/
En los ejemplos se usa 8.3; sustitúyela por la tuya.
2. Cerrar la puerta por defecto
Si llega una petición con un Host que no corresponde a ningún sitio (alguien que escribe la IP directamente o un bot que escanea rangos), nginx la entrega al servidor por defecto. Con la configuración predeterminada, eso muestra la página de bienvenida de nginx o, peor, el primer sitio que encuentre.
Lo recomendable es desactivar el sitio de ejemplo y crear un servidor por defecto que cierre la conexión:
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/00-default
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
444 es un código propio de nginx: cierra la conexión sin enviar respuesta. Actívalo:
sudo ln -s /etc/nginx/sites-available/00-default /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
3. Preparar un sitio: usuario, carpetas y base de datos
Los pasos siguientes se repiten para cada sitio. En los ejemplos, el sitio se llama sitio1 y su dominio es ejemplo.com.
Usuario de sistema y carpetas
sudo adduser --system --group --no-create-home --shell /usr/sbin/nologin sitio1
sudo mkdir -p /var/www/sitio1/{public,tmp}
sudo chown -R sitio1:sitio1 /var/www/sitio1
sudo chmod 750 /var/www/sitio1
sudo usermod -aG sitio1 www-data
publiccontiene WordPress y es la raíz web.tmpes la carpeta temporal privada del sitio (para subidas y archivos temporales de PHP).www-data(el usuario de nginx) entra en el grupo del sitio para poder leer los archivos estáticos, pero no es el propietario.
Base de datos
sudo mariadb
CREATE DATABASE sitio1_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'sitio1_user'@'localhost' IDENTIFIED BY 'una-contraseña-larga-y-única';
GRANT ALL PRIVILEGES ON sitio1_db.* TO 'sitio1_user'@'localhost';
EXIT;
El usuario solo tiene permisos sobre su base de datos y solo puede conectarse desde el propio servidor.
4. Un pool de PHP-FPM por sitio
PHP-FPM trae un pool llamado www que ejecuta todo como www-data. Crea uno para el sitio:
sudo nano /etc/php/8.3/fpm/pool.d/sitio1.conf
[sitio1]
user = sitio1
group = sitio1
listen = /run/php/php-fpm-sitio1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
pm.max_requests = 500
php_admin_value[open_basedir] = /var/www/sitio1/
php_admin_value[upload_tmp_dir] = /var/www/sitio1/tmp
php_admin_value[sys_temp_dir] = /var/www/sitio1/tmp
php_admin_value[session.save_path] = /var/www/sitio1/tmp
php_admin_value[upload_max_filesize] = 128M
php_admin_value[post_max_size] = 128M
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 300
Qué hace cada bloque:
userygroup: el PHP del sitio se ejecuta comositio1, así que solo puede escribir en sus propios archivos.listen: cada pool tiene su propio socket, al que nginx enviará las peticiones PHP de ese sitio.pm = ondemand: los procesos se crean cuando llegan peticiones y se cierran tras 30 segundos sin actividad. Es la opción más eficiente cuando hay varios sitios con tráfico moderado.pm.max_children: el máximo de procesos simultáneos del sitio. La suma de todos los pools debe caber en la memoria del servidor. Para calcularlo, mide cuánto ocupa cada proceso con tu carga real:
ps -C php-fpm8.3 -o rss= | awk '{s+=$1; n++} END {print s/n/1024 " MB por proceso"}'
open_basedir: PHP no puede leer ni escribir fuera de la carpeta del sitio. Es la barrera que impide que un sitio comprometido llegue a los demás.- Límites de subida y memoria: se definen por sitio, así que uno que necesite subir archivos grandes no obliga a subir el límite de todos.
Aplica los cambios, verifica que el socket existe y, si ningún sitio lo usa, desactiva el pool www (renombrando www.conf a www.conf.disabled) para no mantener procesos innecesarios:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
ls -l /run/php/
5. El bloque de nginx del sitio
sudo nano /etc/nginx/sites-available/sitio1
server {
listen 80;
listen [::]:80;
server_name ejemplo.com www.ejemplo.com;
root /var/www/sitio1/public;
index index.php index.html;
access_log /var/log/nginx/sitio1.access.log;
error_log /var/log/nginx/sitio1.error.log;
client_max_body_size 128M;
# Archivos ocultos (.git, .env, .htaccess), salvo la validación de certificados
location ~ /\.(?!well-known) {
deny all;
}
# Nunca ejecutar PHP dentro de la carpeta de subidas
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}
# Interfaz antigua de WordPress, objetivo frecuente de ataques de fuerza bruta
location = /xmlrpc.php {
deny all;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php-fpm-sitio1.sock;
}
}
Algunos detalles que marcan la diferencia:
listen [::]:80: sin esta línea, el sitio no responde por IPv6 y esas peticiones caen en el servidor por defecto. Es una causa típica de que un dominio "a veces" muestre otra cosa.- El orden de los bloques
locationcon expresiones regulares importa: nginx usa el primero que coincide. Las reglas que bloquean (archivos ocultos, PHP enuploads) deben ir antes del bloque que envía PHP al socket; si van después, nunca se aplican. client_max_body_sizedebe coincidir conupload_max_filesizeypost_max_sizedel pool. Si nginx permite menos que PHP, verás un error413 Request Entity Too Largeal subir archivos o restaurar copias de seguridad.xmlrpc.php: bloquéalo solo si ningún servicio lo usa (algunas aplicaciones móviles o integraciones antiguas lo necesitan).
Activa el sitio y valida la configuración antes de recargar:
sudo ln -s /etc/nginx/sites-available/sitio1 /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
nginx -t es innegociable: si hay un error de sintaxis y recargas sin revisar, puedes dejar caídos todos los sitios del servidor, no solo el que estás tocando.
6. Instalar WordPress o restaurar un sitio existente
Para un sitio nuevo, descarga WordPress en la carpeta public con el usuario del sitio:
cd /tmp && curl -O https://wordpress.org/latest.tar.gz
sudo tar -xzf latest.tar.gz -C /var/www/sitio1/public --strip-components=1
sudo chown -R sitio1:sitio1 /var/www/sitio1/public
Luego completa wp-config.php con los datos de la base de datos creada en el paso 3.
Si estás migrando un sitio desde otro hosting, lo habitual es instalar un WordPress limpio y restaurar encima la copia con un plugin de migración. Tras restaurar, conviene revisar que no queden rutas o URL del hosting anterior. Con WP-CLI se hace de forma segura (primero en modo simulación):
sudo -u sitio1 wp search-replace 'https://dominio-anterior.com' 'https://ejemplo.com' \
--all-tables --dry-run --path=/var/www/sitio1/public
Si el resultado es el esperado, repite el comando sin --dry-run.
7. Probar el sitio antes de tocar el DNS
No hace falta cambiar el DNS para verificar que todo funciona. Hay dos formas de "engañar" al cliente para que envíe el Host correcto a la IP del servidor:
Desde la línea de comandos, con curl:
curl -I -H "Host: ejemplo.com" http://IP-DEL-SERVIDOR/
Desde el navegador, añadiendo una línea al archivo hosts de tu equipo (C:\Windows\System32\drivers\etc\hosts en Windows, /etc/hosts en Linux y macOS):
IP-DEL-SERVIDOR ejemplo.com www.ejemplo.com
Así puedes revisar el sitio completo, entrar en el panel de WordPress y probar formularios. Recuerda borrar esa línea al terminar, o seguirás viendo el servidor nuevo aunque el DNS apunte a otro sitio.
8. HTTPS con Let's Encrypt
Cuando el DNS del dominio ya apunta a la IP pública y el puerto 80 es accesible desde internet, certbot obtiene el certificado y modifica el bloque de nginx para servir HTTPS y redirigir HTTP:
sudo certbot --nginx -d ejemplo.com -d www.ejemplo.com
Los certificados duran 90 días y se renuevan solos mediante un temporizador de systemd. Verifica que la renovación funcionará:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
Dos puntos a tener en cuenta:
- La validación estándar (HTTP-01) necesita que el puerto 80 esté abierto desde internet, también para las renovaciones. Si lo cierras después de emitir el certificado, fallará dentro de dos meses.
- Si no puedes abrir el puerto 80, o necesitas un certificado comodín (
*.ejemplo.com), usa la validación por DNS (DNS-01), que funciona creando un registro TXT en la zona del dominio.
9. Publicarlo en internet a través del firewall
Esta es la parte que muchas guías resuelven con un "abre los puertos 80 y 443". Hacerlo bien requiere algo más de cuidado.
El servidor, en su propia zona
El servidor web no debería estar en la misma red que los equipos de los usuarios ni que los servidores internos. Colócalo en una DMZ o en una VLAN dedicada, de forma que, si un sitio se ve comprometido, el atacante no tenga acceso directo al resto de la red.
Publicar solo lo necesario
El servidor no necesita una IP pública propia. El firewall recibe el tráfico en su IP pública y lo redirige a la IP privada del servidor mediante NAT de destino. En FortiGate esto se configura con una IP virtual (VIP):
- Activa la opción de redirección de puertos en la VIP y publica solo el 80 y el 443. Una VIP sin redirección de puertos hace un NAT estático de todos los puertos, y la protección queda en manos de que la regla del firewall esté bien definida.
- Crea una regla de entrada desde la interfaz WAN hacia la VIP, con los servicios HTTP y HTTPS únicamente.
- En esa regla, no actives el NAT de origen. Así el servidor ve la IP real de los visitantes, y tus logs y los plugins de seguridad de WordPress trabajan con datos útiles.
Perfiles de seguridad
Aplica a la regla de entrada los perfiles de inspección que tengas disponibles: IPS (detecta intentos de explotación conocidos), antivirus y, si la licencia lo incluye, WAF. Son una capa adicional a la que ya aporta nginx.
Si decides restringir el acceso por país, ten presente que Let's Encrypt valida los dominios desde varias ubicaciones en distintas regiones. Un filtro geográfico demasiado estricto puede hacer que las renovaciones fallen sin que lo notes.
La administración, nunca por internet
SSH, phpMyAdmin o cualquier panel de administración del servidor no se publican. Se accede a ellos desde la red interna o a través de la VPN de la organización. Si necesitas proteger también el panel de WordPress (/wp-admin), puedes limitarlo por IP en nginx o exigir un segundo factor de autenticación.
Tráfico de salida controlado
La DMZ tampoco debería tener salida libre. El servidor necesita, como mucho:
- Salida a internet por HTTPS (actualizaciones del sistema, de WordPress y de plugins, y renovación de certificados).
- DNS.
- SMTP autenticado, si WordPress envía correo a través de un servidor externo.
Y ningún acceso iniciado desde la DMZ hacia la red interna, salvo excepciones concretas y documentadas.
Acceso desde la red interna
Cuando los usuarios de la oficina visitan el sitio, el dominio resuelve a la IP pública del propio firewall. Según la configuración, esas peticiones pueden no llegar al servidor. Hay dos soluciones habituales: un DNS interno que resuelva el dominio a la IP privada del servidor, o una regla adicional en el firewall que permita a la red interna acceder a la VIP (lo que se conoce como hairpin NAT).
10. Añadir un sitio nuevo: lista de verificación
Con la estructura anterior, cada sitio nuevo sigue siempre el mismo procedimiento:
- Crear el usuario de sistema y las carpetas
publicytmp. - Añadir
www-dataal grupo del sitio. - Crear la base de datos y su usuario.
- Copiar la plantilla del pool de PHP-FPM, cambiar el nombre, el usuario, el socket y las rutas, y recargar PHP-FPM.
- Copiar la plantilla del bloque de nginx, cambiar
server_name,root, los logs y el socket, y validar connginx -t. - Instalar o restaurar WordPress.
- Probar con
curlo con el archivohosts. - Cambiar el DNS y emitir el certificado con certbot.
- Actualizar la documentación del servidor.
El último paso es el que más se olvida y el que más se agradece cuando otra persona tiene que intervenir.
Errores frecuentes y cómo resolverlos
| Síntoma | Causa probable | Qué revisar |
|---|---|---|
502 Bad Gateway en todo el sitio |
nginx no encuentra el socket de PHP-FPM | Que la ruta de fastcgi_pass coincida con la de ls /run/php/ y que el pool esté activo. |
| El dominio muestra otro sitio o la página de bienvenida de nginx | La petición cae en el servidor por defecto | server_name (con y sin www), listen [::]:80 y el servidor por defecto del paso 2. |
413 Request Entity Too Large al subir archivos |
Límite de tamaño de nginx | client_max_body_size y los límites de subida del pool. |
| Error al subir archivos o al actualizar plugins | Permisos o open_basedir |
El propietario de los archivos y que upload_tmp_dir esté dentro de la carpeta del sitio. |
| Tras restaurar una copia, el sitio carga pero el panel de WordPress falla | Un plugin (a menudo de seguridad) necesita tablas o archivos que no venían en la copia | /var/log/nginx/sitio1.error.log. Para descartar el plugin, renombra su carpeta en wp-content/plugins. |
| Contenido mixto o enlaces al dominio anterior | URL antiguas guardadas en la base de datos | wp search-replace (paso 6). |
Ante cualquier problema, los logs son el primer lugar donde mirar:
sudo tail -f /var/log/nginx/sitio1.error.log
sudo journalctl -u php8.3-fpm -f
Mantenimiento básico
Un servidor web que se monta y se olvida acaba siendo un riesgo. Como mínimo:
- Copias de seguridad de cada sitio por separado: los archivos de
/var/www/sitioXy un volcado de su base de datos (mariadb-dump sitio1_db). Guárdalas fuera del servidor y verifica de vez en cuando que se pueden restaurar. - Actualizaciones automáticas de seguridad del sistema con
unattended-upgrades. - Actualizaciones de WordPress y plugins, con una revisión periódica de los plugins que ya no se usan: cada plugin inactivo es código vulnerable que sigue en el servidor.
- Documentación de cada sitio: dominio, usuario, base de datos, pool y cualquier particularidad.
Conclusión
Alojar varios sitios en un servidor propio no es solo una cuestión de ahorro: es recuperar el control sobre servicios que forman parte de la imagen de la organización. La clave está en tratar cada sitio como un inquilino independiente (su usuario, su pool de PHP, su base de datos y sus logs) y en publicar el servidor con el mínimo de exposición posible.
Con esta base, el siguiente paso natural es el cambio de DNS de los dominios, que tiene sus propios riesgos, sobre todo cuando el mismo dominio se usa también para el correo corporativo.


