Tema
Despliegue de Laravel
Tu aplicación funciona en tu computadora. Ahora hay que ponerla en internet para que tu cliente la use. En esta lección verás las dos rutas más comunes: hosting compartido (cPanel, barato, lo que muchos clientes ya tienen) y VPS (Ubuntu + Nginx + PHP-FPM + MySQL, control total). También la lista de verificación de producción que nunca debes saltarte.
Concepto
Desplegar es mudarse de casa: no basta con llevar las cajas (los archivos); hay que conectar la luz (base de datos), cambiar la dirección (dominio, APP_URL), poner cerraduras (HTTPS, APP_DEBUG=false) y contratar a alguien que haga las tareas periódicas (cron, colas).
La regla de oro de Laravel: el dominio debe apuntar a la carpeta public/, nunca a la raíz del proyecto. Si apuntas a la raíz, archivos como .env (con tus contraseñas) podrían descargarse desde el navegador.
| Hosting compartido | VPS | |
|---|---|---|
| Precio | Bajo | Medio |
| Control | Limitado (cPanel) | Total (root) |
| Colas/workers | Difícil (sólo cron) | Supervisor |
| Ideal para | Sitios corporativos, sistemas pequeños | Apps con tráfico, APIs, colas |
Antes de subir: preparar el proyecto
bash
# 1. Tests en verde
php artisan test
# 2. Compilar assets para producción (genera public/build)
npm run build
# 3. Dependencias sin paquetes de desarrollo (si vas a subir vendor/)
composer install --no-dev --optimize-autoloader.env de producción (nunca subas tu .env local; crea uno nuevo en el servidor):
env
APP_NAME="Sistema Cliente"
APP_ENV=production
APP_KEY=base64:... # genera con php artisan key:generate
APP_DEBUG=false # ¡CRÍTICO! true muestra código y contraseñas en los errores
APP_URL=https://sistema.cliente.com
APP_TIMEZONE=America/Mexico_City
APP_LOCALE=es
LOG_CHANNEL=daily
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=cliente_sistema
DB_USERNAME=cliente_user
DB_PASSWORD=una-contraseña-larga-y-aleatoria
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true # cookie de sesión sólo por HTTPS
QUEUE_CONNECTION=database
CACHE_STORE=database
MAIL_MAILER=smtp
MAIL_HOST=mail.cliente.com
MAIL_PORT=587
MAIL_USERNAME=no-reply@cliente.com
MAIL_PASSWORD=...
MAIL_FROM_ADDRESS=no-reply@cliente.com
MAIL_FROM_NAME="${APP_NAME}"Nunca en producción
APP_DEBUG=true. Ante cualquier error, Laravel mostraría una página con tu código, variables de entorno y credenciales de la base de datos a cualquier visitante.
Opción A: hosting compartido (cPanel)
Requisitos: PHP 8.2+ (8.3 recomendado) con extensiones pdo_mysql, mbstring, openssl, tokenizer, xml, ctype, fileinfo, bcmath. Revísalo en cPanel → Seleccionar versión de PHP.
Paso 1: base de datos
En cPanel → Bases de datos MySQL:
- Crea la base (
usuario_sistema). - Crea un usuario con contraseña fuerte.
- Agrégalo a la base con todos los privilegios.
(En cPanel el nombre real lleva prefijo: cpaneluser_sistema. Usa ese nombre en .env).
Paso 2: estructura de carpetas recomendada
Sube el proyecto fuera de public_html y deja en public_html sólo el contenido de public/:
txt
/home/cpaneluser/
├── sistema/ ← todo el proyecto Laravel (app, vendor, .env, storage...)
│ ├── app/
│ ├── bootstrap/
│ ├── public/ ← (lo copiaremos o enlazaremos)
│ ├── storage/
│ ├── vendor/
│ └── .env
└── public_html/ ← raíz del dominio: index.php, build/, .htaccess, favicon...Método 1 (el mejor): cambiar la raíz del documento. Si puedes, en cPanel → Dominios edita la raíz del dominio o subdominio para que apunte a /home/cpaneluser/sistema/public. Listo: no hay que mover nada.
Método 2: copiar public/ a public_html. Si la raíz no se puede cambiar (dominio principal en algunos hostings), copia el contenido de sistema/public/ a public_html/ y edita public_html/index.php para que encuentre el proyecto:
php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Ajustamos las rutas: el proyecto está en ../sistema
if (file_exists($maintenance = __DIR__.'/../sistema/storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../sistema/vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/../sistema/bootstrap/app.php';
$app->handleRequest(Request::capture());Y para que Laravel sepa que la carpeta pública es public_html (lo necesitan storage:link, Vite y public_path()), añade en sistema/bootstrap/app.php antes de ->create()… o más sencillo, en AppServiceProvider::register():
php
public function register(): void
{
// Sólo si la carpeta pública real es public_html
$this->app->usePublicPath(base_path('../public_html'));
}Error común
Subir el proyecto entero dentro de public_html y acceder como midominio.com/public. Funciona, pero deja expuestos .env, storage/logs y composer.json. Nunca lo hagas.
Paso 3: subir los archivos
- Con SSH (muchos hostings lo activan en cPanel → Terminal o Acceso SSH): clona con Git y ejecuta Composer en el servidor.
bash
cd ~
git clone https://github.com/tuusuario/sistema.git
cd sistema
composer install --no-dev --optimize-autoloader
cp .env.example .env
nano .env # pega la configuración de producción
php artisan key:generate
php artisan migrate --force # --force: obligatorio en producción
php artisan storage:link
php artisan optimize # cachea config, rutas, vistas y eventosSi php apunta a una versión vieja, usa la ruta completa, p. ej. /opt/cpanel/ea-php83/root/usr/bin/php artisan migrate --force.
- Sin SSH: prepara todo en tu computadora y sube un ZIP.
- En local:
composer install --no-dev --optimize-autoloaderynpm run build. - Comprime el proyecto sin
node_modules,.gitni tu.env. - En cPanel → Administrador de archivos: sube el ZIP a
/home/cpaneluser/y extrae. - Crea el
.envde producción desde el administrador de archivos. - Base de datos: exporta tu BD local (después de migrar) con phpMyAdmin o
mysqldumpe impórtala en phpMyAdmin del hosting.
- En local:
Paso 4: el enlace de storage sin SSH
php artisan storage:link necesita terminal. Alternativas:
Opción 1: ruta temporal (bórrala después de usarla):
php
// routes/web.php — TEMPORAL
Route::get('/crear-enlace-storage-x7k2', function () {
\Illuminate\Support\Facades\Artisan::call('storage:link');
return 'Enlace creado';
});Opción 2: script PHP suelto en public_html/link.php (bórralo al terminar):
php
<?php
// Crea public_html/storage -> sistema/storage/app/public
$target = __DIR__ . '/../sistema/storage/app/public';
$link = __DIR__ . '/storage';
if (symlink($target, $link)) {
echo 'Enlace creado.';
} else {
echo 'No se pudo crear (¿symlink deshabilitado?).';
}Opción 3: cron de una vez. En cPanel → Trabajos de Cron, agrega un trabajo con el comando de abajo, espera un minuto y elimínalo:
bash
cd /home/cpaneluser/sistema && php artisan storage:linkEl mismo truco del cron sirve para php artisan migrate --force u optimize si no tienes SSH.
Buena práctica
Si el hosting desactiva symlink, cambia la raíz del disco public en config/filesystems.php para guardar directo en public_html/storage: 'root' => base_path('../public_html/storage') y 'url' => env('APP_URL').'/storage'.
Paso 5: permisos, cron y colas
- Permisos:
storage/ybootstrap/cache/deben poder escribirse (755 para carpetas, 644 para archivos suele bastar en cPanel). - Scheduler: en cPanel → Trabajos de Cron, cada minuto:
bash
/usr/local/bin/php /home/cpaneluser/sistema/artisan schedule:run >> /dev/null 2>&1- Colas sin Supervisor: procesa la cola desde el scheduler en
routes/console.php:
php
use Illuminate\Support\Facades\Schedule;
// Procesa los trabajos pendientes y termina (no se queda colgado)
Schedule::command('queue:work --stop-when-empty --max-time=50')
->everyMinute()
->withoutOverlapping();Paso 6: HTTPS
En cPanel → SSL/TLS Status activa AutoSSL (Let's Encrypt gratis). Fuerza HTTPS en public_html/.htaccess, justo después de RewriteEngine On:
apache
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]Opción B: VPS con Ubuntu, Nginx y PHP-FPM
Ejemplo con Ubuntu 24.04 en DigitalOcean, Linode, Hetzner, Vultr, etc. Conéctate con ssh root@IP.
Paso 1: usuario y firewall
bash
# Usuario no-root con sudo
adduser deploy
usermod -aG sudo deploy
# Firewall: sólo SSH, HTTP y HTTPS
ufw allow OpenSSH
ufw allow 'Nginx Full' # (después de instalar nginx)
ufw enablePaso 2: instalar Nginx, PHP 8.3, MySQL y Composer
bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mysql-server unzip git supervisor
# PHP 8.3 y extensiones que Laravel necesita
sudo apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
php8.3-xml php8.3-curl php8.3-zip php8.3-bcmath php8.3-intl php8.3-gd
# Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
# Node (para compilar assets en el servidor, opcional)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejsPaso 3: base de datos
bash
sudo mysql_secure_installation
sudo mysqlsql
CREATE DATABASE sistema CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'sistema_user'@'localhost' IDENTIFIED BY 'ContraseñaLargaYAleatoria!';
GRANT ALL PRIVILEGES ON sistema.* TO 'sistema_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;Paso 4: subir el proyecto
bash
sudo mkdir -p /var/www/sistema
sudo chown deploy:www-data /var/www/sistema
cd /var/www/sistema
git clone https://github.com/tuusuario/sistema.git .
composer install --no-dev --optimize-autoloader
npm ci && npm run build
cp .env.example .env
nano .env
php artisan key:generate
php artisan migrate --force
php artisan storage:link
php artisan optimize
# Permisos: el usuario de PHP-FPM (www-data) debe escribir en storage y cache
sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cachePaso 5: Nginx
/etc/nginx/sites-available/sistema:
nginx
server {
listen 80;
server_name sistema.cliente.com;
root /var/www/sistema/public; # ← SIEMPRE la carpeta public
index index.php;
charset utf-8;
client_max_body_size 20M; # subida de archivos
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
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 ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
# Bloquea archivos ocultos (.env, .git...)
location ~ /\.(?!well-known).* {
deny all;
}
}bash
sudo ln -s /etc/nginx/sites-available/sistema /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t # verifica la sintaxis
sudo systemctl reload nginxApunta el dominio: en tu proveedor de DNS crea un registro A sistema → IP del VPS.
Paso 6: HTTPS con Certbot
bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d sistema.cliente.com
# Renovación automática ya queda configurada; pruébala:
sudo certbot renew --dry-runPaso 7: Supervisor para las colas
/etc/supervisor/conf.d/sistema-worker.conf:
ini
[program:sistema-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/sistema/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deploy
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/sistema/storage/logs/worker.log
stopwaitsecs=3600bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl statusPaso 8: cron del scheduler
bash
crontab -e # como usuario deploybash
* * * * * cd /var/www/sistema && php artisan schedule:run >> /dev/null 2>&1Script de despliegue
Para actualizar la app después de cada cambio, crea deploy.sh en el servidor:
bash
#!/bin/bash
set -e # detente si un comando falla
cd /var/www/sistema
php artisan down --retry=60 # modo mantenimiento
git pull origin main
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan optimize:clear
php artisan optimize
php artisan queue:restart # los workers cargan el código nuevo
php artisan up
echo "Despliegue completado"bash
chmod +x deploy.sh
./deploy.shAlternativas administradas
Laravel Forge, Ploi o Laravel Cloud automatizan todo lo anterior (Nginx, SSL, Supervisor, despliegues con un clic) por una cuota mensual. Vale la pena cuando manejas varios clientes; aun así, entender el proceso manual te salva cuando algo falla.
Lista de verificación de producción
- [ ] Dominio apuntando a
public/. - [ ]
APP_ENV=production,APP_DEBUG=false,APP_URLconhttps://. - [ ]
APP_KEYgenerada (y respaldada: si la pierdes, no podrás descifrar datos cifrados ni mantener sesiones). - [ ] HTTPS activo y redirección de HTTP a HTTPS.
- [ ]
php artisan optimizeejecutado. - [ ]
storage:linkcreado;storage/ybootstrap/cache/con permisos de escritura. - [ ] Cron del scheduler y worker de colas funcionando.
- [ ] Correo de producción probado (recuperar contraseña).
- [ ] Respaldos automáticos de base de datos y de
storage/app(por ejemplo, con el paquetespatie/laravel-backup). - [ ] Rutas temporales y scripts como
link.phpeliminados.
Revisa también el checklist de seguridad.
Error común
Cambiar el .env en producción y no ver efecto. Con php artisan optimize la configuración está cacheada: ejecuta php artisan config:clear (o optimize de nuevo) después de editar .env.
Error común
Error 500 en blanco tras subir. Revisa storage/logs/laravel.log. Las causas típicas: falta APP_KEY, permisos de storage/, versión de PHP vieja o falta una extensión.
Ejercicios
- Escribe el
.envde producción para un cliente cuyo sitio seráhttps://reservas.hotelsol.mx, con BDhotel_reservas, zona horaria de Cancún y correo por SMTP en el puerto 465. - En un hosting sin SSH, ¿cómo ejecutarías las migraciones nuevas después de subir una actualización? Da dos opciones.
Ver solución 1
env
APP_NAME="Reservas Hotel Sol"
APP_ENV=production
APP_KEY=base64:GENERADA_CON_KEY_GENERATE
APP_DEBUG=false
APP_URL=https://reservas.hotelsol.mx
APP_TIMEZONE=America/Cancun
APP_LOCALE=es
LOG_CHANNEL=daily
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=cpuser_hotel_reservas
DB_USERNAME=cpuser_reservas
DB_PASSWORD=Q8#mZ2!vLr9pX4t
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true
QUEUE_CONNECTION=database
MAIL_MAILER=smtp
MAIL_HOST=mail.hotelsol.mx
MAIL_PORT=465
MAIL_SCHEME=smtps
MAIL_USERNAME=reservas@hotelsol.mx
MAIL_PASSWORD=contraseña-del-buzon
MAIL_FROM_ADDRESS=reservas@hotelsol.mx
MAIL_FROM_NAME="${APP_NAME}"Ver solución 2
- Cron de una vez: en cPanel → Trabajos de Cron, agregar
cd /home/cpuser/sistema && php artisan migrate --forcecada minuto, esperar a que corra (revisa el correo de salida del cron o la BD) y eliminarlo. - Ruta temporal protegida: una ruta con un nombre difícil de adivinar, protegida con
authycan:view-admin, que ejecuteArtisan::call('migrate', ['--force' => true])y devuelvaArtisan::output(). Eliminarla en el siguiente despliegue.
(Alternativa manual: aplicar el SQL de los cambios desde phpMyAdmin, pero es fácil equivocarse y la tabla migrations queda desincronizada).
Resumen
- El dominio siempre apunta a
public/; nunca expongas la raíz del proyecto. - Producción:
APP_DEBUG=false, HTTPS,optimize,migrate --force,storage:link. - Hosting compartido: proyecto fuera de
public_html, ajustaindex.php, usa cron para scheduler y colas; sin SSH, usa ZIP + cron temporal. - VPS: Nginx + PHP-FPM + MySQL, Certbot para SSL, Supervisor para workers y un cron para el scheduler.
- Automatiza actualizaciones con un script
deploy.sh(down, pull, composer, migrate, optimize, queue:restart, up). - Revisa
storage/logs/laravel.logante cualquier error 500.