Vitor Bellini
Todos os posts

Como eu uso Docker com Laravel 13

11 min de leitura #laravel #architecture

Docker não é mais novidade pra ninguém, mas vou te mostrar como eu utilizo no dia a dia com Laravel.

Toda vez que eu começava um projeto, perdia um tempão montando ambiente: versão do PHP, extensão faltando, Postgres instalado na máquina, Redis... Então juntei tudo num repositório com uns modelos prontos, e hoje subir um projeto é questão de minutos.

E no final tem a parte que eu mais curto: colocar a aplicação atrás de um load balancer, rodando em mais de uma instância.

Só um aviso antes: isso tudo é pra rodar na sua máquina, em desenvolvimento. Não sobe isso em produção, beleza?

O que tem no repositório

Tá tudo lá no GitHub: bellinivitor/docker-laravel.

São três pastas, e cada uma é um ambiente completo. Você escolhe uma e copia pro seu projeto:

Pasta Quando usar
default O dia a dia. Só a aplicação, sem filas rodando em segundo plano.
supervisor Quando o projeto usa filas (jobs) e tarefas agendadas. O próprio container da aplicação já roda os workers e o scheduler.
loadbalancer Duas cópias da aplicação atrás de um nginx que divide as requisições entre elas. Boa pra estudar escalabilidade e pra fazer teste de carga.

Em todas elas sobem os mesmos serviços:

  • app: o PHP 8.5, que roda o Laravel.
  • web: o nginx, que recebe as requisições do navegador e passa pro PHP.
  • database: o PostgreSQL 18.
  • cache: o Redis 8.

O que você precisa ter instalado

Só isso:

Pra ver se o Docker tá ok:

docker compose version

Apareceu uma versão? Bora.

1. Criando o projeto Laravel

Se você já tem um projeto Laravel 13, pode pular pro próximo passo.

Você não precisa nem ter PHP ou Composer instalados. A gente usa o Composer de dentro de um container:

docker run --rm -it -v "$PWD":/app -w /app --user "$(id -u):$(id -g)" composer:2 create-project laravel/laravel meu-app

Isso cria a pasta meu-app com um Laravel zerado. O --user é pra que os arquivos fiquem no seu nome, e não no do root do container. Sem ele você vai brigar com permissão depois.

2. Copiando o ambiente pro projeto

Clona o repositório:

git clone https://github.com/bellinivitor/docker-laravel.git

E copia a variante que você quer pra dentro do projeto. Vamos começar pela default:

cp -R docker-laravel/default/. meu-app/

Repara no /. no final. Ele copia também o .dockerignore, que é um arquivo oculto e fica pra trás se você esquecer.

Seu projeto vai ficar assim:

meu-app/
├── app/
├── docker/
│   ├── Dockerfile
│   ├── nginx/nginx.conf
│   └── php/php.ini
├── public/
├── .dockerignore
├── .env
└── docker-compose.yml

Uma coisa rápida: abre o docker-compose.yml e troca a primeira linha, name: project-name, pelo nome do seu projeto. É esse nome que aparece nos containers, e ele evita que um projeto atropele o outro.

3. Configurando o .env

O Laravel novo vem configurado com SQLite. Abre o .env, acha o bloco que começa com DB_CONNECTION e troca por isso:

DB_CONNECTION=pgsql
DB_HOST=database
DB_PORT=5432
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=secret

E aponta o Redis pro container de cache:

REDIS_HOST=cache

Viu que o DB_HOST é database e o REDIS_HOST é cache? São os nomes dos serviços no docker-compose.yml. Dentro da rede do Docker, um container acha o outro pelo nome. Nada de 127.0.0.1 aqui.

E você não precisa configurar o banco em dois lugares: o Docker Compose lê esse mesmo .env pra criar o Postgres com esse usuário e senha.

Tá no Linux? Coloca também o seu UID e GID, senão a pasta storage vai dar dor de cabeça com permissão:

echo "HOST_UID=$(id -u)" >> .env
echo "HOST_GID=$(id -g)" >> .env

No Mac e no Windows pode ignorar essa parte.

4. Subindo tudo

Entra na pasta do projeto e manda:

cd meu-app
docker compose up -d --build
  • up sobe os serviços.
  • -d deixa rodando em segundo plano e te devolve o terminal.
  • --build monta a imagem do PHP. A primeira vez demora uns minutinhos, depois fica em cache.

Pra ver se subiu tudo:

docker compose ps

Tem que estar tudo Up. O database e o cache mostram (healthy) quando já estão prontos pra receber conexão.

5. Migrations e navegador

Cria as tabelas no Postgres:

docker compose exec app php artisan migrate

Agora abre o http://localhost. Se apareceu a página do Laravel, deu certo!

Um detalhe que eu deixei resolvido no Dockerfile: o container roda com o usuário www-data, o mesmo do PHP que atende o site. Por padrão, o docker compose exec rodaria os comandos como root, e aí um migrate qualquer criava arquivo em storage/ que o Laravel não conseguia escrever depois. Era erro de permissão aparecendo do nada. Assim você não precisa lembrar de nada.

Os comandos que você vai usar todo dia

# Qualquer comando do artisan
docker compose exec app php artisan route:list

# Instalar pacote
docker compose exec app composer require nome/pacote

# Entrar no container
docker compose exec app sh

# Ver os logs de tudo (Ctrl+C pra sair)
docker compose logs -f

# Parar tudo
docker compose down

O código do projeto tá montado dentro do container. Salvou o arquivo no editor, já vale na próxima requisição. Não precisa reiniciar nada.

Só toma cuidado com o docker compose down -v. Esse -v apaga os volumes, ou seja, apaga o seu banco. Já vi muita gente perder dado assim.

Simular prod com filas: a pasta supervisor

Nesse caso, copia a pasta supervisor no lugar da default:

cp -R docker-laravel/supervisor/. meu-app/

O resto é igualzinho. A diferença é que o container app passa a rodar três coisas ao mesmo tempo, e quem cuida delas é o Supervisor:

  • o PHP-FPM, que atende as requisições;
  • dois workers de fila (queue:work);
  • o scheduler (schedule:work), que roda as tarefas agendadas.

Pra ver se tá tudo rodando:

docker compose exec app supervisorctl status

Pra acompanhar o que os workers estão fazendo:

docker compose logs -f app

E uma pegadinha clássica: o worker carrega o código quando inicia. Se você mexer num job, reinicia os workers, senão eles continuam rodando a versão antiga:

docker compose exec app php artisan queue:restart

Agora a parte divertida: o load balancer

Copia a pasta loadbalancer:

cp -R docker-laravel/loadbalancer/. meu-app/

Sobe normal, com docker compose up -d --build. A diferença é que aqui não tem um serviço app, tem dois: app1 e app2. Então nos comandos você usa app1:

docker compose exec app1 php artisan migrate

Tanto faz em qual delas você roda. As duas usam o mesmo código e o mesmo banco.

Como funciona

flowchart LR
    browser([Navegador]) --> nginx[nginx]
    nginx --> app1[app1<br/>PHP-FPM]
    nginx --> app2[app2<br/>PHP-FPM]
    app1 --> db[(PostgreSQL)]
    app2 --> db
    app1 --> redis[(Redis)]
    app2 --> redis

Todo mundo bate no nginx, e ele decide pra qual instância mandar cada requisição. Isso fica no docker/nginx/nginx.conf:

upstream php_backend {
    least_conn;
    server app1:9000;
    server app2:9000;
}
  • O upstream é a lista de servidores que vão dividir o trabalho.
  • O least_conn manda cada requisição pra instância que tá com menos conexões abertas naquele momento.

E se uma instância cair, o nginx manda pra outra. O usuário nem fica sabendo.

Sessão e cache precisam ficar num lugar só

Pensa comigo: com duas instâncias, uma requisição pode cair na app1 e a próxima na app2. Se a sessão do usuário ficar guardada só dentro de uma delas, ele é deslogado do nada quando troca de instância.

Por isso, sessão e cache vão pra um lugar que as duas enxergam. No .env:

SESSION_DRIVER=redis
CACHE_STORE=redis

Guarda essa regra, que ela vale pra qualquer aplicação que precisa escalar: a instância não guarda estado. Tudo que precisa durar vai pro banco, pro Redis ou pra um storage compartilhado.

Vendo o balanceamento acontecer

Deixei um header X-Upstream em toda resposta, dizendo qual instância atendeu. Faz umas requisições seguidas:

for i in 1 2 3 4 5 6; do curl -s -o /dev/null -D - http://localhost | grep -i x-upstream; done

Você vai ver dois endereços se revezando, tipo:

X-Upstream: 172.20.0.5:9000
X-Upstream: 172.20.0.6:9000
X-Upstream: 172.20.0.5:9000

Quer saber qual IP é de qual instância?

docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker compose ps -q app1 app2)

Colocando mais uma instância

No docker-compose.yml, adiciona um serviço logo abaixo do app2:

    app3:
        <<: *app

Esse <<: *app reaproveita toda a configuração das outras instâncias, não precisa copiar nada. Coloca a app3 também no depends_on do serviço web.

No docker/nginx/nginx.conf, adiciona a linha no upstream:

    server app3:9000;

Sobe a instância nova e reinicia o nginx:

docker compose up -d
docker compose restart web

Confere com o loop do curl lá de cima: agora vão aparecer três endereços se revezando.

Deu ruim? Os problemas mais comuns

"port is already allocated" na hora de subir. Já tem alguma coisa usando a porta 80 na sua máquina. Escolhe outra no .env, tipo APP_PORT=8080, e acessa http://localhost:8080. Mesma coisa pro FORWARD_DB_PORT (5432) e FORWARD_REDIS_PORT (6379).

Erro 502 Bad Gateway. O nginx não tá conseguindo falar com o PHP. Vê se o container da aplicação tá de pé com docker compose ps e procura o erro com docker compose logs app.

"Permission denied" no storage/logs. Algum arquivo ficou com um dono que o PHP não consegue escrever. Isso costuma acontecer quando alguém roda comando com -u root. No Linux, confere também o HOST_UID/HOST_GID do passo 3. Depois arruma o dono dos arquivos (esse precisa de root mesmo):

docker compose exec -u root app chown -R www-data:www-data storage bootstrap/cache

Mudei o .env e nada mudou. Limpa o cache de configuração:

docker compose exec app php artisan config:clear

Mudei o Dockerfile e nada mudou. A imagem precisa ser montada de novo:

docker compose up -d --build

Resumindo

  • Escolhe a variante (default, supervisor ou loadbalancer) e copia pro projeto.
  • No .env, DB_HOST=database e REDIS_HOST=cache.
  • docker compose up -d --build e depois o migrate dentro do container.
  • Com o loadbalancer, você tem várias instâncias atrás do nginx, e adicionar mais uma é só um serviço no compose e uma linha no upstream.

Bônus: o que acontece por baixo dos panos

Se você chegou até aqui só copiando comando, beleza, já dá pra trabalhar. Mas vale entender o que o Docker faz quando você roda um docker compose up. Uns anos atrás eu desenhei isso pra explicar pro time, e continua valendo.

Do docker-compose.yml ao container rodando

flowchart LR
    compose[docker-compose.yml] --> existe{Imagem existe?}
    compose -. "build:" .-> build[docker build]
    existe -- Não --> pull[docker pull]
    existe -- Sim --> run[docker run]
    pull --> run
    build --> run
    run --> container([Container rodando])

O Docker Compose é basicamente um jeito organizado de escrever vários docker run. Cada serviço do arquivo vira um comando, com as portas, volumes e variáveis que você configurou.

Pra cada serviço, o Docker vê se já tem a imagem na sua máquina. Se não tiver, baixa do Docker Hub (é por isso que o primeiro up demora e os próximos são rápidos). No nosso caso, o app é diferente: ele tem build: no compose, então em vez de baixar pronto, o Docker monta a imagem seguindo o nosso Dockerfile. As redes e os volumes passam pelo mesmo processo: se não existem, são criados.

Uma comparação que ajuda: a imagem é a classe, o container é o objeto. A imagem é o molde, só de leitura. O container é uma instância rodando a partir dela, e você pode ter várias instâncias da mesma imagem. Foi exatamente isso que a gente fez com o app1 e o app2.

As camadas de um container

flowchart BT
    so["Sistema operacional<br/>Linux, ou uma VM Linux no Mac e no Windows"] --> engine["Docker Engine<br/>divide CPU, memória e rede entre os containers"]
    engine --> container
    subgraph container ["Container"]
        rw["Camada gravável<br/>o que muda enquanto ele roda"]
        img["Camadas da imagem<br/>somente leitura e compartilhadas"]
    end
    hd[("Seu disco<br/>pasta do projeto")] -.->|"bind mount ./:/app"| container
    vol[("Volume nomeado<br/>database-data")] -.->|"/var/lib/postgresql"| container

Lendo de baixo pra cima:

  • Sistema operacional e Docker Engine. Os containers usam o kernel do próprio sistema, por isso são bem mais leves que uma máquina virtual. No Mac e no Windows, o Docker Desktop roda uma VM Linux pequena por trás, porque os containers precisam de um kernel Linux.
  • Camadas da imagem. São só leitura e ficam salvas uma vez só no disco. O app1 e o app2 usam a mesma imagem, então não ocupam o dobro de espaço.
  • Camada gravável. Tudo que o container escreve enquanto roda fica aqui. Só que ela é descartável: removeu o container, foi junto.

E é por isso que existem os volumes, pra guardar o que não pode sumir:

  • O bind mount (./:/app) mapeia uma pasta do seu HD pra dentro do container. É o que faz o código que você salva no editor aparecer na hora lá dentro.
  • O volume nomeado (database-data) é gerenciado pelo Docker e guarda os dados do Postgres. Você pode derrubar e recriar o container à vontade que o banco continua lá. A não ser que você rode aquele docker compose down -v, lembra?

E quando eu não uso Docker?

Nem todo projeto precisa de Docker. Quando é algo mais simples, ou quando eu quero só abrir o projeto e sair codando, uso o Laravel Herd junto com o DBngin.

  • O Herd instala PHP e nginx direto na máquina, sem container. Cada pasta de projeto vira um endereço .test automaticamente (tipo meu-app.test), e trocar a versão do PHP é um clique.
  • O DBngin sobe Postgres, MySQL e Redis também com um clique, cada um na versão que você quiser.

É mais leve e mais rápido pra começar. Em compensação, o ambiente fica atrelado à sua máquina. Quando o projeto tem fila, precisa de várias instâncias ou quando o time inteiro precisa rodar exatamente o mesmo ambiente, eu volto pro Docker.

Qualquer dúvida ou sugestão, abre uma issue lá no repositório. Bons códigos!

1 curtida