Despliega Laravel en Hetzner
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
php artisan serve está bien en tu laptop. En producción, una app de Laravel necesita un servidor web real, una base de datos, un worker de colas que se recupere después de una caída, el scheduler corriendo cada minuto, y HTTPS. Esta guía construye todo eso en un solo servidor de Hetzner Cloud con Docker Compose, usando FrankenPHP, el servidor de aplicaciones que la propia documentación de despliegue de Laravel menciona junto a Nginx. Al final tendrás la app en vivo en tu dominio, una rutina de despliegue de tres comandos, y una copia de seguridad de la base de datos que ya has restaurado de verdad.
Lo que necesitas
- Un servidor de Hetzner Cloud. El plan optimizado en costo CX23 (2 vCPU, 4 GB de memoria, 40 GB NVMe, 20 TB de tráfico) cuesta 5,99 € al mes sin IVA, con una dirección IPv4 incluida. Corre una app de Laravel con su base de datos MySQL. El CX33 (4 vCPU, 8 GB, 80 GB) cuesta 8,99 € al mes si la cola hace trabajo pesado. Los precios son de la página de planes optimizados en costo de Hetzner.
- Un dominio cuyo DNS puedas editar.
- Una cuenta de Hetzner Cloud con tu clave SSH cargada.
- Una app de Laravel en un repositorio Git. Esta guía usa Laravel 13, que requiere PHP 8.3 o más reciente, y la ejecuta en PHP 8.4 con la imagen oficial
dunglas/frankenphp:1-php8.4.
Paso 1: Crea el servidor y apunta el DNS hacia él
En Hetzner Cloud Console, abre tu proyecto y elige Add Server. Selecciona una ubicación (Nuremberg, Falkenstein y Helsinki mantienen los datos en la UE), Ubuntu 24.04, el tipo CX23 dentro de optimizado en costo, y tu clave SSH. Créalo y copia su dirección IPv4.
En tu proveedor de DNS, crea un registro A para tu dominio (por ejemplo app.example.com) que apunte a esa dirección. Hazlo ahora, porque FrankenPHP solicita el certificado HTTPS en el momento en que arranca en el Paso 6, y solo puede tener éxito una vez que el nombre resuelva a este servidor.
Conéctate:
ssh root@your-server-ip
Deberías obtener un prompt root@ sin contraseña, ya que tu clave se agregó cuando se creó el servidor.
Paso 2: Instala Docker
curl -fsSL https://get.docker.com | sh
docker compose version
El script de instalación oficial de Docker instala el motor y el plugin de Compose. Deberías ver impresa una versión de Compose.
Paso 3: Escribe el Dockerfile
FrankenPHP es un servidor de aplicaciones PHP construido sobre Caddy. Un solo contenedor sirve PHP y tus archivos estáticos y obtiene su propio certificado de Let’s Encrypt, así que no necesitas un par separado de Nginx y PHP-FPM. La imagen está basada en la imagen oficial de PHP, y su sitio por defecto sirve el directorio public/ de /app, que es exactamente donde vive el front controller de Laravel.
Agrega este Dockerfile en la raíz del repositorio:
FROM node:24-slim AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build
FROM dunglas/frankenphp:1-php8.4
RUN install-php-extensions pdo_mysql intl zip opcache pcntl bcmath \
&& cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev
Qué hace cada parte:
- La etapa
assetsconstruye tus assets de Vite enpublic/build, de modo que la imagen de producción incluye CSS y JavaScript ya compilados. Incluye unpackage-lock.jsonen el commit para que la instalación sea repetible. install-php-extensionses un helper incluido en la imagen de FrankenPHP. Los requisitos documentados de Laravel (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer y XML) ya están en la imagen base: esto agrega el driver de MySQL y las extensiones que usan la mayoría de las apps y workers de colas.- Copiar
php.ini-productionen su lugar cambia PHP a sus valores por defecto de producción. - Composer instala las dependencias antes de copiar el resto del código, así Docker reutiliza esa capa cuando solo cambia tu código.
Agrega un .dockerignore junto a él, para que las dependencias locales, el resultado del build y tu .env queden fuera de la imagen:
.env
.git
node_modules
vendor
public/build
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
Paso 4: Escribe el archivo Compose
La app necesita cuatro procesos de larga duración: el servidor web, un worker de colas, el scheduler y MySQL. Los tres primeros corren la misma imagen con un comando distinto. Agrega compose.yaml:
x-laravel: &laravel
build: .
image: laravel-app
restart: unless-stopped
env_file: .env
volumes:
- storage:/app/storage
depends_on:
db:
condition: service_healthy
services:
app:
<<: *laravel
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- storage:/app/storage
- caddy-data:/data
- caddy-config:/config
queue:
<<: *laravel
command: php artisan queue:work --tries=3
healthcheck:
disable: true
scheduler:
<<: *laravel
command: php artisan schedule:work
healthcheck:
disable: true
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 5s
timeout: 5s
retries: 30
volumes:
storage:
db-data:
caddy-data:
caddy-config:
Unos cuantos detalles hacen trabajo real aquí:
condition: service_healthyhace que los contenedores de PHP esperen hasta que MySQL acepte conexiones, no solo hasta que su contenedor exista. El health check se conecta por127.0.0.1, lo cual solo tiene éxito una vez que MySQL terminó su inicialización de primer arranque.php artisan schedule:workejecuta el scheduler de Laravel en primer plano y lo invoca cada minuto, así que el contenedor no necesita un demonio cron.- La imagen de FrankenPHP incluye un health check que consulta su servidor web integrado. Los contenedores de queue y scheduler no corren ese servidor, así que sus health checks se desactivan en lugar de reportarse como fallidos.
storagelo comparten los tres contenedores de PHP, así que los logs y los archivos subidos al disco local son los mismos en todas partes.caddy-dataguarda los certificados, de modo que sobreviven a cada redespliegue.- MySQL no publica ningún puerto. Solo el contenedor
apppuede alcanzarlo, por el nombre de hostdb.
Paso 5: Clona el código y crea .env
En el servidor:
git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app
Para un repositorio privado, agrégale primero una deploy key de solo lectura, o clónalo por HTTPS con un token. El .gitignore por defecto de Laravel ya excluye .env, así que el archivo que crees ahora permanece en el servidor entre despliegues:
APP_NAME=YourApp
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://app.example.com
SERVER_NAME=app.example.com
LOG_CHANNEL=stderr
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=replace-with-a-long-random-password
SERVER_NAME le indica a FrankenPHP qué dominio servir y para cuál solicitar un certificado. LOG_CHANNEL=stderr envía los logs de Laravel a docker compose logs. Genera la contraseña de la base de datos con openssl rand -hex 24, y luego protege el archivo con chmod 600 .env. Compose lee ese mismo archivo para llenar ${DB_DATABASE} y los demás valores de MySQL.
Laravel necesita una clave de cifrado. Construye la imagen y deja que Laravel genere una:
docker compose build
docker compose run --rm --no-deps app php artisan key:generate --show
Deberías ver un valor que empieza con base64:. Pégalo después de APP_KEY= en .env. Guarda una copia en algún lugar seguro: si la pierdes, los datos cifrados y las sesiones existentes no se podrán leer.
Paso 6: Inicia todo y ejecuta las migraciones
docker compose up -d
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
--force es necesario porque Laravel pide confirmación antes de migrar en producción. optimize cachea la configuración, los eventos, las rutas y las vistas, algo que la documentación de despliegue de Laravel recomienda en cada despliegue.
Revisa los contenedores:
docker compose ps
Deberías ver app, queue, scheduler y db con estado Up, y db marcado como healthy. Abre https://app.example.com: la app carga por HTTPS con un certificado válido. La ruta de salud integrada de Laravel también debería responder:
curl -I https://app.example.com/up
Deberías ver HTTP/2 200.
Los contenedores de queue y scheduler pueden reiniciarse varias veces antes de que terminen las migraciones, porque las tablas de base de datos que usan todavía no existen. Eso es esperado, y se estabilizan una vez que migrate se ha ejecutado.
Paso 7: Despliega nuevas versiones
Cada versión:
cd /opt/your-app
git pull
docker compose up -d --build
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
Compose reconstruye la imagen y recrea los tres contenedores de PHP, así que el worker de colas y el scheduler toman el código nuevo. Eso es lo que la documentación de Laravel quiere decir cuando afirma que los procesos de larga duración deben reiniciarse después de un despliegue. MySQL sigue corriendo, y los certificados permanecen en su volumen. El sitio queda no disponible durante los pocos segundos que tarda en reiniciar el contenedor app.
Para revertir, haz git checkout del commit anterior y ejecuta los mismos comandos. Si la versión incluía una migración, revierte primero con docker compose exec app php artisan migrate:rollback --force, mientras el código nuevo todavía está corriendo.
Paso 8: Respalda la base de datos
MySQL contiene los únicos datos que no puedes reconstruir a partir de Git. Guarda esto como /opt/your-app/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/your-app
mkdir -p backups
docker compose exec -T db sh -c 'exec mysqldump --no-tablespaces --single-transaction -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' | gzip > "backups/db-$(date +%F).sql.gz"
find backups -name 'db-*.sql.gz' -mtime +14 -delete
--single-transaction toma una instantánea consistente sin bloquear las tablas InnoDB, y --no-tablespaces permite que el propio usuario de base de datos de la app ejecute el dump sin privilegios extra. Hazlo ejecutable y prográmalo con crontab -e:
chmod +x /opt/your-app/backup.sh
30 3 * * * /opt/your-app/backup.sh >> /var/log/your-app-backup.log 2>&1
Una copia de seguridad en el mismo disco no sobrevive a la pérdida del servidor, así que copia también la carpeta backups/ a otro lugar: Hetzner Object Storage, otra máquina, o cualquier bucket compatible con S3. Luego comprueba que el dump funciona restaurándolo en la base de datos:
gunzip -c backups/db-YYYY-MM-DD.sql.gz | docker compose exec -T db sh -c 'exec mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"'
Reemplaza YYYY-MM-DD con la fecha del archivo. Esto reemplaza las tablas con el contenido de la copia de seguridad, así que pruébalo justo después de tomar una copia de seguridad, o contra una copia del stack. No deberías ver errores, y la app debería funcionar como antes.
El atajo: hazlo con Railyard
Todo lo anterior es una receta que Railyard ejecuta por ti, sin el Dockerfile, el archivo Compose ni el script de backup. Consulta Despliega Laravel en Hetzner para ver la guía del proveedor.
- En Hetzner Cloud Console, abre tu proyecto, luego Security, luego API tokens, y genera un token con permiso Read & Write.
- En Railyard, ve a Servers → Add a server → Create on your cloud, elige Hetzner, una ubicación y un tamaño. El servidor se crea en tu propia cuenta de Hetzner, y Hetzner te factura directamente sin recargo. También puedes conectar un servidor que ya tengas: Ubuntu 22.04 o 24.04,
sudosin contraseña, puertos 22, 80 y 443 abiertos. - Despliega el repositorio, o abre
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitcon la URL de tu propio repositorio.
Railyard ejecuta php artisan migrate --force antes de cada versión. Conecta DB_HOST, DB_DATABASE y las demás variables de base de datos de tu .env.example con la base de datos que aprovisiona, genera APP_KEY una sola vez en el formato base64 de Laravel, y configura APP_URL con la dirección de la app. php artisan queue:work se ofrece como worker, u horizon cuando laravel/horizon está instalado. Las bases de datos reciben copias de seguridad programadas que se verifican restaurándolas, con restauración de un clic: consulta Bases de datos y copias de seguridad.
El plan Free cubre un servidor más un servidor de base de datos y tres apps, sin necesidad de tarjeta. Pro cuesta 19 USD al mes por equipo y Team cuesta 49 USD al mes por equipo. Los docs de Laravel detallan las reglas de detección.
Solución de problemas
bind: address already in use en el puerto 80 o 443. Algo más en el servidor tiene el puerto ocupado, a menudo un Nginx o Apache instalado antes. sudo ss -tlnp | grep -E ':(80|443) ' muestra qué proceso es. Detenlo y desactívalo, por ejemplo sudo systemctl disable --now nginx, y luego ejecuta docker compose up -d.
No hay certificado, y la app registra errores de ACME. El DNS todavía no apunta al servidor, SERVER_NAME no coincide con el dominio, o un firewall bloquea los puertos 80 y 443. dig +short app.example.com debe devolver la IP del servidor. Si agregaste un Hetzner Cloud Firewall, permite entrante TCP 80 y 443 (y UDP 443 para HTTP/3). El servidor reintenta por su cuenta una vez que el DNS es correcto.
SQLSTATE[HY000] [2002] o Access denied de MySQL. Si es un error de conexión, revisa DB_HOST=db. Si el acceso es denegado, la contraseña en .env cambió después del primer arranque de MySQL: MySQL solo lee MYSQL_USER y MYSQL_PASSWORD cuando inicializa un volumen de datos vacío. Vuelve a poner la contraseña original, o cámbiala dentro de MySQL.
Los enlaces y redirecciones usan http:// o el host equivocado. APP_URL está mal, o la caché de configuración guarda un valor antiguo. Corrige .env, y luego ejecuta docker compose up -d y docker compose exec app php artisan optimize para que los contenedores y la caché lo tomen.
Vite manifest not found at: /app/public/build/manifest.json. Los assets compilados no están en la imagen. Comprueba que npm run build haya tenido éxito en la etapa assets (docker compose --progress plain build app muestra su salida) y que tu configuración de Vite todavía escriba en public/build, y luego reconstruye.
Permission denied al escribir en storage. La imagen de FrankenPHP corre como root por defecto, así que esto normalmente significa archivos copiados al volumen a mano, o una imagen cambiada para correr como otro usuario. Haz que el volumen sea escribible para el usuario con el que corre PHP, por ejemplo docker compose exec app chown -R www-data:www-data storage si cambiaste a www-data.
El build se mata mientras compila assets o instala dependencias. Un CX23 tiene 4 GB de memoria, lo cual normalmente alcanza, pero un build grande de frontend puede agotarla junto a un stack en ejecución. Agrega un archivo swap de 2 GB (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile), o sube a un CX33.
Preguntas frecuentes
¿Puedo usar Nginx y PHP-FPM en su lugar? Sí. La documentación de despliegue de Laravel incluye una configuración de Nginx para PHP-FPM. Tendrías que correr una imagen de PHP-FPM, un contenedor de Nginx con esa configuración, y algo que gestione los certificados. FrankenPHP hace las tres cosas en un solo contenedor, por eso esta guía lo usa.
¿Y Laravel Octane? FrankenPHP es uno de los servidores soportados por Octane. Instala laravel/octane, ejecuta php artisan octane:install --server=frankenphp, e inicia el contenedor de la app con php artisan octane:frankenphp. Lee primero la documentación de Octane: mantiene la app en memoria entre peticiones, lo cual cambia cómo tienes que manejar el estado.
¿Debería usar Postgres en lugar de MySQL? Cualquiera de los dos funciona con Laravel. Cambia el servicio db por la imagen oficial de Postgres, configura DB_CONNECTION=pgsql y DB_PORT=5432, e instala la extensión pdo_pgsql en lugar de pdo_mysql.
¿Dónde van los archivos subidos? Con los discos local y public por defecto, van a storage/app, que vive en el volumen storage. Respalda también ese volumen, o mueve las subidas a almacenamiento de objetos compatible con S3, lo cual también te permite agregar un segundo servidor más adelante.
¿Necesito Redis? No para empezar. Esta configuración usa los valores por defecto de Laravel 13, que mantienen la caché, las sesiones y la cola en la base de datos. Agrega un servicio de Redis cuando pases a Horizon o necesites una caché más rápida.
Eso es todo el stack: una imagen para el servidor web, el worker y el scheduler, MySQL junto a ellos, y los certificados gestionados por ti. Después de la primera configuración, cada versión es un git pull, un comando de Compose y una migración.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.