← Volver al blog

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.

Esquema de nginx dirigiendo cada petición a su sitio según el dominio, con un pool de PHP y una base de datos independientes para cada uno

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
  • public contiene WordPress y es la raíz web.
  • tmp es 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:

  • user y group: el PHP del sitio se ejecuta como sitio1, 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 location con expresiones regulares importa: nginx usa el primero que coincide. Las reglas que bloquean (archivos ocultos, PHP en uploads) deben ir antes del bloque que envía PHP al socket; si van después, nunca se aplican.
  • client_max_body_size debe coincidir con upload_max_filesize y post_max_size del pool. Si nginx permite menos que PHP, verás un error 413 Request Entity Too Large al 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:

  1. Crear el usuario de sistema y las carpetas public y tmp.
  2. Añadir www-data al grupo del sitio.
  3. Crear la base de datos y su usuario.
  4. Copiar la plantilla del pool de PHP-FPM, cambiar el nombre, el usuario, el socket y las rutas, y recargar PHP-FPM.
  5. Copiar la plantilla del bloque de nginx, cambiar server_name, root, los logs y el socket, y validar con nginx -t.
  6. Instalar o restaurar WordPress.
  7. Probar con curl o con el archivo hosts.
  8. Cambiar el DNS y emitir el certificado con certbot.
  9. 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/sitioX y 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.