← Blog

Despliega Laravel en Hetzner

Railyard

25 de junio de 2026 · 12 min de lectura

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 assets construye tus assets de Vite en public/build, de modo que la imagen de producción incluye CSS y JavaScript ya compilados. Incluye un package-lock.json en el commit para que la instalación sea repetible.
  • install-php-extensions es 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-production en 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_healthy hace que los contenedores de PHP esperen hasta que MySQL acepte conexiones, no solo hasta que su contenedor exista. El health check se conecta por 127.0.0.1, lo cual solo tiene éxito una vez que MySQL terminó su inicialización de primer arranque.
  • php artisan schedule:work ejecuta 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.
  • storage lo comparten los tres contenedores de PHP, así que los logs y los archivos subidos al disco local son los mismos en todas partes. caddy-data guarda los certificados, de modo que sobreviven a cada redespliegue.
  • MySQL no publica ningún puerto. Solo el contenedor app puede alcanzarlo, por el nombre de host db.

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.

  1. En Hetzner Cloud Console, abre tu proyecto, luego Security, luego API tokens, y genera un token con permiso Read & Write.
  2. 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, sudo sin contraseña, puertos 22, 80 y 443 abiertos.
  3. Despliega el repositorio, o abre https://app.railyard.run/deploy?repo=https://github.com/you/your-app.git con 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.

Sáltate la configuración del servidor.

Conecta tu cuenta de Hetzner, pega la URL de tu repositorio y Railyard construye, migra y despliega tu app de Laravel con su base de datos y workers.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.