Pular para o conteúdo
Fernando Valler

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.

Como publicar uma aplicação PHP com Docker, Traefik e HTTPS automático

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:

text
                         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:

yaml
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:

text
Servidor
├── :8080 → aplicação A
├── :8081 → aplicação B
├── :8082 → aplicação C
└── :8083 → aplicação D

Com Traefik, podemos trabalhar com domínios:

text
app1.exemplo.com.br
app2.exemplo.com.br
app3.exemplo.com.br

Todos chegam nas portas padrão:

text
80
443

E 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:

bash
docker network create proxy

Podemos verificar:

bash
docker network ls

A 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:

text
meu-projeto/
├── docker/
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php/
│       └── Dockerfile
├── public/
│   └── index.php
├── src/
├── composer.json
└── compose.yml

A aplicação terá dois containers:

text
nginx
php-fpm

O Nginx recebe as requisições HTTP e encaminha os arquivos PHP para o PHP-FPM.

Dockerfile do PHP

Um exemplo simples:

dockerfile
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:

dockerfile
FROM nginx:alpine

COPY default.conf /etc/nginx/conf.d/default.conf

WORKDIR /var/www/html

COPY public /var/www/html/public

O Nginx terá apenas a responsabilidade de servir a aplicação HTTP.

Configuração do Nginx

Um exemplo:

nginx
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:

nginx
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:

yaml
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: true

Existe um detalhe importante aqui:

yaml
ports:

não está definido.

Isso é proposital.

A aplicação não precisa publicar:

text
8080:80

ou:

text
80:80

no host.

Ela ficará acessível através do Traefik.

O que as labels estão fazendo?

A primeira:

yaml
- "traefik.enable=true"

informa que o container deve ser considerado pelo Traefik.

Depois:

yaml
- "traefik.http.routers.phpapp.rule=Host(`app.exemplo.com.br`)"

define quando essa rota deve ser utilizada.

Uma requisição para:

text
https://app.exemplo.com.br

será direcionada para esse router.

A entrada:

yaml
- "traefik.http.routers.phpapp.entrypoints=websecure"

determina que a rota utiliza o entrypoint HTTPS.

Então:

yaml
- "traefik.http.routers.phpapp.tls=true"

habilita TLS.

E:

yaml
- "traefik.http.routers.phpapp.tls.certresolver=letsencrypt"

indica qual resolver será usado para obter o certificado.

Por fim:

yaml
- "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:

yaml
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: true

Em 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:

text
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:

bash
mkdir -p letsencrypt

touch letsencrypt/acme.json

chmod 600 letsencrypt/acme.json

Nã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:

text
app.exemplo.com.br
        │
        ▼
     servidor
        │
        ▼
     Traefik
        │
        ▼
     aplicação

Com 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:

bash
dig +short app.exemplo.com.br

ou:

bash
nslookup app.exemplo.com.br

O endereço retornado deve apontar para o servidor correto.

Subindo o Traefik

Depois:

bash
docker compose up -d

Podemos verificar:

bash
docker compose ps

E:

bash
docker logs traefik

Subindo a aplicação

No projeto PHP:

bash
docker compose up -d --build

Depois:

bash
docker compose ps

Podemos verificar se os containers estão executando:

text
php
nginx

O Nginx deve estar conectado às duas redes:

text
internal
proxy

Enquanto o PHP fica apenas em:

text
internal

Essa separação é importante.

O PHP-FPM não precisa ser acessível diretamente pelo Traefik.

Testando o roteamento

Primeiro podemos testar o DNS:

bash
curl -I http://app.exemplo.com.br

Como configuramos o redirecionamento, esperamos algo semelhante a:

text
HTTP/1.1 301
Location: https://app.exemplo.com.br/

Depois:

bash
curl -I https://app.exemplo.com.br

A 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:

text
Cliente
   │
   │ HTTPS
   ▼
Traefik
   │
   │ HTTP
   ▼
Nginx
   │
   │ FastCGI
   ▼
PHP-FPM

Isso 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:

text
app1.exemplo.com.br
app2.exemplo.com.br
api.exemplo.com.br

Não precisamos criar três servidores web independentes.

Cada aplicação pode declarar suas próprias labels:

yaml
- "traefik.http.routers.app1.rule=Host(`app1.exemplo.com.br`)"
yaml
- "traefik.http.routers.app2.rule=Host(`app2.exemplo.com.br`)"
yaml
- "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:

yaml
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:

yaml
image: traefik:v3.x

em vez de:

yaml
image: traefik:latest

A 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:

dockerfile
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:

yaml
volumes:
  - "./letsencrypt:/letsencrypt"

não é um detalhe opcional.

E o banco de dados?

O banco pode ficar em uma rede diferente.

Por exemplo:

text
proxy
  │
  └── nginx

internal
  ├── nginx
  ├── php
  └── database

O 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

bash
dig +short app.exemplo.com.br

2. Traefik

bash
docker logs traefik

3. Containers

bash
docker compose ps

4. Redes

bash
docker network inspect proxy

5. Nginx

Verifique os logs:

bash
docker compose logs nginx

6. PHP-FPM

bash
docker compose logs php

7. 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:

text
[ ] 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 controladas

Conclusã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.

Referências