Como publicar uma aplicação PHP com Docker, Traefik e HTTPS automático
Um guia prático para colocar uma aplicação PHP em produção usando Docker, Traefik, HTTPS automático com Let's Encrypt e uma arquitetura preparada para múltiplas aplicações.

Publicar uma aplicação PHP em produção não deveria significar abrir várias portas no servidor, configurar certificados manualmente e criar uma configuração diferente para cada novo projeto.
Uma arquitetura usando Docker + Traefik + HTTPS automático resolve boa parte desse trabalho e cria uma estrutura que pode ser reutilizada por várias aplicações no mesmo servidor.
Neste artigo, vou mostrar uma abordagem prática para publicar uma aplicação PHP utilizando:
- Docker Compose
- PHP-FPM
- Nginx
- Traefik
- Let’s Encrypt
- HTTPS automático
- Redes Docker
- Deploy sem expor diretamente as portas da aplicação
A ideia não é apenas fazer a aplicação funcionar. É criar uma estrutura que continue simples quando o servidor passar a hospedar dois, cinco ou dez projetos.
A arquitetura
A arquitetura que vamos montar pode ser representada assim:
Internet
│
│ HTTPS :443
│ HTTP :80
▼
┌─────────────────┐
│ Traefik │
│ │
│ Reverse Proxy │
│ TLS / Let's │
│ Encrypt │
└────────┬────────┘
│
Docker network: proxy
│
▼
┌─────────────────┐
│ Nginx │
│ │
│ HTTP :80 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ PHP-FPM │
│ │
│ PHP 8.x │
└─────────────────┘O ponto mais importante dessa arquitetura é que a aplicação PHP não precisa publicar uma porta no host.
Por exemplo, não precisamos fazer:
ports:
- "8080:80"A porta 80 do Nginx pode ficar disponível apenas dentro da rede Docker.
O Traefik consegue encontrar o container através da rede Docker e encaminhar as requisições para ele.
Por que colocar o Traefik na frente?
Sem um reverse proxy, cada aplicação poderia precisar de uma porta diferente:
Servidor
├── :8080 → aplicação A
├── :8081 → aplicação B
├── :8082 → aplicação C
└── :8083 → aplicação DCom Traefik, podemos trabalhar com domínios:
app1.exemplo.com.br
app2.exemplo.com.br
app3.exemplo.com.brTodos chegam nas portas padrão:
80
443E o Traefik decide para qual container a requisição deve ser encaminhada.
Isso também permite centralizar algumas responsabilidades:
- HTTPS
- redirecionamento HTTP → HTTPS
- roteamento por domínio
- certificados
- logs de acesso
- regras de middleware
- integração com outros serviços Docker
O provider Docker do Traefik permite que essas configurações sejam declaradas através das labels dos containers. A documentação oficial também recomenda desabilitar a exposição automática e declarar explicitamente quais containers devem ser publicados.
Criando a rede compartilhada
Quando o Traefik é utilizado para várias aplicações, gosto de mantê-lo como infraestrutura compartilhada.
Criamos uma rede Docker externa:
docker network create proxyPodemos verificar:
docker network lsA rede poderá ser utilizada tanto pelo Traefik quanto pelas aplicações.
O Docker Compose permite definir redes explicitamente e conectar serviços apenas às redes necessárias, o que ajuda a separar componentes da aplicação.
Estrutura do projeto
Um projeto PHP simples pode ter uma estrutura semelhante a esta:
meu-projeto/
├── docker/
│ ├── nginx/
│ │ ├── Dockerfile
│ │ └── default.conf
│ └── php/
│ └── Dockerfile
├── public/
│ └── index.php
├── src/
├── composer.json
└── compose.ymlA aplicação terá dois containers:
nginx
php-fpmO Nginx recebe as requisições HTTP e encaminha os arquivos PHP para o PHP-FPM.
Dockerfile do PHP
Um exemplo simples:
FROM php:8.4-fpm-alpine
WORKDIR /var/www/html
RUN docker-php-ext-install pdo pdo_mysql
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader
COPY . .
RUN chown -R www-data:www-data /var/www/html
USER www-data
CMD ["php-fpm"]Em uma aplicação real, a lista de extensões deve ser adaptada ao projeto.
Uma aplicação Symfony, por exemplo, pode precisar de extensões adicionais dependendo do banco de dados, cache ou outras bibliotecas utilizadas.
Também é importante evitar colocar secrets diretamente no Dockerfile.
Dockerfile do Nginx
Podemos usar:
FROM nginx:alpine
COPY default.conf /etc/nginx/conf.d/default.conf
WORKDIR /var/www/html
COPY public /var/www/html/publicO Nginx terá apenas a responsabilidade de servir a aplicação HTTP.
Configuração do Nginx
Um exemplo:
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
}
location ~ /\. {
deny all;
}
}Observe que:
fastcgi_pass php:9000;não aponta para localhost.
O hostname php é o nome do serviço Docker.
Dentro da rede Docker, o Compose fornece resolução de nomes entre os serviços.
Compose da aplicação
Agora podemos juntar tudo:
services:
php:
build:
context: .
dockerfile: docker/php/Dockerfile
restart: unless-stopped
networks:
- internal
nginx:
build:
context: .
dockerfile: docker/nginx/Dockerfile
restart: unless-stopped
depends_on:
- php
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.phpapp.rule=Host(`app.exemplo.com.br`)"
- "traefik.http.routers.phpapp.entrypoints=websecure"
- "traefik.http.routers.phpapp.tls=true"
- "traefik.http.routers.phpapp.tls.certresolver=letsencrypt"
- "traefik.http.services.phpapp.loadbalancer.server.port=80"
networks:
internal:
driver: bridge
proxy:
external: trueExiste um detalhe importante aqui:
ports:não está definido.
Isso é proposital.
A aplicação não precisa publicar:
8080:80ou:
80:80no host.
Ela ficará acessível através do Traefik.
O que as labels estão fazendo?
A primeira:
- "traefik.enable=true"informa que o container deve ser considerado pelo Traefik.
Depois:
- "traefik.http.routers.phpapp.rule=Host(`app.exemplo.com.br`)"define quando essa rota deve ser utilizada.
Uma requisição para:
https://app.exemplo.com.brserá direcionada para esse router.
A entrada:
- "traefik.http.routers.phpapp.entrypoints=websecure"determina que a rota utiliza o entrypoint HTTPS.
Então:
- "traefik.http.routers.phpapp.tls=true"habilita TLS.
E:
- "traefik.http.routers.phpapp.tls.certresolver=letsencrypt"indica qual resolver será usado para obter o certificado.
Por fim:
- "traefik.http.services.phpapp.loadbalancer.server.port=80"informa ao Traefik que o serviço HTTP dentro do container está na porta 80.
Configurando o Traefik
O Traefik pode ser executado em um projeto separado.
Um exemplo simplificado:
services:
traefik:
image: traefik:v3
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.email=admin@exemplo.com.br"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
networks:
- proxy
networks:
proxy:
external: trueEm produção, o Traefik precisa conseguir observar o Docker para descobrir os containers e suas labels. A documentação oficial também apresenta a configuração dos entrypoints 80/443, do redirect HTTP → HTTPS e do resolver ACME para Let’s Encrypt.
O arquivo acme.json
O arquivo:
letsencrypt/acme.jsoné utilizado pelo Traefik para armazenar os dados do ACME e os certificados obtidos.
Antes de iniciar o Traefik, podemos preparar o arquivo:
mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.jsonNão devemos tratar esse arquivo como algo descartável.
Se o Traefik perder seu armazenamento ACME, poderá precisar negociar novamente os certificados.
Também é importante considerar esse diretório no backup da infraestrutura.
DNS
Antes de testar o HTTPS, o domínio precisa apontar para o servidor.
Por exemplo:
app.exemplo.com.br
│
▼
servidor
│
▼
Traefik
│
▼
aplicaçãoCom o desafio HTTP do Let’s Encrypt, a infraestrutura precisa estar publicamente acessível nas portas necessárias e o domínio precisa apontar para o servidor que está executando o Traefik.
Então precisamos verificar:
dig +short app.exemplo.com.brou:
nslookup app.exemplo.com.brO endereço retornado deve apontar para o servidor correto.
Subindo o Traefik
Depois:
docker compose up -dPodemos verificar:
docker compose psE:
docker logs traefikSubindo a aplicação
No projeto PHP:
docker compose up -d --buildDepois:
docker compose psPodemos verificar se os containers estão executando:
php
nginxO Nginx deve estar conectado às duas redes:
internal
proxyEnquanto o PHP fica apenas em:
internalEssa separação é importante.
O PHP-FPM não precisa ser acessível diretamente pelo Traefik.
Testando o roteamento
Primeiro podemos testar o DNS:
curl -I http://app.exemplo.com.brComo configuramos o redirecionamento, esperamos algo semelhante a:
HTTP/1.1 301
Location: https://app.exemplo.com.br/Depois:
curl -I https://app.exemplo.com.brA requisição deverá chegar ao Nginx através do Traefik.
Onde termina o HTTPS?
Uma característica importante dessa arquitetura é que o TLS termina no Traefik.
O fluxo fica:
Cliente
│
│ HTTPS
▼
Traefik
│
│ HTTP
▼
Nginx
│
│ FastCGI
▼
PHP-FPMIsso significa que o Nginx e o PHP não precisam gerenciar os certificados públicos.
Essa centralização simplifica muito a infraestrutura quando existem várias aplicações.
Adicionando outra aplicação
Imagine que o servidor tenha:
app1.exemplo.com.br
app2.exemplo.com.br
api.exemplo.com.brNão precisamos criar três servidores web independentes.
Cada aplicação pode declarar suas próprias labels:
- "traefik.http.routers.app1.rule=Host(`app1.exemplo.com.br`)"- "traefik.http.routers.app2.rule=Host(`app2.exemplo.com.br`)"- "traefik.http.routers.api.rule=Host(`api.exemplo.com.br`)"O Traefik fica responsável por decidir para onde cada requisição deve ir.
Essa é uma das maiores vantagens da abordagem.
O que eu evitaria em produção
Existem algumas decisões aparentemente convenientes que podem criar problemas depois.
Publicar portas desnecessariamente
Evite:
ports:
- "8080:80"quando a aplicação só precisa ser acessada pelo Traefik.
Quanto menos portas expostas no host, menor a superfície de ataque.
Expor o dashboard do Traefik diretamente
Evite configurações que deixem o dashboard administrativo aberto na Internet sem autenticação.
O dashboard pode ser publicado através de uma rota protegida, autenticação e, dependendo do cenário, acesso restrito à rede administrativa.
Usar latest
Prefira versões explícitas:
image: traefik:v3.xem vez de:
image: traefik:latestA mesma ideia vale para as imagens da aplicação.
Um deploy reproduzível depende de saber exatamente qual versão está sendo executada.
Colocar secrets no Dockerfile
Evite:
ENV DATABASE_PASSWORD="minha-senha"Credenciais, tokens e chaves de API não deveriam fazer parte da imagem.
Esses valores devem ser injetados durante o deploy utilizando variáveis de ambiente, secrets ou outro mecanismo apropriado.
Não persistir o armazenamento ACME
O armazenamento do Let’s Encrypt precisa sobreviver à recriação do container do Traefik.
Por isso:
volumes:
- "./letsencrypt:/letsencrypt"não é um detalhe opcional.
E o banco de dados?
O banco pode ficar em uma rede diferente.
Por exemplo:
proxy
│
└── nginx
internal
├── nginx
├── php
└── databaseO Traefik só precisa conhecer o Nginx.
O PHP pode conversar com o banco.
Mas o banco não precisa estar conectado à rede proxy.
Essa separação reduz a exposição desnecessária dos serviços internos.
O próprio Docker Compose permite criar redes distintas para controlar quais serviços conseguem se comunicar entre si.
E quando algo dá errado?
A principal vantagem de uma arquitetura como essa também pode ser sua maior fonte de confusão: existem várias camadas.
Quando a aplicação retorna 404, 502 ou 504, vale testar cada camada individualmente.
1. DNS
dig +short app.exemplo.com.br2. Traefik
docker logs traefik3. Containers
docker compose ps4. Redes
docker network inspect proxy5. Nginx
Verifique os logs:
docker compose logs nginx6. PHP-FPM
docker compose logs php7. Aplicação
Por fim, investigue os logs da própria aplicação.
Uma falha de conexão entre PHP e banco, por exemplo, não será corrigida alterando o router do Traefik.
Separar o diagnóstico por camada economiza muito tempo.
Checklist de produção
Antes de considerar o deploy concluído, eu verificaria:
[ ] DNS apontando para o servidor
[ ] Portas 80 e 443 disponíveis
[ ] Traefik executando
[ ] Rede proxy criada
[ ] Container publicado explicitamente pelo Traefik
[ ] Aplicação sem porta pública desnecessária
[ ] HTTP redirecionando para HTTPS
[ ] Certificado Let's Encrypt emitido
[ ] acme.json persistente
[ ] Dashboard administrativo protegido
[ ] Secrets fora da imagem
[ ] Containers com restart policy
[ ] Logs funcionando
[ ] Healthchecks configurados
[ ] Backup dos dados persistentes
[ ] Imagens com versões controladasConclusão
Docker resolve o empacotamento da aplicação.
Nginx resolve a camada HTTP da aplicação PHP.
PHP-FPM executa o código.
Traefik resolve o roteamento e a camada de entrada.
Let’s Encrypt fornece os certificados.
O resultado é uma arquitetura relativamente simples, mas que escala muito melhor do que ficar abrindo portas diferentes e configurando manualmente um servidor web para cada projeto.
O mais interessante é que, depois que o Traefik está funcionando, publicar uma nova aplicação passa a ser praticamente uma questão de adicionar um novo container, conectá-lo à rede proxy e declarar suas regras através das labels.
Essa é uma das características que tornam Docker + Traefik uma combinação interessante para servidores que hospedam vários projetos PHP.