Criando um Dockerfile para um site em PHP
Olá meus Unicórnios! 🦄✨
Sabe aquele site em PHP que funciona lindamente na sua máquina e, no servidor do cliente, resolve que a extensão curl não existe? 😅 Pois é. Eu já perdi tarde inteira nisso — e a frase que sempre sobrava era a mesma: "mas aqui roda".
O Docker resolve essa frase de um jeito meio bruto: ele empacota o PHP, o Apache, as extensões e o seu código num arquivo só. O que você testou é literalmente o que vai subir. Não é "o mesmo ambiente" — é o mesmo mesmo.
Só que o caminho até lá tem umas pedras que ninguém avisa. Este artigo é o que eu aprendi montando o Dockerfile de um painel em PHP: por que a imagem oficial vem sem quase nenhuma extensão, como o seu .env vaza para dentro da imagem sem você perceber, e por que a ordem das linhas muda o tempo do build.
🧱 O Dockerfile inteiro, antes de destrinchar
Vou começar pelo fim, porque acho mais fácil entender uma coisa depois de ver ela pronta. Este é o arquivo completo, e ele é menor do que a maioria das pessoas espera:
# ============================================================================
# Site em PHP servido por Apache.
#
# Este e o Dockerfile do artigo. A imagem oficial php:8.2-apache ja vem com o
# Apache configurado servindo /var/www/html na porta 80, com index.php como
# DirectoryIndex — por isso nao ha nenhum arquivo de configuracao do Apache
# aqui. O que sobra e o que realmente muda de projeto para projeto: as
# extensoes, o codigo e as permissoes.
# ============================================================================
FROM php:8.2-apache
# ---------------------------------------------------------------------------
# Extensoes do PHP
# ---------------------------------------------------------------------------
# A imagem oficial vem enxuta: nem curl, nem pdo_mysql, nem gd vem prontos.
# docker-php-ext-install compila a extensao; as libs -dev sao o que ela
# precisa para compilar.
#
# Tudo num RUN so, com o apt-get clean no fim: cada RUN e uma camada, e
# apagar o cache do apt numa camada seguinte nao diminui a imagem — os
# arquivos continuam gravados na camada anterior.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
&& docker-php-ext-install curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/html
# ---------------------------------------------------------------------------
# Codigo da aplicacao
# ---------------------------------------------------------------------------
# O .dockerignore ao lado deste arquivo decide o que NAO entra: .env, .git,
# node_modules. Sem ele, um COPY . copia o .env com as senhas para dentro da
# imagem — e quem baixar a imagem le o arquivo.
COPY . /var/www/html/
# O Apache roda como www-data. Sem isso, uma aplicacao que precise escrever
# (upload, cache, log) esbarra em permissao.
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80
# A imagem php:8.2-apache ja define o CMD que sobe o Apache em primeiro
# plano (apache2-foreground). Nao redefina: um Apache em segundo plano faz o
# processo principal terminar, e o container morre logo depois de subir.
É isso. Um FROM, um RUN que instala uma extensão, um COPY, um chown e o EXPOSE. Não tem arquivo de configuração do Apache, não tem CMD, não tem script de inicialização.
E cada uma dessas ausências tem um motivo. Vamos por partes. 🙂
🐘 A imagem oficial já traz o Apache configurado
A primeira linha é a que faz mais trabalho:
FROM php:8.2-apache
Essa imagem já vem com o Apache instalado, o módulo do PHP habilitado, o DocumentRoot apontando para /var/www/html e o index.php como DirectoryIndex. É por isso que não existe nenhum .conf no meu Dockerfile: não precisa.
Existem outras variantes, e a escolha importa:
php:8.2-apache— Apache junto, pronto para servir. É a que eu uso para site.php:8.2-fpm— só o PHP-FPM, sem servidor web. Você precisa de um Nginx num segundo container conversando com ele.php:8.2-cli— só a linha de comando. Boa para script e worker, péssima para site.
Já vi gente começar com a -fpm achando que era "a mais leve" e passar a tarde tentando entender por que a porta 80 não respondia nada. 😅 Ela não responde porque não tem ninguém escutando ali — o FPM fala outro protocolo, em outra porta, e espera um Nginx do outro lado. Para um site simples, a -apache é o caminho curto.
🧩 Por que a extensão cURL não estava lá
Essa é a pedra que me pegou primeiro. A imagem oficial do PHP é enxuta de propósito: ela traz o núcleo da linguagem e pouco mais. Nem curl, nem pdo_mysql, nem gd, nem zip vêm prontos.
O código sobe, a página abre, e aí a primeira chamada a curl_init() estoura com um Call to undefined function. No meu caso demorei a entender porque, na minha máquina, o PHP do XAMPP tinha tudo habilitado — o problema só apareceu dentro do container.
Quem resolve é o docker-php-ext-install, um script que a própria imagem oficial traz:
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
&& docker-php-ext-install curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
Repare que são duas coisas diferentes na mesma linha, e confundir as duas é comum:
libcurl4-openssl-devé a biblioteca do sistema, instalada peloapt-get. A extensão precisa dela para compilar.docker-php-ext-install curlé a extensão do PHP, compilada e habilitada.
Instalar só a primeira não cria a função. Chamar a segunda sem a primeira dá erro de compilação. As duas andam juntas — e é por isso que o padrão para cada extensão é sempre "a lib -dev mais o ext-install".
📚 Por que tudo isso num RUN só
Aqui está uma coisa que eu fazia errado e demorei a entender: cada instrução do Dockerfile vira uma camada, e camada não se apaga.
Eu escrevia assim, achando bonito e organizado:
RUN apt-get update
RUN apt-get install -y libcurl4-openssl-dev
RUN docker-php-ext-install curl
RUN rm -rf /var/lib/apt/lists/*
O resultado é uma imagem maior, mesmo com o rm -rf no fim. Porque o cache do apt foi gravado na camada do update, e a camada do rm só marca os arquivos como removidos na camada dela. Os bytes continuam lá embaixo, viajando junto com a imagem para sempre.
A imagem final é a soma de todas as camadas — inclusive daquelas cujo conteúdo você "apagou". Por isso o update, o install e a limpeza têm que estar no mesmo RUN: aí o cache nasce e morre dentro da mesma camada, e nunca chega a ser gravado.
O --no-install-recommends entra na mesma lógica. Sem ele, o apt instala um monte de pacote "recomendado" que ninguém pediu e que ninguém vai usar.
🔒 O .dockerignore — e como o seu .env vaza
Se você só for ler um pedaço deste artigo, leia este. 🙏
O COPY . /var/www/html/ copia a pasta inteira para dentro da imagem. Inteira mesmo: o .git com todo o histórico, o node_modules, e — o que dói — o .env com as suas senhas.
E o pior é que nada acusa. O build passa, o site sobe, tudo funciona. O arquivo está lá dentro, quietinho, e qualquer pessoa que tenha a imagem lê ele com um comando:
docker run --rm minha-imagem cat /var/www/html/.env
Se você publicou essa imagem num registro, publicou as senhas junto. 😳
Quem resolve é um arquivo chamado .dockerignore, ao lado do Dockerfile. Ele funciona igual ao .gitignore, mas para o build:
# O que NAO entra na imagem.
#
# Este arquivo e a diferenca entre publicar uma imagem limpa e publicar as
# suas senhas: sem ele, o "COPY . /var/www/html/" leva o .env junto, e quem
# baixar a imagem le o arquivo com um "docker run ... cat .env".
# Segredos — vem da aba Environment do painel, nunca da imagem
.env
.env.*
!.env.example
# Metadados de dev / VCS
.git
.gitignore
.claude
README.md
*.code-workspace
# Dependencias reconstruidas dentro do build
node_modules
# O proprio Dockerfile nao precisa se copiar
Dockerfile
.dockerignore
Fora a segurança, ele deixa o build mais rápido: tudo que o Docker copia primeiro viaja para o daemon como "contexto de build". Uma pasta .git gorda ou um node_modules fazem esse envio demorar à toa.
No repositório de exemplo eu deixei essa armadilha reproduzível de propósito — dá para ver o vazamento acontecer em trinta segundos: renomeie o .dockerignore, rebuilde, e rode o cat acima. O arquivo aparece na tela.
🔑 Se o .env não entra, de onde vêm os segredos?
Da variável de ambiente, passada na hora de rodar. É a mesma imagem em homologação e em produção; o que muda é o que você injeta:
docker run --rm -p 8080:80 \
-e APP_USUARIO=admin \
-e APP_SENHA=segredo123 \
-e PAINEL_MAQUINA=MaquinaDaLoja \
painel-php
Do lado do PHP, eu leio essas variáveis com uma função que falha alto quando falta alguma:
<?php
/**
* Configuracao — toda via variavel de ambiente.
*
* Nao ha valor de fallback para segredo. Se faltar uma variavel, a aplicacao
* para com erro claro em vez de subir com credencial silenciosa.
*/
/**
* Le uma variavel de ambiente obrigatoria. Aborta se ela nao estiver definida.
*
* getenv() cobre as variaveis que o Docker injeta; $_ENV e $_SERVER cobrem os
* casos em que so eles estao populados, que e o que acontece sob alguns
* SAPIs do Apache.
*/
function env_obrigatoria($nome) {
$valor = getenv($nome);
if ($valor === false || $valor === '') {
$valor = isset($_ENV[$nome]) ? $_ENV[$nome] : (isset($_SERVER[$nome]) ? $_SERVER[$nome] : '');
}
if ($valor === '') {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
exit("Erro de configuracao: variavel de ambiente '$nome' nao definida.");
}
return $valor;
}
define('USUARIO', env_obrigatoria('APP_USUARIO'));
define('SENHA', env_obrigatoria('APP_SENHA'));
define('PAINEL_MAQUINA', env_obrigatoria('PAINEL_MAQUINA'));
O detalhe que importa aqui é a ausência de valor padrão. Eu já escrevi esse tipo de função com um ?: 'admin' no fim, para "não quebrar" — e é justamente isso que quebra, mais tarde e pior: o site sobe achando que está configurado, com uma senha que eu deixei no código meses atrás. Melhor a tela branca com o erro explícito no primeiro acesso.
Repare também que ele consulta três lugares: getenv(), $_ENV e $_SERVER. Parece exagero, mas não é — dependendo de como o Apache foi compilado e do que está no php.ini, a variável aparece num e não no outro. Consultar os três é o que faz a mesma função servir no php -S e dentro do container.
⚡ A ordem das linhas decide o tempo do build
Esta é a parte que mais mudou o meu dia a dia. O Docker guarda cache por camada, e a regra é simples: se a camada mudou, todas as de baixo são refeitas.
Quer dizer que o que muda pouco vai em cima, o que muda toda hora vai embaixo. No meu arquivo, o RUN das extensões vem antes do COPY do código — de propósito. Editar o index.php refaz só o COPY, que leva um segundo. Se estivessem invertidos, cada vírgula no PHP recompilaria a extensão cURL. ⏳
No app em Node.js do repositório eu levei essa ideia ao extremo, copiando o package.json sozinho antes do resto:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ... só depois é que o código entra
COPY --chown=node:node . .
Enquanto o package.json não mudar, o npm ci inteiro vem do cache — mesmo que você tenha reescrito o projeto todo. É o mesmo truque do composer.json num projeto com Composer, e vale para qualquer linguagem que tenha um arquivo de dependências separado do código.
🚪 O CMD que não está lá
Repare que o meu Dockerfile do PHP não tem CMD. E isso é de propósito.
A imagem php:8.2-apache já define o dela: apache2-foreground. Esse "foreground" é o ponto — o Apache normalmente sobe como serviço em segundo plano, e num container isso é fatal. O container vive enquanto o processo principal viver; se o Apache vai para o segundo plano, o processo principal termina, e o container morre logo depois de subir, sem erro nenhum no log.
É o clássico "meu container sobe e cai na hora". Quase sempre é isso: alguém sobrescreveu o CMD com algo que não segura o processo em primeiro plano.
Quando você precisa escrever um CMD — como no app Node — vale a outra regra:
CMD ["node", "servidor.js"]
Essa é a forma exec (lista JSON). A alternativa, CMD node servidor.js, é a forma shell — e nela o seu processo vira filho de um /bin/sh. Quando você dá docker stop, o SIGTERM chega ao shell, que não repassa nada; o Node nunca fica sabendo, e o container só morre no SIGKILL dez segundos depois.
Com a forma exec, o sinal chega direto e dá para encerrar com educação:
process.on("SIGTERM", () => {
console.log("SIGTERM recebido, encerrando…");
servidor.close(() => process.exit(0));
});
🏗️ Multi-estágio: o que instala não precisa ir junto
Um detalhe que vale para qualquer imagem: quem constrói não precisa viajar com quem executa.
Se você usa Composer, o binário do Composer não tem por que ficar na imagem final. Se usa npm para gerar assets, o npm também não. O Dockerfile do app Node no repositório mostra o formato:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node . .
USER node
São duas imagens sendo construídas, e só a segunda é entregue. O COPY --from=deps vai lá no primeiro estágio e traz só a pasta pronta — o cache do npm, os compiladores e o resto do lixo ficam para trás.
Num projeto PHP com Composer, o mesmo desenho fica assim:
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist
FROM php:8.2-apache
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html/
E repare no USER node lá em cima. Por padrão o container roda como root, o que é mais permissão do que um servidor web precisa. A imagem do Node já traz um usuário node pronto; do lado do PHP, o Apache já cai para o www-data sozinho depois de subir — por isso o chown -R www-data:www-data, para que ele consiga escrever quando precisar.
🧪 O que eu testei — e o que eu não pude testar
Aqui preciso ser honesta com você. 🙏
Os dois sites rodaram de verdade, fora do Docker, e eu conferi o comportamento todo: login com senha errada e com a certa, sessão por cookie, rota protegida devolvendo 401 sem login, e o encerramento limpo no SIGTERM. O painel em PHP respondeu isto com as variáveis de ambiente injetadas na linha de comando:
Máquina MaquinaDaLoja
Versão do PHP 7.4.33
Extensão cURL carregada
Servida por cli-server
Aquele cli-server na última linha é o php -S respondendo. Dentro do container, essa mesma linha diz apache2handler — e é exatamente por isso que ela está no painel: é a prova visível de que quem está servindo a página mudou.
Agora a parte que eu não pude fazer: não havia Docker instalado na máquina onde escrevi este artigo, então os docker build não foram executados. Os dois Dockerfile seguem a imagem oficial e o formato documentado, e o do PHP é derivado de um que roda em produção — mas eu não vou dizer que testei o que não testei. Rode o build no seu ambiente antes de confiar.
Prefiro escrever isso e você conferir, do que colar aqui uma saída de terminal bonita que eu inventei. 🙂
📦 O repositório
Está tudo lá: o site em PHP com o Dockerfile deste artigo, o mesmo painel reescrito em Node.js com o Dockerfile multi-estágio, e os dois .dockerignore comentados. O README traz o passo a passo para reproduzir o vazamento do .env de propósito e ver o arquivo aparecer na tela.
No fim, o que o Docker me deu não foi velocidade nem elegância: foi parar de descobrir que faltava uma extensão só depois de subir para o servidor do cliente. A extensão agora falta — ou não falta — na minha máquina, quatro minutos depois de eu escrever a linha. 😌
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
PHP: faturando compras no BOnline (Portugal)
Como emitir uma fatura em Portugal pela API do BOnline com PHP: NIF, IVA por linha, o total ao cêntimo e o documento que não pode ser reenviado.
PHP: faturando compras no Igest, em Portugal
Como emitir uma fatura em PHP pelo webservice SOAP do Igest: o IVA por linha, o total ao cêntimo, o NIF limpo e a fatura que não se reenvia.
Máscara e validação de CNPJ alfanumérico
Como mascarar um input e validar o CNPJ alfanumérico que passa a valer em julho de 2026, em JavaScript e PHP, com funções prontas para copiar.