DevOps Mind
Docker Compose passo a passo para quem vem de VMs
Docker Compose passo a passo com PHP e MariaDB para quem vem de VMs. Teste volume, rede e falhas, e evite conflito de faixa IP com o host.
No começo da carreira, operei aplicações PHP em EC2 com Nginx e PHP-FPM direto no host, com o banco no RDS. Quando o Docker entrou nesse cenário, o que mais me pegou não foi o Dockerfile, foi a rede: a rede criada pelo Docker entrou em conflito com a rede local da EC2, na faixa 172.30, e o container simplesmente não subia.
Para quem vem de VMs, a dúvida central é onde cada coisa roda: qual endereço usar, qual porta acessar e onde ficam os dados. Este passo a passo responde isso na prática. Você sobe uma aplicação PHP com MariaDB usando Docker Compose, recria os containers e para o banco de propósito para ver o que acontece.
Antes de começar Para acompanhar, você precisa de Docker e Compose funcionando e um terminal Bash no Linux ou WSL. O exemplo usa PHP simples, sem instalar Laravel. Testado na distro WSL Ubuntu-26.04; Docker Desktop e macOS não foram testados.
Início rápido: suba aplicação e banco
1. Confira as ferramentas e crie a pasta
docker version
docker compose version
mkdir compose-vms-lab
cd compose-vms-labO primeiro comando precisa mostrar cliente e servidor. Se aparecer erro de conexão com o daemon, resolva isso antes de seguir. O segundo precisa reconhecer docker compose, com espaço. Para instalar, use as instruções oficiais de Docker Engine e do plugin Compose.
A pasta vai conter compose.yaml, Dockerfile, index.php e .env. Crie os três primeiros com seu editor. O .env será gerado no passo seguinte.
2. Gere a senha do laboratório
umask 077
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
printf '.env\n*.sql\n' > .gitignore
printf '.env\n*.sql\n.git\n' > .dockerignoreFaça isso uma única vez, na pasta nova. O comando depende de openssl. Não compartilhe nem versione o .env. O .dockerignore também impede que esse arquivo entre no contexto enviado ao build.
Não regenere a senha depois de criar o banco As variáveis de inicialização do MariaDB configuram o banco quando o diretório de dados está vazio. Trocar apenas o .env depois disso não altera automaticamente a senha já gravada no banco. Você pode acabar com a aplicação usando uma senha e o banco esperando outra.
3. Crie a aplicação PHP
Salve como index.php. A aplicação abre uma conexão, cria uma tabela didática e incrementa um contador a cada acesso. Esse comportamento permite observar se o dado sobrevive à recriação dos containers.
<?php
header('Content-Type: text/plain; charset=utf-8');
try {
$dsn = 'mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_DATABASE') . ';charset=utf8mb4';
$db = new PDO($dsn, getenv('DB_USERNAME'), getenv('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 3,
]);
$db->exec('CREATE TABLE IF NOT EXISTS visitas (id INT PRIMARY KEY, total INT NOT NULL)');
$db->exec('INSERT INTO visitas (id, total) VALUES (1, 1) ON DUPLICATE KEY UPDATE total = total + 1');
$total = $db->query('SELECT total FROM visitas WHERE id = 1')->fetchColumn();
echo "Aplicacao PHP conectada ao banco.\nVisitas: $total\n";
} catch (PDOException $e) {
http_response_code(503);
error_log('Banco indisponivel; SQLSTATE: ' . $e->getCode());
echo "Banco indisponivel. Consulte os logs do laboratorio.\n";
}O usuário do banco pode criar essa tabela para simplificar o exercício. Em uma aplicação de produção, permissões e migrações merecem outro desenho. A resposta de falha é genérica; o navegador não recebe a mensagem completa da exceção.
4. Crie a imagem da aplicação
Salve como Dockerfile:
FROM php:8.4-cli
RUN docker-php-ext-install pdo_mysql
WORKDIR /app
COPY index.php /app/index.php
USER www-data
CMD ["php", "-S", "0.0.0.0:8000", "-t", "/app"]Esse arquivo parte da imagem oficial PHP, instala o driver de conexão com MySQL/MariaDB e copia o programa. O processo da aplicação usa www-data. A documentação da imagem PHP explica o instalador de extensões utilizado aqui. Para imagens de produção menores, veja build multistage no Docker.
Servidor apenas para estudo O servidor embutido do PHP atende ao laboratório local. Não transforme esse comando em um deploy público. O próprio manual do PHP delimita seu uso para desenvolvimento e testes.
5. Descreva os dois serviços
Salve como compose.yaml:
name: devopsmind-compose-lab
services:
app:
build: .
ports:
- "127.0.0.1:8080:8000"
environment:
DB_HOST: db
DB_DATABASE: laboratorio
DB_USERNAME: aluno
DB_PASSWORD: ${DB_PASSWORD:?Defina DB_PASSWORD no arquivo .env}
depends_on:
db:
condition: service_healthy
db:
image: mariadb:11.4
environment:
MARIADB_DATABASE: laboratorio
MARIADB_USER: aluno
MARIADB_PASSWORD: ${DB_PASSWORD:?Defina DB_PASSWORD no arquivo .env}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
volumes:
db_data:Leia o arquivo procurando responsabilidades. app é o serviço PHP. db é o banco. build: . manda construir a imagem da aplicação usando o diretório atual. O banco usa uma imagem pronta. db_data guarda os dados fora da camada gravável do container. O Compose também aceita o nome antigo docker-compose.yml; compose.yaml é o nome padrão atual, conforme a referência do arquivo Compose.
O banco não tem ports. A aplicação consegue conversar com ele pela rede do projeto sem publicar a porta no computador. A senha chega aos dois serviços por uma variável do ambiente. O usuário root recebe uma senha aleatória gerada pela imagem e não é usado pelo PHP.
Por que o banco está em container aqui O MariaDB roda em container para o laboratório ficar autocontido. No cenário de EC2 que descrevi na abertura, o banco ficava no RDS, fora da máquina. O raciocínio de rede é o mesmo nos dois casos: a aplicação fala com um nome, não com um IP fixo.
As tags php:8.4-cli e mariadb:11.4 fixam a linha de versão, mas recebem atualizações. Para algo além do laboratório, fixe a imagem por digest.
6. Suba e confira a resposta
docker compose config --quiet
docker compose up -d --build --wait --wait-timeout 180
docker compose ps
curl -i http://localhost:8080config --quiet valida a configuração sem imprimir o arquivo interpolado com a senha. O build e o primeiro download dependem de internet. Se o tempo limite for atingido, confira ps e os logs antes de repetir os comandos.
O resultado esperado é HTTP 200 e um corpo neste formato:
Aplicacao PHP conectada ao banco.
Visitas: 1Abra também http://localhost:8080 no navegador. O contador deve aumentar a cada requisição ao PHP, inclusive acessos adicionais feitos pelo navegador. Não espere um número fixo depois de abrir várias abas.

Confira se o dado sobrevive à recriação

Anote o contador e execute:
docker compose down
docker compose up -d --wait --wait-timeout 180
curl http://localhost:8080O contador deve continuar do valor anterior. Esse é o critério do exercício: o programa foi recriado, mas a tabela continuou no volume nomeado. Mantenha o mesmo diretório e o mesmo nome de projeto durante o teste.
Segundo a referência de docker compose down, remover o projeto com esse comando não remove os volumes nomeados por padrão. Acrescentar --volumes muda essa operação.
Volume não é backup Persistir dados entre containers não protege contra falha do disco, exclusão do volume ou um comando SQL errado. Backup do banco, restauração e retenção são tarefas próprias. Este laboratório não implementa essa proteção.
Quebre uma dependência e acompanhe o diagnóstico
Pare somente o banco:
docker compose stop db
docker compose ps -a
curl -i http://localhost:8080
docker compose logs --tail=20 appstop não é down stop só para os containers, que continuam existindo. down remove containers e a rede do projeto, mas mantém os volumes nomeados, a menos que você passe --volumes.
O esperado é HTTP 503 com uma mensagem de banco indisponível. O processo PHP pode continuar em execução, mas isso não significa que a aplicação esteja funcional. Compare o estado dos dois serviços com a resposta HTTP. Se o DNS do ambiente estiver lento, a resposta de erro pode demorar alguns segundos.
Recupere o projeto e tente novamente:
docker compose up -d --wait --wait-timeout 180
curl -i http://localhost:8080O esperado é voltar ao HTTP 200 com os dados anteriores. O healthcheck.sh vem da imagem MariaDB e testa a prontidão do banco, conforme a documentação oficial do healthcheck.
depends_on com service_healthy coordena a inicialização. Ele não transforma Compose em um sistema que resolve toda queda posterior de dependência. Leia as condições e limites na documentação de ordem de inicialização.
O que muda no raciocínio de quem opera VMs
| Referência de infraestrutura | Como pensar neste laboratório |
|---|---|
| VM com sistema operacional e serviços instalados | Container executa o processo definido pela imagem, com isolamento; não é uma VM completa |
| Instalar PHP manualmente no servidor | Dockerfile descreve como montar a imagem com PHP e extensão |
| Iniciar app e banco separadamente | Compose gerencia os dois serviços declarados no projeto |
| Guardar tudo no disco da máquina | Separar a imagem da aplicação dos dados persistentes do banco |
| Entrar por SSH para investigar | Começar por estado, logs e comandos pontuais com docker compose exec |
| Fixar IP do servidor na configuração | Usar o nome db para descobrir o serviço na rede Compose |
Container não é servidor para ajustar na mão Um vício comum de quem vem de VM é entrar com docker compose exec para instalar um pacote ou editar um php.ini. Funciona até o container ser recriado, por um down seguido de up ou por um up --build com imagem nova: ele volta ao que a imagem define. Ajuste que precisa ficar vai para o Dockerfile ou para o compose.yaml.
Containers Linux compartilham o kernel do ambiente Linux que os executa. No Docker Desktop, esse ambiente pode estar dentro de uma VM. Containers e VMs podem coexistir. A distinção está no modelo de execução e isolamento, não em proibir uma tecnologia quando você adota a outra.
Se imagem, build e container ainda se misturam, volte à base em Docker e o papel da imagem no pipeline. O Compose usa essas peças; ele não substitui o Dockerfile.
Rede: de onde você está tentando conectar?

Uma dificuldade que encontrei foi separar os IPs dos containers, os endereços da rede local e a própria rede Docker. Antes de trocar um IP na configuração, localize quem está iniciando a conexão.
| Origem | Destino neste lab | Endereço e porta |
|---|---|---|
| Navegador no computador que executa o lab | Aplicação publicada | localhost:8080 |
Serviço app | Serviço db | db:3306 |
PHP dentro de app | O próprio container app | localhost aponta para o próprio ambiente do processo, não para db |
| Outro computador da LAN | Aplicação do lab | Não há publicação intencional na interface da LAN nesta configuração |
Em 127.0.0.1:8080:8000, a primeira parte seleciona a interface local do host, a segunda é a porta publicada e a terceira é a porta em que o PHP atende dentro do container. Já 0.0.0.0:8000, no comando PHP, faz o processo escutar nas interfaces do container; isso não equivale a publicar a aplicação na LAN. A referência de publicação de portas explica o mapeamento e as ressalvas, incluindo a possibilidade de acesso pela mesma rede local em versões do Engine anteriores à 28.
Entre os serviços, use db. Não copie o IP observado em uma inspeção para DB_HOST. O Compose fornece descoberta pelo nome do serviço, e o endereço do container pode mudar. A documentação de rede Compose detalha essa separação.
Faça a pergunta antes do comando O navegador está no host e usa a porta publicada. O PHP está dentro de app e usa o serviço db. Desenhar esses dois caminhos no papel já elimina boa parte da confusão com localhost.
Confira nome e conexão a partir da aplicação
docker compose exec app php -r 'echo gethostbyname("db"), PHP_EOL;'
docker compose exec app php -r '$s = @fsockopen("db", 3306, $errno, $erro, 3); echo $s ? "TCP OK\n" : "TCP FALHOU\n";'
docker compose port app 8000O primeiro comando deve devolver o IP resolvido para db; se devolver apenas o texto db, a resolução falhou. O segundo verifica se consegue abrir a porta TCP. TCP OK não valida usuário, senha ou tabela, apenas essa conexão. O terceiro mostra a publicação da porta da aplicação.
Quando a rede do Docker colide com a rede do host
Esse foi o problema que vivi na EC2. O Docker cria redes bridge em faixas privadas, e cada projeto Compose ganha a sua. No meu caso, a rede do Docker conflitou com a rede local da instância, na faixa 172.30, e o sintoma foi o container falhando na subida. Para quem vem de VM, a pegadinha é que essas faixas parecem “internas do Docker”, mas dividem a tabela de rotas com o host.
A correção combinou três passos: ajustar o daemon.json para o Engine usar faixas que não conflitam com a rede da VPC, fixar a sub-rede do projeto no Compose e remover a rede conflitante. A remoção é necessária porque mudar o daemon não altera redes que já existem; ela vale para as próximas que forem criadas.
ip route
docker network inspect devopsmind-compose-lab_default --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'Compare a sub-rede do projeto com as rotas que o host já usa. Para evitar a colisão, defina faixas que não conflitam com a sua rede: para o Engine inteiro, com default-address-pools na configuração do daemon; para um projeto, com ipam na definição de redes do Compose.
Use a inspeção do host como uma camada do diagnóstico
No meu caso, foi o ip a que esclareceu a rede: as interfaces docker0 e br-... aparecem ali com seus blocos de IP, ao lado das interfaces da própria máquina. No Linux que hospeda o Engine, comece por:
ip a
docker network ls
docker network inspect devopsmind-compose-lab_defaultO nome acima decorre do name definido no arquivo. Ele muda se você sobrescrever o nome do projeto. Consulte docker network ls em vez de assumir que toda rede Docker se chama docker0.
Para conhecer a rede deste projeto, prefira a inspeção do Docker a arquivos de rede da distribuição, e não edite o host para tentar corrigir DB_HOST.
Erros que valem investigar por camada
| Sintoma | O que verificar | Próxima ação |
|---|---|---|
Docker não mostra servidor em docker version | Engine e contexto selecionado | Resolver o acesso ao daemon antes do Compose |
| Porta 8080 ocupada | Outro processo ou projeto usando a porta | Trocar só a porta do host, por exemplo para 127.0.0.1:8081:8000 |
app não encontra db | Nome do serviço e rede compartilhada | Testar resolução dentro de app |
| TCP responde, mas PHP retorna 503 | Credenciais, nome do banco e inicialização | Conferir logs de app; comparar variáveis sem divulgá-las |
Senha mudou no .env e conexão parou | Volume inicializado com outra senha | Restaurar a configuração anterior ou alterar a senha pelo banco |
| Banco demora a ficar saudável | Pull, recursos e logs de inicialização | Ler logs de db localmente e conferir o healthcheck |
| Container não sobe e a rede do projeto conflita com a rede do host | Sobreposição entre a sub-rede Docker e as rotas do host (ip route) | Definir faixas com default-address-pools ou ipam e remover a rede conflitante |
No Windows, use Bash no WSL para os comandos deste artigo. Com Docker Desktop, a rede dos containers vive no ambiente administrado pelo Desktop; ip a no WSL ou no host não mostra necessariamente as mesmas interfaces do Engine. A documentação de rede do Desktop descreve essas diferenças.
Encerre o laboratório sabendo o que será removido
Para encerrar preservando o volume:
docker compose downSomente se quiser apagar os dados didáticos e começar do zero:
docker compose down --volumesExecute na pasta do laboratório, confira o projeto e não use comandos de limpeza global. As imagens podem continuar no cache local; isso é separado da vida dos containers e dos dados.
Minha leitura: onde o Compose cabe
Minha regra continua a mesma do post sobre Docker: Compose no ambiente local e em serviço de uma máquina só. Em EC2 de serviço, como um Zabbix com banco e aplicação juntos, ele funcionou bem e não deu trabalho no dia a dia. Quando a aplicação precisa escalar e se recuperar sozinha, o lugar é o Kubernetes.
Antes de seguir, faça três coisas sem consultar o YAML: diga qual endereço o PHP usa para o banco, mostre onde o dado persiste e encontre o primeiro comando para investigar uma falha. Quando isso fizer sentido, o laboratório de Kubernetes local com Kind passa a ser um próximo passo com contexto.








