Pular para o conteúdo
PHP

Criando um Dockerfile para um site em PHP

Paloma Macetko
Ilustração colorida de uma baleia mágica carregando caixotes luminosos com símbolos de código, um unicórnio de crina brilhante ao lado com uma prancheta de camadas, e um castelo com velas flutuantes ao fundo

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.

Exemplos_DockerfilePHP no GitHubO mesmo painel escrito duas vezes — em PHP com Apache e em Node.js — cada um com o seu Dockerfile, para comparar lado a lado.github.com

🧱 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 pelo apt-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

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.

Exemplos_DockerfilePHP no GitHubOs dois projetos completos, prontos para clonar e buildar — o app Node não tem nenhuma dependência externa.github.com

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