Skip to content

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 compartidoVPS
PrecioBajoMedio
ControlLimitado (cPanel)Total (root)
Colas/workersDifícil (sólo cron)Supervisor
Ideal paraSitios corporativos, sistemas pequeñosApps 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:

  1. Crea la base (usuario_sistema).
  2. Crea un usuario con contraseña fuerte.
  3. 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 eventos

Si 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.
    1. En local: composer install --no-dev --optimize-autoloader y npm run build.
    2. Comprime el proyecto sin node_modules, .git ni tu .env.
    3. En cPanel → Administrador de archivos: sube el ZIP a /home/cpaneluser/ y extrae.
    4. Crea el .env de producción desde el administrador de archivos.
    5. Base de datos: exporta tu BD local (después de migrar) con phpMyAdmin o mysqldump e impórtala en phpMyAdmin del hosting.

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:link

El 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/ y bootstrap/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 enable

Paso 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 nodejs

Paso 3: base de datos ​

bash
sudo mysql_secure_installation
sudo mysql
sql
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/cache

Paso 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 nginx

Apunta 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-run

Paso 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=3600
bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status

Paso 8: cron del scheduler ​

bash
crontab -e   # como usuario deploy
bash
* * * * * cd /var/www/sistema && php artisan schedule:run >> /dev/null 2>&1

Script 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.sh

Alternativas 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_URL con https://.
  • [ ] APP_KEY generada (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 optimize ejecutado.
  • [ ] storage:link creado; storage/ y bootstrap/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 paquete spatie/laravel-backup).
  • [ ] Rutas temporales y scripts como link.php eliminados.

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 ​

  1. Escribe el .env de producción para un cliente cuyo sitio será https://reservas.hotelsol.mx, con BD hotel_reservas, zona horaria de Cancún y correo por SMTP en el puerto 465.
  2. 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
  1. Cron de una vez: en cPanel → Trabajos de Cron, agregar cd /home/cpuser/sistema && php artisan migrate --force cada minuto, esperar a que corra (revisa el correo de salida del cron o la BD) y eliminarlo.
  2. Ruta temporal protegida: una ruta con un nombre difícil de adivinar, protegida con auth y can:view-admin, que ejecute Artisan::call('migrate', ['--force' => true]) y devuelva Artisan::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, ajusta index.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.log ante cualquier error 500.

Academia Web — aprende, copia y construye.