← Blog

Fazendo deploy de Laravel na Hetzner

Railyard

25 de junho de 2026 · 12 min de leitura

Siga isso no seu próprio servidor. Comece grátis, sem cartão

php artisan serve é ótimo no seu laptop. Em produção, uma aplicação Laravel precisa de um servidor web de verdade, um banco de dados, um queue worker que volta depois de travar, o scheduler rodando a cada minuto, e HTTPS. Este guia constrói tudo isso num único servidor Hetzner Cloud com Docker Compose, usando o FrankenPHP, o application server que os próprios docs de deploy do Laravel listam ao lado do Nginx. No final você vai ter a aplicação no ar no seu domínio, uma rotina de deploy de três comandos, e um backup de banco de dados que você de fato restaurou.

O que você vai precisar

  • Um servidor Hetzner Cloud. O CX23 otimizado por custo (2 vCPU, 4 GB de memória, 40 GB NVMe, 20 TB de tráfego) custa €5,99 por mês sem IVA, com um endereço IPv4 incluído. Ele roda uma aplicação Laravel com o banco MySQL dela. O CX33 (4 vCPU, 8 GB, 80 GB) custa €8,99 por mês se a fila faz trabalho pesado. Os preços são da página de planos otimizados por custo da Hetzner.
  • Um domínio cujo DNS você consiga editar.
  • Uma conta Hetzner Cloud com sua chave SSH carregada.
  • Uma aplicação Laravel num repositório Git. Este guia usa o Laravel 13, que exige PHP 8.3 ou mais novo, e roda ele no PHP 8.4 com a imagem oficial dunglas/frankenphp:1-php8.4.

Passo 1: criar o servidor e apontar o DNS pra ele

No Hetzner Cloud Console, abra seu projeto e escolha Add Server. Escolha uma localização (Nuremberg, Falkenstein e Helsinki mantêm os dados na UE), Ubuntu 24.04, o tipo CX23 em otimizado por custo, e sua chave SSH. Crie ele e copie o endereço IPv4.

No seu provedor de DNS, crie um registro A pro seu domínio (por exemplo app.example.com) apontando pra esse endereço. Faça isso agora, porque o FrankenPHP solicita o certificado HTTPS no instante em que inicia no Passo 6, e isso só pode funcionar depois que o nome resolver pra esse servidor.

Conecte:

ssh root@your-server-ip

Você deve receber um prompt root@ sem senha, já que sua chave foi adicionada quando o servidor foi criado.

Passo 2: instalar o Docker

curl -fsSL https://get.docker.com | sh
docker compose version

O script de conveniência oficial do Docker instala o engine e o plugin do Compose. Você deve ver uma versão do Compose impressa.

Passo 3: escrever o Dockerfile

O FrankenPHP é um application server PHP construído sobre o Caddy. Um container serve o PHP e seus arquivos estáticos e consegue seu próprio certificado Let’s Encrypt, então você não precisa de um par separado de Nginx e PHP-FPM. A imagem é baseada na imagem oficial do PHP, e seu site padrão serve o diretório public/ do /app, que é exatamente onde o front controller do Laravel vive.

Adicione este Dockerfile na raiz do repositório:

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

O que cada parte faz:

  • O estágio assets constrói seus assets do Vite em public/build, então a imagem de produção já traz o CSS e o JavaScript compilados. Faça commit de um package-lock.json pra que a instalação seja repetível.
  • install-php-extensions é um helper incluído na imagem do FrankenPHP. Os requisitos documentados do Laravel (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer e XML) já estão na imagem base; isso adiciona o driver do MySQL e as extensões que a maioria das aplicações e queue workers usa.
  • Copiar o php.ini-production pro lugar muda o PHP pros seus padrões de produção.
  • O Composer instala as dependências antes do resto do código ser copiado, então o Docker reaproveita essa camada quando só o seu código muda.

Adicione um .dockerignore ao lado dele, pra que dependências locais, saída de build e seu .env fiquem fora da imagem:

.env
.git
node_modules
vendor
public/build
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*

Passo 4: escrever o arquivo do Compose

A aplicação precisa de quatro processos de longa duração: o servidor web, um queue worker, o scheduler e o MySQL. Os três primeiros rodam a mesma imagem com um comando diferente. Adicione 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:

Alguns detalhes estão fazendo trabalho real aqui:

  • condition: service_healthy faz os containers PHP esperarem até o MySQL aceitar conexões, não só até o container dele existir. O health check conecta via 127.0.0.1, que só funciona depois que o MySQL termina sua inicialização de primeira execução.
  • php artisan schedule:work roda o scheduler do Laravel em primeiro plano e o invoca a cada minuto, então o container não precisa de nenhum daemon de cron.
  • A imagem do FrankenPHP traz um health check que consulta seu servidor web embutido. Os containers de queue e scheduler não rodam esse servidor, então os health checks deles são desligados em vez de reportados como falhando.
  • storage é compartilhado pelos três containers PHP, então logs e arquivos enviados no disco local são os mesmos em todo lugar. caddy-data guarda os certificados, então eles sobrevivem a todo redeploy.
  • O MySQL não publica nenhuma porta. Só o container da aplicação consegue alcançá-lo, pelo hostname db.

Passo 5: clonar o código e criar o .env

No servidor:

git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app

Pra um repositório privado, adicione uma deploy key só-leitura nele primeiro, ou clone via HTTPS com um token. O .gitignore padrão do Laravel já exclui o .env, então o arquivo que você cria agora continua no servidor entre deploys:

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 diz ao FrankenPHP qual domínio servir e pra qual solicitar um certificado. LOG_CHANNEL=stderr manda os logs do Laravel pro docker compose logs. Gere a senha do banco de dados com openssl rand -hex 24, depois proteja o arquivo com chmod 600 .env. O Compose lê o mesmo arquivo pra preencher ${DB_DATABASE} e os outros valores pro MySQL.

O Laravel precisa de uma chave de criptografia. Construa a imagem e deixe o Laravel gerar uma:

docker compose build
docker compose run --rm --no-deps app php artisan key:generate --show

Você deve ver um valor começando com base64:. Cole ele depois de APP_KEY= no .env. Guarde uma cópia em algum lugar seguro: se você perdê-la, dados criptografados e sessões existentes não podem ser lidos.

Passo 6: iniciar tudo e rodar as migrations

docker compose up -d
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize

--force é exigido porque o Laravel pede confirmação antes de migrar em produção. optimize cacheia configuração, eventos, rotas e views, o que os próprios docs de deploy do Laravel recomendam em cada deploy.

Cheque os containers:

docker compose ps

Você deve ver app, queue, scheduler e db com status Up, e db marcado como healthy. Abra https://app.example.com: a aplicação carrega sobre HTTPS com um certificado válido. A rota de health check embutida do Laravel também deve responder:

curl -I https://app.example.com/up

Você deve ver HTTP/2 200.

Os containers de queue e scheduler podem reiniciar algumas vezes antes das migrations terminarem, porque as tabelas do banco de dados que eles usam ainda não existem. Isso é esperado, e eles se acomodam depois que o migrate rodar.

Passo 7: fazer deploy de novas versões

Cada release:

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

O Compose reconstrói a imagem e recria os três containers PHP, então o queue worker e o scheduler pegam o código novo. É isso que os docs do Laravel querem dizer quando falam que processos de longa duração precisam ser reiniciados depois de um deploy. O MySQL continua rodando, e os certificados ficam no volume dele. O site fica indisponível pelos poucos segundos que o container da aplicação leva pra reiniciar.

Pra fazer rollback, dê git checkout no commit anterior e rode os mesmos comandos. Se a release incluiu uma migration, desfaça ela primeiro com docker compose exec app php artisan migrate:rollback --force, enquanto o código novo ainda está rodando.

Passo 8: fazer backup do banco de dados

O MySQL guarda o único dado que você não consegue reconstruir a partir do Git. Salve isto 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 tira um snapshot consistente sem bloquear tabelas InnoDB, e --no-tablespaces deixa o próprio usuário de banco de dados da aplicação rodar o dump sem privilégios extras. Torne executável e agende com crontab -e:

chmod +x /opt/your-app/backup.sh
30 3 * * * /opt/your-app/backup.sh >> /var/log/your-app-backup.log 2>&1

Um backup no mesmo disco não sobrevive à perda do servidor, então copie a pasta backups/ pra outro lugar também: Hetzner Object Storage, outra máquina, ou qualquer bucket compatível com S3. Depois prove que o dump funciona restaurando ele no banco de dados:

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"'

Troque YYYY-MM-DD pela data do arquivo. Isso substitui as tabelas pelo conteúdo do backup, então tente logo depois de fazer um backup, ou contra uma cópia do stack. Você não deve ver nenhum erro, e a aplicação deve funcionar como antes.

O caminho mais curto: fazer isso com o Railyard

Tudo acima é uma receita que o Railyard roda pra você, sem o Dockerfile, o arquivo do Compose ou o script de backup. Veja Deploy Laravel to Hetzner pro passo a passo do provedor.

  1. No Hetzner Cloud Console, abra seu projeto, depois Security, depois API tokens, e gere um token com permissão Read & Write.
  2. No Railyard, vá em Servers → Add a server → Create on your cloud, escolha a Hetzner, uma localização e um tamanho. O servidor é criado na sua própria conta Hetzner, e a Hetzner cobra você direto sem markup. Você também pode conectar um servidor que já tem: Ubuntu 22.04 ou 24.04, sudo sem senha, portas 22, 80 e 443 abertas.
  3. Faça deploy do repositório, ou abra https://app.railyard.run/deploy?repo=https://github.com/you/your-app.git com a URL do seu próprio repositório.

O Railyard roda php artisan migrate --force antes de cada release. Ele conecta DB_HOST, DB_DATABASE e as outras variáveis de banco de dados do seu .env.example ao banco que ele provisiona, gera o APP_KEY uma vez no formato base64 do Laravel, e define APP_URL pro endereço da aplicação. php artisan queue:work é oferecido como worker, ou horizon quando laravel/horizon está instalado. Bancos de dados recebem backups agendados que são verificados restaurando eles, com restauração em um clique; veja Databases and backups.

O plano Free cobre um servidor mais um servidor de banco de dados e três aplicações, sem cartão necessário. O Pro custa US$19 por mês por equipe e o Team custa US$49 por mês por equipe. Os docs do Laravel listam as regras de detecção.

Resolução de problemas

bind: address already in use na porta 80 ou 443. Alguma outra coisa no servidor está ocupando a porta, geralmente um Nginx ou Apache instalado antes. sudo ss -tlnp | grep -E ':(80|443) ' mostra qual processo. Pare e desative ele, por exemplo sudo systemctl disable --now nginx, depois rode docker compose up -d.

Nenhum certificado, e os logs da aplicação mostram erros de ACME. O DNS ainda não aponta pro servidor, SERVER_NAME não combina com o domínio, ou um firewall está bloqueando as portas 80 e 443. dig +short app.example.com precisa retornar o IP do servidor. Se você adicionou um Hetzner Cloud Firewall, permita entrada de TCP 80 e 443 (e UDP 443 pro HTTP/3). O servidor tenta de novo por conta própria depois que o DNS estiver certo.

SQLSTATE[HY000] [2002] ou Access denied do MySQL. Se for um erro de conexão, cheque DB_HOST=db. Se o acesso for negado, a senha no .env mudou depois do primeiro início do MySQL: o MySQL só lê MYSQL_USER e MYSQL_PASSWORD quando inicializa um volume de dados vazio. Coloque a senha original de volta, ou mude ela dentro do MySQL.

Links e redirecionamentos usam http:// ou o host errado. APP_URL está errado, ou o cache de configuração guarda um valor antigo. Corrija o .env, depois rode docker compose up -d e docker compose exec app php artisan optimize pra que os containers e o cache peguem a mudança.

Vite manifest not found at: /app/public/build/manifest.json. Os assets compilados não estão na imagem. Cheque se o npm run build teve sucesso no estágio assets (docker compose --progress plain build app mostra a saída dele) e se sua configuração do Vite ainda escreve em public/build, depois reconstrua.

Permission denied escrevendo em storage. A imagem do FrankenPHP roda como root por padrão, então isso geralmente significa arquivos copiados pro volume à mão, ou uma imagem alterada pra rodar como outro usuário. Torne o volume gravável pro usuário que o PHP usa, por exemplo docker compose exec app chown -R www-data:www-data storage se você trocou pra www-data.

O build é morto enquanto compila assets ou instala dependências. Um CX23 tem 4 GB de memória, que geralmente é suficiente, mas um build de front-end grande pode esgotar isso junto de um stack em execução. Adicione um arquivo de swap de 2 GB (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile), ou suba pro CX33.

Perguntas frequentes

Eu posso usar Nginx e PHP-FPM no lugar? Sim. Os docs de deploy do Laravel incluem uma configuração de Nginx pro PHP-FPM. Você rodaria uma imagem PHP-FPM, um container Nginx com essa configuração, e algo pra tratar certificados. O FrankenPHP faz os três num único container, que é por isso que este guia usa ele.

E o Laravel Octane? O FrankenPHP é um dos servidores suportados pelo Octane. Instale laravel/octane, rode php artisan octane:install --server=frankenphp, e inicie o container da aplicação com php artisan octane:frankenphp. Leia a documentação do Octane primeiro: ela mantém a aplicação em memória entre requisições, o que muda como você precisa tratar o estado.

Eu deveria usar Postgres em vez de MySQL? Os dois funcionam com o Laravel. Troque o serviço db pela imagem oficial do Postgres, defina DB_CONNECTION=pgsql e DB_PORT=5432, e instale a extensão pdo_pgsql em vez de pdo_mysql.

Pra onde vão os arquivos enviados? Com os discos padrão local e public, pra storage/app, que vive no volume storage. Faça backup desse volume também, ou mova os uploads pra um armazenamento de objetos compatível com S3, o que também te deixa adicionar um segundo servidor depois.

Eu preciso de Redis? Não pra começar. Essa configuração usa os padrões do Laravel 13, que mantêm o cache, as sessões e a fila no banco de dados. Adicione um serviço Redis quando você migrar pro Horizon ou precisar de um cache mais rápido.

Esse é o stack completo: uma imagem pro servidor web, o worker e o scheduler, o MySQL ao lado deles, e certificados tratados pra você. Depois da primeira configuração, toda release é um git pull, um comando do Compose e uma migration.

Por que escolhem o Railyard para isso

  • Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
  • Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
  • Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
  • Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
  • Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.

Pule a configuração do servidor.

Conecte sua conta Hetzner, cole a URL do seu repositório, e o Railyard constrói, migra e faz deploy da sua aplicação Laravel com o banco de dados e os workers dela.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.