Administración de Linux

Montar un stack LAMP: Apache, MariaDB, PHP-FPM, virtual hosts y TLS

Intermedio9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Un servidor LAMP son cuatro servicios independientes que tienen que ponerse de acuerdo sobre sockets, usuarios y permisos de ficheros. Esta guía monta uno a conciencia en lugar de copiar un comando mágico: Apache con el MPM event y virtual hosts por nombre, MariaDB con una cuenta de aplicación de mínimo privilegio, PHP-FPM en su propio pool, una propiedad razonable del document root y TLS de Let's Encrypt. Cada paso termina con una forma de comprobarlo.

Lo que aprenderás

  • Instalar y verificar Apache, MariaDB y PHP-FPM en un servidor Debian o de la familia RHEL
  • Configurar virtual hosts por nombre con document roots y ficheros de log separados
  • Crear una base de datos y una cuenta de aplicación con solo los privilegios que necesita
  • Conectar Apache con PHP-FPM mediante un socket y ajustar un pool a la memoria disponible
  • Obtener y renovar un certificado de Let's Encrypt y aplicar una checklist básica de hardening

LAMP lleva veinticinco años siendo la forma por defecto de servir un sitio web dinámico, y el significado del acrónimo ha ido cambiando sin hacer ruido. La L de Linux sigue siendo la misma. La A sigue siendo Apache, pero el modelo de procesos recomendado ya no es el mismo. La M ahora suele ser MariaDB. Y la P ya ni siquiera se ejecuta dentro del servidor web: PHP es un servicio aparte con el que Apache habla a través de un socket.

Ese último cambio es el que merece la pena entender, porque explica la mayor parte de la configuración que sigue. Esta guía monta paso a paso un servidor funcional en un host Debian o Ubuntu actual, indica los equivalentes para la familia RHEL y verifica cada capa antes de pasar a la siguiente. Aquí no hay nada exótico; el valor está en hacerlo a conciencia, para que cuando algo falle sepas cuál de los cuatro servicios tienes que mirar.

Instalar las piezas

# Debian / Ubuntu
sudo apt update
sudo apt install apache2 mariadb-server php-fpm php-mysql php-mbstring php-xml php-curl php-zip

# RHEL family (Rocky, Alma, RHEL) — Apache is packaged as httpd
sudo dnf install httpd mariadb-server php-fpm php-mysqlnd php-mbstring php-xml
sudo systemctl enable --now httpd mariadb php-fpm

Los nombres de los paquetes cambian (apache2 frente a httpd, php-mysql frente a php-mysqlnd), y también la organización de la configuración: Debian usa /etc/apache2/ con sites-available y sites-enabled más los asistentes a2ensite y a2enmod, mientras que RHEL usa un único directorio plano, /etc/httpd/conf.d/. Todo lo demás es trasladable. Si la propia gestión de paquetes te resulta poco familiar, la guía de gestión de paquetes cubre los repositorios y el anclaje de versiones.

Comprueba que los servicios están levantados antes de tocar ninguna configuración:

$ systemctl is-active apache2 mariadb php8.3-fpm
active
active
active
$ sudo ss -ltnp | grep -E ':(80|3306)'
LISTEN 0 511  0.0.0.0:80   0.0.0.0:* users:(("apache2",pid=1201,fd=4))
LISTEN 0 80  127.0.0.1:3306 0.0.0.0:* users:(("mariadb",pid=1408,fd=25))

Fíjate en que MariaDB solo escucha por defecto en la interfaz de loopback. Es lo correcto para un host LAMP de un solo servidor y debe seguir así: una base de datos con dirección pública es una invitación, y la guía de redes explica cómo llegar a ella a través de un túnel SSH cuando necesites un cliente en tu portátil.

Apache: modelo de procesos y módulos

Apache 2.4 elige un módulo de multiprocesamiento (MPM) al compilarse o en la configuración. prefork usa un proceso por conexión y existe sobre todo para mod_php; event es un modelo con threads que saca las conexiones keep-alive inactivas de los threads de trabajo y soporta mucha más concurrencia con la misma memoria. Con PHP ejecutándose bajo FPM, event es la opción correcta.

sudo a2dismod php8.3 mpm_prefork 2>/dev/null
sudo a2enmod mpm_event proxy_fcgi setenvif rewrite headers ssl http2
sudo a2enconf php8.3-fpm
sudo systemctl restart apache2
apache2ctl -M | grep -E 'mpm|fcgi|rewrite'

proxy_fcgi es el que reenvía las peticiones PHP a FPM; rewrite lo necesita el front controller de casi cualquier framework PHP; headers te permite definir cabeceras de seguridad; y ssl y http2 los vas a querer en breve. No actives nada más: cada módulo es código en el camino de la petición.

Ajustar el MPM event es cuestión de aritmética, no de folclore. Apache es ahora ligero porque PHP vive en otra parte, así que los límites sirven sobre todo para evitar que encoles más trabajo del que la máquina puede hacer:

# /etc/apache2/mods-available/mpm_event.conf
<IfModule mpm_event_module>
    StartServers             2
    MinSpareThreads         25
    MaxSpareThreads         75
    ThreadsPerChild         25
    MaxRequestWorkers      150
    MaxConnectionsPerChild   0
</IfModule>

Virtual hosts

Un virtual host por nombre permite que una sola dirección sirva muchos sitios; Apache elige el bloque cuyo ServerName o ServerAlias coincide con la cabecera Host. Dale a cada sitio su propio fichero, su propio document root y sus propios ficheros de log.

# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
    ServerName  example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example.com/public

    <Directory /var/www/example.com/public>
        Options -Indexes +FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>

    # Never serve the application's internals, even if a path escapes the root
    <DirectoryMatch "/(\.git|vendor|storage)/">
        Require all denied
    </DirectoryMatch>

    ErrorLog  ${APACHE_LOG_DIR}/example.com-error.log
    CustomLog ${APACHE_LOG_DIR}/example.com-access.log combined
</VirtualHost>
sudo a2ensite example.com
sudo apache2ctl configtest       # or: httpd -t
sudo systemctl reload apache2

Hay cuatro detalles de ese bloque que conviene explicar. DocumentRoot apunta a un subdirectorio public/, no a la raíz del proyecto, de modo que los ficheros de configuración y las dependencias quedan físicamente fuera del árbol servido. Options -Indexes impide que Apache genere un listado del directorio cuando no hay fichero índice, una fuente habitual de fugas accidentales. AllowOverride None desactiva .htaccess, lo que significa que Apache no busca esos ficheros en cada petición y que tu configuración vive en un solo sitio; actívalo solo para las aplicaciones que de verdad lo necesiten. Y Require all granted es la sintaxis de autorización de la 2.4 que sustituyó a Order allow,deny; los tutoriales antiguos que muestran la forma de la 2.2 producen una configuración que no carga.

Ejecuta siempre configtest antes de recargar. Un error de sintaxis en un reload deja la configuración anterior sirviendo; un error de sintaxis que solo aparece en un restart deja el sitio caído.

MariaDB: asegúrala y aplica el mínimo privilegio

Lo primero que hay que ejecutar tras la instalación es el script de hardening, que elimina las cuentas anónimas y la base de datos de pruebas y se ofrece a desactivar el login remoto de root:

sudo mariadb-secure-installation     # mysql_secure_installation on MySQL

En las versiones actuales de MariaDB, la cuenta root se autentica mediante el plugin unix_socket, lo que significa que sudo mariadb funciona sin contraseña y que el login remoto de root es imposible por diseño. Déjalo así. Las aplicaciones tienen su propia cuenta, restringida a su propia base de datos y al host desde el que se conectan:

CREATE DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'a-long-random-passphrase';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';

-- A separate account for migrations, used only by the deployment process
CREATE USER 'shop_ddl'@'localhost' IDENTIFIED BY 'another-long-passphrase';
GRANT ALL PRIVILEGES ON shop.* TO 'shop_ddl'@'localhost';

FLUSH PRIVILEGES;
SHOW GRANTS FOR 'shop_app'@'localhost';

utf8mb4 es el único juego de caracteres correcto; el antiguo utf8 de MySQL almacena como máximo tres bytes por carácter y no puede representar emojis ni algunos caracteres CJK. Separar la cuenta de ejecución de la cuenta de migraciones del esquema hace que una vulnerabilidad de inyección SQL en la aplicación no pueda borrar una tabla, un control barato y eficaz. La parte de esquema y consultas se trata en buenas prácticas de SQL.

Hay dos tareas operativas que tienen su sitio aquí y no más adelante. Haz una copia de seguridad y demuestra que sabes restaurarla:

sudo mariadb-dump --single-transaction --routines --events shop | zstd -o /srv/backups/shop-$(date +%F).sql.zst
zstd -dc /srv/backups/shop-2026-09-07.sql.zst | sudo mariadb shop_restore_test

Y mantén las credenciales fuera del historial del shell y del listado de procesos guardándolas en un fichero de opciones con modo 600 en lugar de pasarlas por la línea de comandos:

# /root/.my.cnf   (chmod 600)
[client]
user = root
password = only-if-you-are-not-using-socket-auth

PHP-FPM

FPM ejecuta pools: grupos independientes de procesos worker, cada uno con su propio usuario, su socket y sus límites. Dale a cada sitio su propio pool para que una aplicación desbocada no pueda dejar sin recursos a las demás y para que la propiedad de los ficheros siga separada.

; /etc/php/8.3/fpm/pool.d/example.com.conf
[example.com]
user = www-example
group = www-example
listen = /run/php/php8.3-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

php_admin_value[error_log] = /var/log/php/example.com-error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 16M
php_admin_value[post_max_size] = 16M
php_admin_value[open_basedir] = /var/www/example.com:/tmp

El número que importa es pm.max_children. Multiplícalo por la memoria media que usa una petición (mídela, no la adivines) y el resultado tiene que caber holgadamente en la RAM, dejando sitio para MariaDB y la caché de páginas. Veinte workers a 80 MB son 1,6 GB, que ya es demasiado para una máquina de 2 GB. pm.max_requests recicla un worker tras cierto número de peticiones, lo que tapa las fugas lentas del código de terceros.

open_basedir confina el intérprete a las rutas indicadas, de modo que un fallo de inclusión de ficheros no pueda leer /etc/passwd. A diferencia de un simple php_value, lo que se fija con php_admin_value no puede sobrescribirlo la aplicación en tiempo de ejecución.

Haz que el virtual host apunte al socket:

    <FilesMatch \.php$>
        SetHandler "proxy:unix:/run/php/php8.3-fpm-example.sock|fcgi://localhost/"
    </FilesMatch>
    <FilesMatch "\.(env|ini|log|sh|sql|bak)$">
        Require all denied
    </FilesMatch>
sudo systemctl reload php8.3-fpm apache2

En producción, activa la caché de opcodes y desactiva los ajustes que filtran información. En /etc/php/8.3/fpm/php.ini:

display_errors = Off
expose_php = Off
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

opcache.validate_timestamps = 0 evita que PHP compruebe la fecha de modificación de los ficheros en cada petición, lo que supone una mejora medible, pero implica que tienes que recargar FPM en cada despliegue. Haz que forme parte de tu script de despliegue en lugar de algo que tengas que recordar.

Permisos del document root

Aquí es donde la mayoría de instalaciones LAMP se tuercen sin que nadie lo note, normalmente a base de chmod -R 777. El principio es sencillo: el servidor web debe poder leer la aplicación y escribir solo en los pocos directorios que de verdad lo necesitan.

sudo useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin www-example
sudo chown -R deploy:www-example /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 750 {} +
sudo find /var/www/example.com -type f -exec chmod 640 {} +

# Only these need to be writable by the PHP worker
sudo chown -R www-example:www-example /var/www/example.com/storage /var/www/example.com/var/cache
sudo chmod -R 770 /var/www/example.com/storage

Los ficheros pertenecen a la cuenta de despliegue y los puede leer el grupo del pool; el usuario del pool solo es dueño de los directorios con permiso de escritura. Nada es legible por todo el mundo, y los ficheros subidos no se pueden ejecutar porque el intérprete solo procesa las rutas que coinciden con el handler de FilesMatch; por eso mismo, un directorio de subidas debería servirse desde una ruta donde el tratamiento de .php esté completamente desactivado. El razonamiento detrás de estos modos, y la alternativa con ACL cuando la propiedad se complica, están en usuarios, grupos y permisos.

En un host de la familia RHEL, SELinux añade una segunda capa. Los ficheros deben llevar la etiqueta correcta, y el demonio necesita un booleano antes de poder, por ejemplo, abrir una conexión de red:

sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/storage(/.*)?"
sudo restorecon -Rv /var/www/example.com
sudo setsebool -P httpd_can_network_connect_db on

Desactivar SELinux no es una solución; ausearch -m avc -ts recent te dice exactamente qué se ha denegado.

TLS con Let's Encrypt

Certbot obtiene un certificado, edita el virtual host e instala un timer de renovación.

sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d example.com -d www.example.com --redirect --agree-tos -m [email protected]
sudo certbot renew --dry-run
systemctl list-timers 'certbot*'

--redirect añade la redirección permanente del puerto 80 al 443. El challenge HTTP-01 necesita el puerto 80 abierto a Internet, así que déjalo accesible en lugar de cerrarlo en el firewall tras la primera emisión. Después, define explícitamente la política de protocolos en el virtual host TLS:

    SSLProtocol -all +TLSv1.2 +TLSv1.3
    SSLHonorCipherOrder off
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Protocols h2 http/1.1

Activa HSTS solo cuando tengas la certeza de que todos los subdominios seguirán funcionando por HTTPS: los navegadores guardan la cabecera en caché durante toda su vigencia y no hay forma rápida de deshacerlo. Empieza con un max-age corto, confirma que nada se ha roto y luego súbelo.

Verificar todo el stack

$ curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://example.com/
301 https://example.com/
$ curl -sSI https://example.com/ | head -6
HTTP/2 200
server: Apache
strict-transport-security: max-age=31536000; includeSubDomains
$ printf '<?php echo "db: ", (new PDO("mysql:host=localhost;dbname=shop","shop_app","...")) ? "ok" : "fail";' \
    | sudo -u www-example php
db: ok
$ sudo tail -n 20 /var/log/apache2/example.com-error.log

Confirma cuatro cosas por separado: la redirección funciona, el TLS termina con las cabeceras que has definido, PHP llega a la base de datos como usuario del pool y el log de errores está limpio. Nunca dejes olvidada una página phpinfo(): publica tus rutas, tus extensiones y, a menudo, las variables de entorno.

Checklist de hardening

  • Mantén el host parcheado, idealmente con las actualizaciones de seguridad automáticas activadas.
  • Bloquea en el firewall todo salvo 80, 443 y SSH; mantén MariaDB en loopback.
  • Ejecuta cada sitio con el usuario de su propio pool de FPM, con open_basedir definido.
  • display_errors = Off, expose_php = Off, ServerTokens Prod y ServerSignature Off.
  • Deniega en el virtual host el acceso a ficheros ocultos, .env, copias de seguridad y directorios de control de versiones.
  • Desactiva .htaccess salvo que una aplicación lo necesite.
  • Define LimitRequestBody y usa mod_reqtimeout y mod_evasive o un reverse proxy para un rate limiting básico.
  • Rota y monitoriza los logs de cada sitio; envíalos a algún lugar donde sobrevivan a la máquina.
  • Haz copia de seguridad de la base de datos y del directorio de subidas, y restaura ambos periódicamente en un entorno desechable.

Errores comunes

  • chmod -R 777 en el document root. Cualquier cuenta local puede ahora reescribir tu aplicación. Corrige la propiedad de los ficheros en su lugar.
  • Apuntar DocumentRoot a la raíz del proyecto. .env, composer.json y .git pasan a poder descargarse.
  • Dejar AllowOverride All activado por costumbre. Obliga a comprobar el sistema de ficheros en cada petición y dispersa la configuración.
  • Usar la sintaxis de autorización de Apache 2.2. Order allow,deny ya no se aplica; usa Require.
  • Reiniciar en lugar de recargar tras un cambio de configuración. configtest y después reload mantiene el sitio en pie si has cometido una errata.
  • Poner pm.max_children alto porque lo hacía un tutorial. Multiplica antes por la memoria por petición; sobredimensionar convierte un pico de tráfico en swapping.
  • Dar ALL PRIVILEGES a la cuenta de la aplicación. Separa las credenciales de ejecución y las de migración.
  • Desactivar SELinux para que desaparezca un 403. Etiqueta los ficheros; ausearch te dice cuáles.

Ideas clave

  • Un LAMP moderno ejecuta PHP como un servicio FPM aparte, lo que permite a Apache usar el MPM event con threads y a cada sitio ejecutarse con su propio usuario.
  • Dale a cada sitio su propio fichero de virtual host, un document root bajo public/, sus ficheros de log y su pool de FPM.
  • Sirve desde un directorio que el usuario web pueda leer pero no escribir, con una lista corta de rutas explícitamente escribibles; nunca 777.
  • Asegura MariaDB al instalarla, mantenla en loopback, usa utf8mb4 y separa la cuenta de ejecución de la de migraciones.
  • Dimensiona pm.max_children a partir de la memoria por petición medida, y activa OPcache con la validación de timestamps desactivada y una recarga de FPM en tu despliegue.
  • Let's Encrypt con certbot se encarga de la emisión y la renovación; añade HSTS y el resto de cabeceras de seguridad de forma deliberada, empezando con una vigencia corta.
  • Verifica cada capa con configtest, curl -I, una conexión a la base de datos como usuario del pool y un log de errores limpio antes de dar el servidor por terminado.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Uso MySQL o MariaDB?
Para un servidor LAMP nuevo cualquiera de los dos sirve, y ambos están en los repositorios por defecto. MariaDB es la opción por defecto en la mayoría de distribuciones Linux, es totalmente open source y sigue siendo compatible a nivel de protocolo para las aplicaciones PHP habituales. Elige MySQL cuando una aplicación exija expresamente una funcionalidad de Oracle MySQL o una versión concreta. Instales el que instales, mantén la biblioteca cliente y el servidor en versiones compatibles y evita mezclar paquetes de ambos proyectos en un mismo host.
¿Qué diferencia hay entre mod_php y PHP-FPM?
mod_php incrusta el intérprete en cada proceso de Apache, lo que obliga a usar el MPM prefork y un proceso pesado por conexión. PHP-FPM ejecuta PHP como un pool de workers aparte con el que Apache habla a través de un socket, así que Apache puede usar el MPM event con threads, los ficheros estáticos no cuestan casi nada y cada sitio puede ejecutarse con su propio usuario y sus propios límites. FPM es la recomendación actual para cualquier cosa que vaya más allá de un sitio de juguete.
¿Por qué recibo un 403 Forbidden en un directorio que existe?
Tres causas explican casi todos los casos. El usuario de Apache no puede atravesar alguno de los directorios de la ruta, así que revísalos uno a uno con namei -l. La configuración no tiene un Require all granted para ese directorio. O SELinux está en modo enforcing y los ficheros no llevan la etiqueta httpd_sys_content_t. Lee primero el log de errores: Apache indica cuál de las tres se ha encontrado, y adivinar hace perder mucho más tiempo.
¿Cómo se renuevan realmente los certificados de Let's Encrypt?
El paquete certbot instala un timer de systemd que se ejecuta dos veces al día, revisa todos los certificados y renueva los que tengan menos de treinta días de validez. Hasta entonces no se renueva nada, así que es normal que el timer se ejecute a menudo. Comprueba toda la cadena con certbot renew --dry-run, que hace un challenge real contra el entorno de staging, y asegúrate de que el puerto 80 sigue accesible, porque el challenge HTTP-01 lo necesita.