Skip to content

Stack LAMP y LEMP ​

LAMP (Linux + Apache + MySQL + PHP) y LEMP (Linux + Nginx, pronunciado "Engine-x", + MySQL + PHP) son las combinaciones clásicas para ejecutar PHP: WordPress, Laravel, sistemas a medida. En esta lección instalarás ambos en un VPS Ubuntu 24.04 y desplegarás un proyecto PHP + MySQL real y un proyecto Laravel.

Concepto ​

CapaLAMPLEMP
Sistema operativoLinux (Ubuntu)Linux (Ubuntu)
Servidor webApache + mod_phpNginx
Ejecuta PHPDentro de ApachePHP-FPM (proceso aparte)
Base de datosMySQL o MariaDBMySQL o MariaDB

¿Cuál elegir? LAMP si necesitas .htaccess (por ejemplo, migrar desde un hosting compartido o plugins de WordPress que lo usan). LEMP si buscas mejor rendimiento y menos memoria. Ambos son válidos en producción.

Requisito

Parte de un VPS configurado como en Configurar un VPS desde cero: usuario con sudo, ufw activo y dominio apuntando.

Opción A: instalar LAMP ​

bash
sudo apt update

# Apache
sudo apt install -y apache2
sudo ufw allow 'Apache Full'

# MySQL
sudo apt install -y mysql-server

# PHP 8.3 (versión incluida en Ubuntu 24.04) + extensiones comunes
sudo apt install -y php libapache2-mod-php php-mysql php-mbstring php-xml \
  php-curl php-zip php-gd php-intl php-bcmath

# Activar mod_rewrite y reiniciar
sudo a2enmod rewrite
sudo systemctl restart apache2

php -v

Opción B: instalar LEMP ​

bash
sudo apt update

# Nginx
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'

# MySQL
sudo apt install -y mysql-server

# PHP-FPM + extensiones (sin el módulo de Apache)
sudo apt install -y php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml \
  php8.3-curl php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath

sudo systemctl status php8.3-fpm

Error común

Instalar el paquete php en un servidor LEMP. Ese metapaquete puede arrastrar libapache2-mod-php y Apache, que ocupará el puerto 80 y Nginx no podrá arrancar. En LEMP instala siempre php8.3-fpm y extensiones explícitas.

Asegurar MySQL y crear la base de datos ​

bash
sudo mysql_secure_installation

Respuestas recomendadas: activar el validador de contraseñas (opcional), eliminar usuarios anónimos sí, deshabilitar login remoto de root sí, eliminar base test sí, recargar privilegios sí.

En Ubuntu, el root de MySQL entra con sudo mysql (autenticación por socket). Crea una base y un usuario exclusivo para la app:

bash
sudo mysql
sql
-- Base de datos con soporte completo de acentos y emojis
CREATE DATABASE tienda CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Usuario que solo puede conectarse desde el propio servidor
CREATE USER 'tienda_app'@'localhost' IDENTIFIED BY 'Cambia-Esta-Contraseña-123!';

-- Permisos solo sobre su base
GRANT ALL PRIVILEGES ON tienda.* TO 'tienda_app'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Probar que PHP conecta con MySQL ​

Crea un archivo temporal de prueba:

bash
sudo mkdir -p /var/www/tienda.com/public
sudo chown -R $USER:www-data /var/www/tienda.com
nano /var/www/tienda.com/public/prueba.php
php
<?php
// prueba.php — BORRAR después de comprobar
$dsn = 'mysql:host=localhost;dbname=tienda;charset=utf8mb4';

try {
    $pdo = new PDO($dsn, 'tienda_app', 'Cambia-Esta-Contraseña-123!', [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
    $version = $pdo->query('SELECT VERSION()')->fetchColumn();
    echo "Conexión correcta. MySQL $version — PHP " . PHP_VERSION;
} catch (PDOException $e) {
    echo 'Error de conexión: ' . htmlspecialchars($e->getMessage());
}

Server block / virtual host ​

apache
<VirtualHost *:80>
    ServerName tienda.com
    ServerAlias www.tienda.com
    DocumentRoot /var/www/tienda.com/public

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

    ErrorLog ${APACHE_LOG_DIR}/tienda-error.log
    CustomLog ${APACHE_LOG_DIR}/tienda-access.log combined
</VirtualHost>
nginx
server {
    listen 80;
    listen [::]:80;
    server_name tienda.com www.tienda.com;
    root /var/www/tienda.com/public;
    index index.php index.html;

    access_log /var/log/nginx/tienda.access.log;
    error_log  /var/log/nginx/tienda.error.log;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }

    client_max_body_size 20M;
}
bash
sudo a2ensite tienda.com.conf
sudo a2dissite 000-default.conf
sudo apachectl configtest && sudo systemctl reload apache2
bash
sudo ln -s /etc/nginx/sites-available/tienda.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Visita http://tienda.com/prueba.php. Si ves "Conexión correcta", borra el archivo: rm /var/www/tienda.com/public/prueba.php.

Ajustes de PHP para producción ​

Edita /etc/php/8.3/fpm/php.ini (LEMP) o /etc/php/8.3/apache2/php.ini (LAMP):

ini
; No mostrar errores al usuario, pero sí registrarlos
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log

; Subidas de archivos
upload_max_filesize = 20M
post_max_size = 25M

; Límites
memory_limit = 256M
max_execution_time = 60

; Zona horaria
date.timezone = America/Mexico_City

; Ocultar versión de PHP en las cabeceras
expose_php = Off

; OPcache: acelera PHP guardando el código compilado en memoria
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
bash
sudo touch /var/log/php_errors.log && sudo chown www-data:www-data /var/log/php_errors.log
# LEMP
sudo systemctl restart php8.3-fpm
# LAMP
sudo systemctl restart apache2

Proyecto 1: desplegar un proyecto PHP + MySQL desde Git ​

bash
# 1. Clonar el repositorio
cd /var/www
sudo rm -rf tienda.com            # si existía la carpeta de prueba
sudo mkdir tienda.com && sudo chown $USER:www-data tienda.com
git clone git@github.com:tu-usuario/tienda.git tienda.com
cd tienda.com

# 2. Archivo de configuración con secretos (NO va en Git)
cp .env.example .env
nano .env
chmod 640 .env

# 3. Importar el esquema y datos
mysql -u tienda_app -p tienda < database/esquema.sql

# 4. Permisos: carpetas 755, archivos 644, uploads con escritura del grupo
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod 640 .env
mkdir -p public/uploads && chmod 775 public/uploads

Ejemplo de .env y cómo leerlo sin librerías:

env
DB_HOST=localhost
DB_NAME=tienda
DB_USER=tienda_app
DB_PASS=Cambia-Esta-Contraseña-123!
APP_DEBUG=false
php
<?php
// src/config.php — carga un .env sencillo (clave=valor)
function cargarEnv(string $ruta): void
{
    if (!is_file($ruta)) {
        return;
    }
    foreach (file($ruta, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) {
        if (str_starts_with(trim($linea), '#') || !str_contains($linea, '=')) {
            continue;
        }
        [$clave, $valor] = array_map('trim', explode('=', $linea, 2));
        $_ENV[$clave] = $valor;
    }
}

cargarEnv(__DIR__ . '/../.env');

$pdo = new PDO(
    sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $_ENV['DB_HOST'], $_ENV['DB_NAME']),
    $_ENV['DB_USER'],
    $_ENV['DB_PASS'],
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);

Para actualizar en el futuro: cd /var/www/tienda.com && git pull.

Proyecto 2: desplegar Laravel (LEMP) ​

bash
# Composer
sudo apt install -y composer unzip

# Clonar
cd /var/www
sudo mkdir laravel-app && sudo chown $USER:www-data laravel-app
git clone git@github.com:tu-usuario/laravel-app.git laravel-app
cd laravel-app

# Dependencias de producción
composer install --no-dev --optimize-autoloader

# Configuración
cp .env.example .env
php artisan key:generate
nano .env    # APP_ENV=production, APP_DEBUG=false, APP_URL, DB_*

# Base de datos
php artisan migrate --force

# Permisos: Laravel necesita escribir en storage y bootstrap/cache
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache

# Enlace de archivos públicos y cachés de producción
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache

Server block: la raíz debe ser public/, nunca la carpeta del proyecto (expondrías el .env).

nginx
server {
    listen 80;
    server_name app.cliente.com;
    root /var/www/laravel-app/public;
    index index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Tareas programadas y colas de Laravel:

bash
# Scheduler: agregar al crontab del usuario (crontab -e)
* * * * * cd /var/www/laravel-app && php artisan schedule:run >> /dev/null 2>&1

phpMyAdmin: ¿sí o no? ​

Instalar phpMyAdmin en producción expone un panel de base de datos a internet, un blanco favorito de bots. Mejor opción: túnel SSH y un cliente de escritorio (DBeaver, HeidiSQL, TablePlus, MySQL Workbench):

bash
ssh -L 3307:127.0.0.1:3306 deploy@tienda.com
# conecta tu cliente a 127.0.0.1:3307 con tienda_app

Si el cliente insiste en phpMyAdmin, protégelo con contraseña adicional (auth básica de Nginx/Apache), con una URL no obvia y restringido por IP.

Buena práctica

Un usuario de MySQL por aplicación, con permisos solo sobre su base. Si una app es vulnerada, el daño queda contenido.

Ejercicios ​

  1. Crea en MySQL la base blog con utf8mb4 y un usuario blog_app con permisos solo sobre ella.
  2. Tu sitio Laravel muestra "The stream or file storage/logs/laravel.log could not be opened". ¿Qué haces?
  3. ¿Por qué la raíz del server block de Laravel debe ser public/?
Ver solución
sql
CREATE DATABASE blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'blog_app'@'localhost' IDENTIFIED BY 'Otra-Contraseña-Fuerte!';
GRANT ALL PRIVILEGES ON blog.* TO 'blog_app'@'localhost';
FLUSH PRIVILEGES;
  1. Es un problema de permisos:
bash
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
  1. Porque fuera de public/ están el .env, el código fuente y vendor/. Si la raíz fuera el proyecto, cualquiera podría descargar https://app.cliente.com/.env con las contraseñas.

Resumen ​

  • LAMP = Apache con mod_php; LEMP = Nginx con PHP-FPM. Ambos con MySQL.
  • Asegura MySQL y crea un usuario por app con utf8mb4.
  • En producción: display_errors = Off, OPcache activo, secretos en .env con permisos 640.
  • Laravel: composer install --no-dev, migrate --force, permisos en storage, raíz en public/.
  • Evita phpMyAdmin público: usa túnel SSH.
  • Siguiente: Desplegar Node con PM2 y Nginx.

Academia Web — aprende, copia y construye.