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:
- Docker Desktop (no Linux, o Docker Engine com o
docker compose) - Git
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
upsobe os serviços.-ddeixa rodando em segundo plano e te devolve o terminal.--buildmonta 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_connmanda 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,supervisorouloadbalancer) e copia pro projeto. - No
.env,DB_HOST=databaseeREDIS_HOST=cache. docker compose up -d --builde depois omigratedentro 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 noupstream.
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
app1e oapp2usam 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 aqueledocker 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
.testautomaticamente (tipomeu-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!