HTTP: Proxy-Reverso descomplicado

Saudações. Nesse artigo vamos explorar um item vital para a Internet: um componente HTTP chamado proxy-reverso, suas implementações, principais softwares e exemplos práticos.

Abordaremos os softwares: Nginx, Nginx Proxy Manager, Lighttpd, Traefik, Caddy e Squid.

Pre-requisitos pra praticar os exemplos:

  • Servidor, VM ou VPS com Linux Debian 12 e Docker;
  • O Linux deve possuir IP público (IPv4) e IPv6 global;
  • Ter pelo menos 1 domínio (DNS) para os testes de URLs (vou usar exemplo.com.br e você muda para o seu);
  • Comando “curl” para os testes;

Instale os programas básicos que vamos usar:

Bash
# Instalar utilitarios de sistema
apt-get -y install psmisc
apt-get -y install iproute2
apt-get -y install net-tools

# Instalar linguagens utilizadas nos exemplos
apt-get -y install python3

# Instalar programas básicos de HTTP
apt-get -y install curl
apt-get -y install wget
apt-get -y install lighttpd
apt-get -y install nginx
apt-get -y install apache2-tools

# Parar sistemas que se auto-iniciaram nas instalacoes acima:
systemctl stop nginx
systemctl disable nginx
systemctl stop lighttpd
systemctl disable lighttpd;

1 – O básico sobre o protocolo HTTP

O protocolo HTTP é de longe o protocolo mais simples e mais usado na Internet. Ele foi concebido para ser uma troca simples de arquivos e acabou dominando a forma de usar a Internet.

O HTTP é uma arquitetura cliente-servidor. O servidor é um software passivo, ele apenas entra em operação quando o cliente envia uma requisição para ele. As requisições e respostas seguem o mesmo formato de texto: tipo (primeira linha) + cabeçalhos (1 por linha) + linha em branco + conteúdo (opcional).

Regras básicas de comunicação entre o cliente e o servidor:

  • A primeira linha da requisição deve ser o método, lista de todos os métodos: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE;
  • A primeira linha da resposta deve ser o protocolo em uso e o código de retorno;
  • As linhas seguintes devem ser pares: nome do parâmetro de cabeçalho, “:” (dois-pontos) e o valor;
  • Cada linha do cabeçalho deve ser finalizada com uma quebra de linha composta pelos caracteres CR+LR (CR = “\r” = Carriage Return, código decimal 13 da tabela ASCII, LF = “\n” = Line Feed código decimal 10 da tabela ASCII);
  • Quando os parâmetros de cabeçalho se encerrarem, uma linha em branco deve ser inserida, ou seja, CR+LF do cabeçalho anterior e CR+LF de uma linha vazia, assim a combinação CR+LF+CR+LF marca o fim do cabeçalho.
  • Apos a linha em branco, opcionalmente, pode ser enviado mais bytes, esse é o conteúdo anexado na requisição (upload ou envio de formulário) ou, na resposta (o documento solicitado);

Exemplo do requisição:

Bash
# Requisicao ao google:
curl -v https://www.google.com/robots.txt

# Retorno do comando acima:
    # Estabelecendo conexao TCP...
    # Estabelecendo canal TLS...
    # Requesicao enviada ao servidor:
    GET /robots.txt HTTP/2
    Host: www.google.com
    User-Agent: curl/8.7.1
    Accept: */*
    (linha em branco, fim da mensagem)

    # Resposta do servidor ao cliente:
    HTTP/2 200
    accept-ranges: bytes
    vary: Accept-Encoding
    content-type: text/plain
    content-length: 9340
    date: Thu, 23 Jan 2025 14:52:05 GMT
    expires: Thu, 23 Jan 2025 14:52:05 GMT
    cache-control: private, max-age=0
    last-modified: Thu, 09 Jan 2025 17:00:00 GMT
    server: sffe
    (linha em branco)
    [texto da resposta, muito longo pra colar aqui!]

A resposta segue o mesmo padrão da requisição, no entanto, a primeira linha informa o protocolo (HTTP) e versão, seguido de um espaço e o código de retorno. Códigos:

  • 1xx – resposta informativa (não é usada)
  • 2xx – resposta conclusiva – o que você pediu foi atendido
  • 3xx – resposta dispersa – você deve ir a outro lugar (nova URL)
  • 4xx – resposta negativa – o pedido não pode ser atendido, arquivo não existe ou restrições foram aplicadas para impedir o envio;
  • 5xx – resposta de pane – alguma coisa deu errado e o servidor HTTP não consegue processar até o final seu pedido (falha na cadeia de softwares no lado servidor);

Viu como é simples? Os outros detalhes são corriqueiros e simples de aprender.

Vou apresentar os cabeçalhos mais comuns, marcando com [S] os cabeçalhos enviados do servidor para o cliente, [R] nos cabeçalhos que constam na requisição, e [*] para cabeçalhos usados em ambas as operações;

  • [R] GET, POST, DELETE, HEAD: o tipo de pedido que o cliente deseja fazer, é a primeira linha da requisição e não é separado por “:”, é seguido do PATH (caminho, exemplo: /chat, /clientes/lista, /client/info?id=1234). Se o caracter “?” estiver presente no PATH, tudo depois do “?” se chama “query” (exemplo: ?id=1234) e costuma ser usado para orientar os detalhes do pedido quando o cliente não deseja submeter isso no corpo;
    • GET: o cliente deseja obter um conteúdo;
    • POST: o cliente deseja submeter uma informação que irá determinar como o servidor irá responder, usado para criar ou atualizar o lado servidor;
    • HEAD: semelhante ao método GET, mas visa obter apenas o cabeçalho da resposta, o servidor não deverá enviar conteúdo;
    • DELETE: o cliente deseja remover algum arquivo ou objeto do servidor;
  • [S] Server: o servidor HTTP informa ao cliente qual software está processando a requisição (normalmente é o nome do próprio software servidor HTTP);
  • [R] User-Agent: nome do software utilizado pelo cliente (nome do navegador ou APP);
  • [R] Host: nome de DNS do site desejado, ou IP do site desejado (ex.: google.com, 200.160.2.3);
  • [*] – Content-Type: tipo de conteúdo que está sendo negociado. O cliente pode pedir um tipo, mas o servidor pode mandar outro. Tipos mais comuns:
    • text/html – páginas HTML;
    • text/plain – texto puro;
    • text/css – texto com código CSS de personalização das páginas HTML;
    • text/csv – texto de planilhas CSV;
    • text/javascript – texto com código javascript (software da página);
    • image/jpeg – Imagem;
    • image/png – Imagem;
    • application/json – texto de dados estruturados JSON;
    • application/octet-stream – tipo indefinido ou binário detectado (download);
  • [*] Content-Length: quando presente, informa qual o tamanho do documento presente no corpo da requisição ou no corpo da resposta. Costuma ser omitido mesmo quando há conteúdo presente;
  • [S] Date: data universal de criação do conteúdo;
  • [S] Expires: informa a data universal limite de vida do conteúdo, é usado pelo navegador para saber por quanto tempo o arquivo/objeto pode ser mantido no cache do cliente;
  • [S] Cache-Control: configurações para que o objeto seja armazenado em cache;
  • [S] Location: nova URL a ser usada (redirecionamento), acontece em respostas com código 3xx pois o servidor moveu o conteúdo para outra URL;

Quando você abre um navegador para acessar um site (https://google.com por exemplo), seu navegador envia um “Get /, Host google.com” e recebe “HTTP/2 200, Content-Type: text/html” seguido do texto HTML da página inicial do Google.

Partes de uma URL:

Se a porta (“port”) não for informada, o software cliente presumirá:

  • Para o esquema (“scheme”) http, porta 80;
  • Para o esquema (“scheme”) https, porta 443;

2 – O servidor HTTP

O servidor HTTP é o software que será executado abrindo alguma porta TCP para escutar os pedidos (requisições) dos clientes, normalmente a porta 80 (HTTP) ou 443 (HTTP+TLS).

Antigamente haviam poucos softwares para isso, dada a simplicidade da função, o Apache dominou por muito tempo.

Eu particularmente gosto muito do Lighttpd e do Nginx para servidores simples e pontuais.

A função do servidor HTTP é mapear o site “www.seusite.com” (DNS apontando para o IP, exemplo: IPv4=45.255.128.2 e IPv6=2804:CAF9:1234::2) em uma pasta do servidor, por exemplo, /var/www/htdocs/.

Ao enviar um pedido “GET /arquivo.txt“, o servidor vai no caminho /var/www/htdocs/arquivo.txt, lê o arquivo, coloca ele no corpo da mensagem que será iniciada assim “HTTP/1 200“. Se o arquivo não existisse, a resposta seria “HTTP/1 404” sem nada no corpo da resposta.

Praticando o exemplo acima:

Bash
# Diretorio de base:
mkdir -p /var/www/htdocs

# Arquivos de exemplos:
echo     "O rato roeu a roupa do rei de roma"  >  /var/www/htdocs/arquivo.txt
echo     '{ "name": "Patolino", age: "6" }'    >  /var/www/htdocs/data1.json
echo -n  "1"                                   >  /var/www/htdocs/1byte.txt

# Configuracao minima do lighttpd:
(
    echo 'server.port = 808'
    echo 'server.document-root = "/var/www/htdocs"'
) > /etc/lighttpd/test.conf

# Rodar o servidor HTTP com lighttpd para servidor arquivos em uma pasta:
killall lighttpd
lighttpd -f /etc/lighttpd/test.conf


# Testando funcionamento (127.0.0.1 é o ip de loopback, se chama localhost)
curl -v http://localhost:808/arquivo.txt
    #*   Trying 127.0.0.1:80...
    #* Connected to localhost (127.0.0.1) port 80 (#0)

    # Requisicao:
    #> GET /arquivo.txt HTTP/1.1
    #> Host: localhost
    #> User-Agent: curl/7.88.1
    #> Accept: */*
    #> 

    # Resposta:
    #< HTTP/1.1 200 OK
    #< Content-Type: application/octet-stream
    #< Content-Length: 35
    #< Accept-Ranges: bytes
    #< Date: Fri, 24 Jan 2025 15:44:03 GMT
    #< Server: lighttpd/1.4.69
    #< 
    #O rato roeu a roupa do rei de roma

    #* Connection #0 to host localhost left intact

# Parar o lighttpd de teste:
killall lighttpd

O servidor HTTP faz a deteção automática do tipo do arquivo, e responde o tipo MIME no cabeçalho Content-Type, há um tipo padrão informado na configuração e há o arquivo mime com a extensão do arquivo e o tipo (.txt = text/plain, .html = text/html, .css = text/css, …). Na ausência do arquivo MIME o tipo padrão costuma ser “text/html” ou “application/octet-stream”.

Scripts no lado servidor: CGI e FastCGI

Uma tecnologia vital foi a implementação do CGI e FastCGI. Esse recurso permite ao servidor HTTP vincular um caminho da URL a um programa que será executado para produzir o conteúdo. O conteúdo é produzido em tempo real de acordo com os detalhes do pedido, e assim surgiu o PHP e a Web 2.0 (sites de conteúdo dinâmico e interativo).

Observe esta configuração do Lighttpd:

server.port = 810
server.document-root = "/var/www/htdocs"

fastcgi.server += ( ".php" =>
	((
		"socket" => "/run/php/php-fpm.sock",
		"broken-scriptfilename" => "enable"
	))
)

Ela informa que toda URL terminada com “.php” deve ser enviada ao SOCKET UNIX do PHP (php-fpm). O php-fpm recebe uma mensagem do servidor HTTP contendo o arquivo solicitado e as variáveis HTTP (Query-String), executará o script PHP, e o retorno do script é devolvido ao servidor HTTP para ser servido ao cliente.

Scripts que rodam como HTTP Server

Outra evolução no uso de HTTP foi que as linguagens de programação passaram a implementar a abertura da porta TCP e a interpretarem diretamente a requisição. Python, NodeJS e Ruby são os mais comuns.

Com essa recurso, seu programa escrito em Python pode abrir a porta TCP, receber os pedidos em HTTP, fazer o “roteamento” de cada pedido para uma função interna.

Algoritmo simples para processar HTTP (implementação de API simples, gravar em /root/py-server-api-ex01.py):

  • Abrir a porta TCP 820 como servidor HTTP;
  • Criar as rotas para cada tipo de pedido:
    • Quando for solicitado “Get /”, acionar a função home_page();
    • Um “GET /client/list” aciona a função clients_list();
    • Um “GET /client/123” aciona a função client_get(123);
    • Um “POST /client/123/disable” aciona a função client_disable(123);
Python
from http.server import HTTPServer, BaseHTTPRequestHandler
import re

# Funções para tratar as rotas
def home_page():
    return {"status": "1", "message": "Welcome to the home page!"}

def clients_list():
    return {"status": "1", "clients": ["Client A", "Client B", "Client C"]}

def client_get(client_id):
    return {"status": "1", "id": client_id, "name": f"Client {client_id}"}

def client_disable(client_id):
    return {"status": "1", "id": client_id, "msg": f"Client {client_id} disabled"}

# Manipulador HTTP
class MyRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Rota "/"
        if self.path == "/":
            response = home_page()
        # Rota "/client/list"
        elif self.path == "/client/list":
            response = clients_list()
        # Rota "/client/<id>"
        elif match := re.match(r"^/client/(\d+)$", self.path):
            client_id = int(match.group(1))
            response = client_get(client_id)
        # Rota "/client/<id>/desativar"
        elif match := re.match(r"^/client/(\d+)/disable$", self.path):
            client_id = int(match.group(1))
            response = client_disable(client_id)
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b'{"status": "error", "message": "Not Found"}')
            return

        # Responder com sucesso
        self.send_response(200)
        self.send_header("Content-type", "application/json")
        self.end_headers()
        self.wfile.write(bytes(str(response), "utf-8"))

# Iniciar o servidor HTTP
def run_server(port=820):
    server_address = ("", port)
    httpd = HTTPServer(server_address, MyRequestHandler)
    print(f"Starting server on port {port}...")
    httpd.serve_forever()

if __name__ == "__main__":
    run_server(820)

Rodando script Python e consultando nossa API de teste:

Bash
# Rodar servidor API python:
python3 /root/py-server-api-ex01.py \
    1>/var/log/pysrv-ex01.log \
    2>/var/log/pysrv-ex01.log &

# Chmando API:
curl -v http://localhost:820/client/list
    #*   Trying 127.0.0.1:820...
    #* Connected to localhost (127.0.0.1) port 820 (#0)

    # Requisicao:
    #> GET /client/list HTTP/1.1
    #> Host: localhost:820
    #> User-Agent: curl/7.88.1
    #> Accept: */*
    #> 

    # Resposta:
    #* HTTP 1.0, assume close after body
    #< HTTP/1.0 200 OK
    #< Server: BaseHTTP/0.6 Python/3.11.2
    #< Date: Fri, 24 Jan 2025 16:11:37 GMT
    #< Content-type: application/json
    #< 
    #* Closing connection 0

    # Conteudo retornado:
    #{'status': '1', 'clients': ['Client A', 'Client B', 'Client C']}

Evolução dos ambientes

Algumas coisas aconteceram depois de 2010 que tornaram as coisas mais complexas:

  • A invenção do Docker e os ambientes com container;
  • A migração em massa da internet HTTP para HTTP+SSL/TLS para proteger as informações como padrão de segurança obrigatório;
  • Os navegadores modernos (Firefox, Safari, Google Chrome) passaram a exigir configurações de segurança mais elevadas, forçando HTTPs como padrão;
  • A necessidade de balancear a carga de muitas requisições em vários servidores (load-balance), bem como o monitoramento em tempo real de quais servidores internos estão disponíveis para receber esses encaminhamentos;
  • Os sistemas se tornaram mais heterogêneos, sites passaram a contar com muitas tecnologias agregadas, frameworks, APIs, web-sockets, anti-bots, atendimento diferenciado por geolocalização (GEOIP DNS Split-Horizon);
  • A migração gradual para o IPv6, enquanto mantem o legado IPv4 em produção;
  • As metodologias de teste e publicação de código em produção (atualizar um site em partes sem quebrar o funcionamento do sistema);
  • A migração em massa para HTTP, WebSockets e JSON como método padrão de interoperabilidade;
  • Softwares antigos foram substituídos por softwares mais avançados e flexíveis, como NGINX, Lighttpd, Traefik, Caddy, entre outros, mais focados em recursos de proxy-reverso;
  • A implementação de TLS foi automatizada por meio do protocolo ACME (Automated Certificate Management Environment), o Let’s Encrypt surgiu permitindo que todo site obtesse um certificado assinado e reconhecido mundialmente sem custo;

A lista acima vai crescendo. Fica impraticável manter um software “monólito” que faça isso tudo.

É melhor deixar um software cuidando de todas elas, e seu aplicativo ou software fica apenas com o processamento de dados que serão trocados.

3 – O proxy-reverso

Um bom exemplo para entender a necessidade de um proxy-reverso é seguinte caso:

  • Temos um site de vendas online, ex.: CompreFlix – compreflix.com;
  • Todos os acessos ao diretório (path) /data deve ser responsabilidade do software de CRM;
  • Todos os acessos ao diretório /pay devem ser responsabilidade do software de loja virtual;
  • Todos os acessos a /support deve ser processada pelo sistema de Help-Desk;

O software que abriu a porta TCP (80 e 443) receberá todas as requisições, mas ele deve rotear a requisição (encaminhar de forma transparente) para diferentes sub-sistemas que estão rodando em outros softwares.

Essa é a função do proxy-reverso.

O proxy-reverso pode, e deve, ser responsável por gerir o certificado de criptografia, atender nas portas 80 (HTTP), 443 (HTTPs) e demais portas necessárias para gestão (8080 por exemplo) e enviar as requisições programadas para os servidores internos.

4 – Proxy Reverso em ambiente Docker

Embora seja comum rodar o proxy-reverso direto no ambiente do servidor (HOST), isso tem como efeito colateral a dificuldade de criar os apontamentos quando as aplicações HTTP internas estão em containers Docker. Você precisaria fixar o IP de todas elas para ter uma configuração confiável, e perderia na flexibilidade (escala, balanceamento, etc…).

A opção mais encorajada e comum é rodar o proxy-reverso em Docker, em uma rede (docker network) personalizada (“network_public”, “apps”, “br-ias”, etc..), visto que na rede nativa do docker o mapeamento automático de nomes e containers não funciona.

Cuidado: o Docker redireciona portas por meio de firewall do Linux (camada prerouting do netfilter) e isso faz com que a porta redirecionada no Docker e mapeada para um container vença a porta do software que você rodou no HOST.

No exemplo acima, podemos ter o site online (exemplo.com.br) e cada diretório (/clients, /support) será entregue ao container responsável.

A importância de criar uma “docker network”, acima nomeada como “network_public” para colocar todos os containers juntos do proxy-reverso é o recurso de mapeamento de nomes no DNS dos containers. O proxy pode usar “http://api-clients” que a camada de DNS do container mapeará automaticamente para o IP que foi atribuído ao container com o nome “api-clients”.

Para tornar seus sites, APPs e APIs compatíveis com IPv6, basta que o servidor (HOST) tenha IPv6 global e que você entregue um docker network com IPv6 (privado, local). Dessa forma as conexões de entrada em IPv4 e IPv6 serão entregues ao seu proxy-reverso, mas o proxy reverso pode entregar as requisições HTTP para os containers e servidores internos que não possuem IPv6 (somente IPv4):

Exemplo de criação da rede “network_public” no Docker, IPv4-Only e IPv4+IPv6:

Bash
# Criar rede para APPs atendidos por proxy-reverso

# - No linux, a interface se chamará br-net-public
# - No docker, a rede se chamara network_public
# - o ICC=true ativa a comunicacao entre containers
# - Colocar MTU em 9000 para acelerar a comunicação entre containers
# - As opcoes '-o' podem ser removidas, se desejar

# Somente IPv4:
docker network create \
    -d bridge \
    -o "com.docker.network.bridge.name"="br-net-public" \
    -o "com.docker.network.bridge.enable_icc"="true" \
    -o "com.docker.network.driver.mtu"="9000" \
    --subnet 100.90.0.0/16 \
    network_public


# Dual-Stack - IPv4 + IPv6:
docker network create \
    -d bridge \
    -o "com.docker.network.bridge.name"="br-net-public" \
    -o "com.docker.network.bridge.enable_icc"="true" \
    -o "com.docker.network.driver.mtu"="9000" \
    --subnet 100.90.0.0/16 \
    --ipv6 --subnet=2001:db8:100:90::/64 \
    network_public


#   Comandos para verificar a rede no Linux (apos criar com os comandos acima):
#       ip addr  show dev br-net-public
#       ip neigh show dev br-net-public
#       brctl show br-net-public
#       tcpdump -pnevas0 -i brctl show br-net-public

5 – Nginx Proxy Manager (NPM)

O NPM – Nginx Proxy Manager é um dos melhores. Baseado em NGINX e com plugins que tornam a configuração e a automação muito simples.

Pontos fortes:

  • Interface muito amigável e fácil de operar;
  • Configura automaticamente os certificados TLS usando provedores gratuitos (ACME – Let’s Encrypt);
  • Possui recursos de segurança para restringir acessos a URLs;
  • Agrupa sites em certificados wildcard (certificado *.exemplo.com.br pode ser usado em vários containers, permitindo uso de wildcard de DNS);

Pontos fracos:

  • Difícil de automatizar para que containers criados com “labels” sejam automaticamente mapeados no NPM sem necessidade de criação manual do mapeamento da URL;

Criar container NPM:

Bash
# Criar container NPM na rede network_public
# npm = nginx-proxy-manager

# Criar diretorio para armazenar no HOST os dados produzidos dentro do container
mkdir -p /storage/npm-app/data
mkdir -p /storage/npm-app/letsencrypt

# Remover container caso exista:
docker rm -f npm-app

# Remover imagem atual para forçar reconstrução com imagem atualizada:
docker rmi jc21/nginx-proxy-manager:latest

# Baixar imagem nova (opcional, o comando docker run abaixo ja faz isso)
docker pull jc21/nginx-proxy-manager:latest

# Criar container:
# - nome:  npm-app
# - portas:
#      80 = HTTP
#      81 = Gerencia
#     443 = HTTPs (HTTP+TLS)
# - pastas:
#      /storage/npm-app/data        mapeada em /data            dentro do container
#      /storage/npm-app/letsencrypt mapeada em /etc/letsencrypt dentro do container
#
# - banco de dados de login e mapeamentos salvos no arquivo
#   /data/database.sqlite dentro do container, que será mapeado no caminho
#   /storage/npm-app/data/database.sqlite
#
docker run \
    -d --restart=unless-stopped \
    \
    --name npm-app -h npm-app.intranet.br \
    --network network_public \
    \
    -p  80:80 \
    -p  81:81 \
    -p 443:443 \
    \
    -e DB_SQLITE_FILE=/data/database.sqlite \
    \
    -v /storage/npm-app/data:/data \
    -v /storage/npm-app/letsencrypt:/etc/letsencrypt \
		\
    jc21/nginx-proxy-manager:latest


# Acesse......:  http://ip-do-servidor:81/
# Login padrao:  admin@example.com  /  changeme

# Nota:
# - sempre faça backup do /storage do HOST
# - destruir e recriar o container do NPM nao deve ser problema
#   pois os dados sempre ficam fora do container e serão os mesmos
#   toda vez que você roda-lo

Tela de login inicial (porta TCP 81), usuário admin@example.com senha changeme:

Altere o email no primeiro login (o email será o novo usuário para autenticação):

Altere a senha (senha padrão changeme):

O primeiro passo no NPM é cadastrar os nomes de DNS (FQDN) que serão usados no menu de certificados “SSL Certificates“. Você deve cadastrar 1 nome de cada vez, evite agregar vários nomes juntos (não funciona todas as vezes por restrições no provedor ACME / Let’sEncrypt).

Troque “exemplo.com.br” pelo seu domínio real que aponta para o IPv4/IPv6 do seu servidor rodando NPM. Registre em seguida o nome “www.exemplo.com.br“.

Certificado registrado e funcional via Let’s Encrypt. O NPM renovará o certificado automaticamente antes do vencimento. Você não precisa se preocupar nunca mais!

Precisamos de um container para hospedar o site em si, mesmo que de exemplo (hello-world), vamos fazer isso com um container lighttpd para servidor uma página inicial de exemplo:

Bash
# Criar a pasta no HOST:
mkdir -p /storage/hello-world-home
echo 'Ola mundo, deu certo' > /storage/hello-world-home/index.html

# Rodar o container chamado hello-world-home
# - Observe que não há porta mapeada, esse container é 100% privado
docker run -d \
    -d --restart=unless-stopped \
    --name hello-world-home -h hello-world-home.intranet.br \
    --network network_public \
    \
    -v /storage/hello-world-home:/var/www/html:ro \
    \
    rtsp/lighttpd

Vamos criar um redirecionamento no NPM de tudo que for para o “exemplo.com.br” para o container “hello-world-home” porta 80. Vá no menu “Hosts” -> “Proxy Hosts“:

Adicione o nome de DNS (exemplo.com.br) em “Domain Names” (pode ser mais de um). NÃO clique em “SAVE”, vá em “SSL” em seguida.

Selecione o certificado registrado com o mesmo nome configurado:

Salve clicando em “SAVE”. O apontamento foi realizado com sucesso. Testando:

Bash
# Teste:
curl -v https://exemplo.com.br/
    #* Using Stream ID: 1 (easy handle 0x5611e3deace0)
    #> GET / HTTP/2
    #> Host: exemplo.com.br
    #> user-agent: curl/7.88.1
    #> accept: */*
    #> 

    #< HTTP/2 200 
    #< server: openresty
    #< date: Fri, 24 Jan 2025 22:20:52 GMT
    #< content-type: text/html
    #< content-length: 21
    #< etag: "2574584986"
    #< last-modified: Fri, 24 Jan 2025 21:20:35 GMT
    #< accept-ranges: bytes
    #< strict-transport-security: max-age=63072000;includeSubDomains; preload
    #< x-served-by: exemplo.com.br
    #< 
    #Ola mundo, deu certo

Com o domínio inteiro recebido, agora vamos redirecionar uma pasta específica do site para outro container, vamos criar um container exemplo de API:

Bash
# Criar a pasta no HOST:
mkdir -p /storage/hello-world-api
echo '{ "nome": "Patolino", "age": "6" }' > /storage/hello-world-api/index.json

# Rodar o container chamado hello-world-home
# - Observe que não há porta mapeada, esse container é 100% privado
docker run -d \
    -d --restart=unless-stopped \
    --name hello-world-api -h hello-world-api.intranet.br \
    --network network_public \
    \
    -v /storage/hello-world-api:/var/www/html:ro \
    \
    rtsp/lighttpd

Edite o HOST que responde pelo domínio “exemplo.com.br” inteiro, menu “Hosts” -> “Proxy hosts” -> vá na linha do Host, no final da linha clique nos três pontinhos “…” e vá no “Edit“:

Vá na guia “Custom locations” e adicione uma localização em “Add location“, no exemplo abaixo, tudo que for para “/api” será enviado para o container “hello-world-api” porta 80. Você pode clicar várias vezes em “Add location” para adicionar mais redirecionamentos de pastas do seu domínio para um container específico:

Com isso aprendemos os principais recursos de proxy-reverso do NPM, navegue pelo software para aprender novos recursos como autenticação, lista de acessos, etc…

Eu recomendo que você evite os redirecionamentos de pastas “/api-client“, no lugar, prefira criar entradas de DNS como “api-client.exemplo.com.br” e entregar o nome inteiro para o container alvo. Isso facilita hospedar sub-domínios em locais diferentes sem um depender do outro no fluxo de conexões TCP/HTTP.

Pouca configuração é melhor que muita configuração!

Destrua tudo que foi construído nesse capítulo para praticar o próximo no mesmo ambiente:

Bash
# Remover NPM
docker stop npm-app
docker rm   npm-app

# Remover containers lighttpd
docker rm -f hello-world-home
docker rm -f hello-world-api

6 – Traefik

O favorito de quem sobe muitos containers e não tem tempo a perder!

Embora o Traefik consiga fazer tudo que o NPM faz, ele é MUITO chato de configurar na mão, em contra-partida, você consegue subir todo serviço de maneira automática sem tocar no Traefik, tudo isso graças ao mapeamento de “labels” dos containers.

O Traefik pode ser conectar ao Docker e detectar novos containers que tenham os labels instruindo o redirecionamento desejado.

Rodando o Traefik:

Bash
# Criar container Traefik (básico)

# Diretorio de dados persistentes
mkdir -p /storage/traefik-app/letsencrypt

# Remover container caso exista:
docker rm -f traefik-app

# Remover imagem atual para forçar download com imagem atualizada:
docker rmi traefik:latest

# Obter imagem (no momento, latest = v3.x):
# - opcional, o comando docker run abaixo ja faz isso
docker pull traefik:latest

# Criar container:
# - nome:  traefik-app
# - Colocar na rede network_public
# - portas:
#      80 = HTTP
#     443 = HTTPs (HTTP+TLS)
# - volume mapeado:
#      arquivo /var/run/docker.sock mapeado em /var/run/docker.sock no container
# - argumento especial '--providers.docker' instrue o Traefik a consultar
#   o socket unix do Docker para obter suas configurações.
#
docker run \
    -d --restart=unless-stopped \
    \
    --name traefik-app -h traefik-app.intranet.br \
    --network network_public \
    \
    -p  80:80 \
    -p 443:443 \
    \
    -v /var/run/docker.sock:/var/run/docker.sock:ro \
    -v /storage/traefik-app/letsencrypt:/etc/letsencrypt \
		\
    traefik:latest \
      --api=true \
      --providers.docker=true \
      --entrypoints.web.address=:80 \
      --entrypoints.websecure.address=:443 \
      --certificatesresolvers.letsencrypt.acme.httpchallenge=true \
      --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web \
      --certificatesresolvers.letsencrypt.acme.email=seu-email@exemplo.com.br \
      --certificatesresolvers.letsencrypt.acme.storage=/etc/letsencrypt/acme.json

Observe que mapeamos o caminho do socket unix (/var/run/docker.sock) do Docker (HOST) no mesmo caminho dentro do container, em modo somente leitura (“:ro”). Isso dá ao Traefik acesso à configuração do Docker para consultar o JSON de todos os containers em execução.

O Traefik consultará todos os containers em busca de “labels” que o instrua como prover o encaminhamento de URLs.

Objetivamente: cada container “puxa” o trafego para si nesse tipo de ambiente.

Vamos criar dois containers de teste com manifesto de labels.

Primeiro container para a página inicial:

Bash
# Lighttpd para receber o site exemplo.com.br e www.exemplo.com.br

# Criar a pasta no HOST:
mkdir -p /storage/hello-world-home
echo 'Ola, deu certo pelo Traefik' > /storage/hello-world-home/index.html

# Rodar o container chamado hello-world-home
# - Observe que não há porta mapeada, esse container é 100% privado
# - Observe os labels do Traefik:
docker run -d \
    -d --restart=unless-stopped \
    --name hello-world-home -h hello-world-home.intranet.br \
    --network network_public \
    \
    -v /storage/hello-world-home:/var/www/html:ro \
    \
    --label "traefik.enable=true" \
    --label "traefik.http.routers.exemplo.rule=Host(\`exemplo.com.br\`) || Host(\`www.exemplo.com.br\`)" \
    --label "traefik.http.routers.exemplo.priority=100" \
    --label "traefik.http.routers.exemplo.entrypoints=websecure" \
    --label "traefik.http.routers.exemplo.tls=true" \
    --label "traefik.http.routers.exemplo.tls.certresolver=letsencrypt" \
    --label "traefik.http.services.exemplo.loadbalancer.server.port=80" \
    \
    rtsp/lighttpd

Segundo container para a pasta “/api” para o site “exemplo.com.br” (e o “www” dele):

Bash
# Lighttpd para receber o site exemplo.com.br/api e api.exemplo.com.br

# Criar a pasta no HOST:
mkdir -p /storage/hello-world-api
echo '{ "nome": "Patolino", "age": "6" }' > /storage/hello-world-api/index.json

# Remover:
docker rm -f hello-world-api

# Rodar o container chamado hello-world-home
# - Observe que não há porta mapeada, esse container é 100% privado
docker run -d \
    -d --restart=unless-stopped \
    --name hello-world-api -h hello-world-api.intranet.br \
    --network network_public \
    \
    -v /storage/hello-world-api:/var/www/html:ro \
    \
    --label "traefik.enable=true" \
    --label "traefik.http.routers.api.rule=(Host(\`exemplo.com.br\`) || Host(\`www.exemplo.com.br\`)) && PathPrefix(\`/api\`)" \
    --label "traefik.http.routers.api.priority=200" \
    --label "traefik.http.routers.api.entrypoints=websecure" \
    --label "traefik.http.routers.api.tls=true" \
    --label "traefik.http.routers.api.tls.certresolver=letsencrypt" \
    --label "traefik.http.services.api.loadbalancer.server.port=80" \
    \
    rtsp/lighttpd

Observe o “priority“, quanto mais específica for seu filtro, aumente o valor de prioridade para que a regra seja processada primeiro em relação à configuração de outros containers.

O Traefik é assim mesmo, você roda ele de forma simples, e deixa o resto pra cada container declarar suas capturas. Trabalho 100% automático!

O ponto fraco do Traefik é que você tem que reconfigurar todos os seus containers para incluir as declarações de labels.

Ao declarar um novo container, o Traefik pode demorar entre 1 e 2 minutos para consultar a API remota do Let’s Encrypt para obter o certificado para ele. Garanta que o DNS foi configurado corretamente ANTES de criar o container, pois há o risco do excesso de tentativas sem sucesso resultar no bloqueio do seu IP na API do Let’s Encrypt.

Traefik personalizado na mão junto com o modo automático

Você pode rodar o Traefik especificando todas as configurações no CMD (command) do container, veja um exemplo bem completo e exaustivo:

Bash
# Criar container Traefik (completo)
# - Diretorios mapeados:
mkdir -p /storage/traefik-app/letsencrypt
mkdir -p /storage/traefik-app/logs
mkdir -p /storage/traefik-app/config

# - Arquivo de configuracao manual
touch    /storage/traefik-app/config/traefik.yml

# - Remover atual:
docker rm -f traefik-app

# - Rodar
docker run \
    -d --restart=unless-stopped \
    \
    --name traefik-app -h traefik-app.intranet.br \
    --network network_public \
    \
    -p 80:80 \
    -p 443:443 \
    \
    -v /var/run/docker.sock:/var/run/docker.sock:ro \
    -v /storage/traefik-app/letsencrypt:/etc/letsencrypt \
    -v /storage/traefik-app/config:/etc/traefik \
    -v /storage/traefik-app/logs:/logs \
    \
    traefik:latest \
      \
      --global.checkNewVersion=false \
      --global.sendAnonymousUsage=false \
      \
      --api.dashboard=true \
      --api.insecure=true \
      \
      --log.level=INFO \
      --log.filePath=/logs/error.log \
      \
      --accessLog.filePath=/logs/access.log \
      \
      --entrypoints.web.address=:80 \
      --entrypoints.web.http.redirections.entryPoint.to=websecure \
      --entrypoints.web.http.redirections.entryPoint.scheme=https \
      --entrypoints.web.http.redirections.entryPoint.permanent=true \
      \
      --entrypoints.websecure.address=:443 \
      \
      --providers.docker=true \
      --providers.file.directory=/etc/traefik \
      \
      --certificatesresolvers.letsencrypt.acme.email=seu-email@exemplo.com.br \
      --certificatesresolvers.letsencrypt.acme.storage=/etc/letsencrypt/acme.json \
      --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web

No exemplo acima, implementamos:

  • Logs: vão ficar armazenados no HOST no diretório /storage/traefik-app/logs, tome cuidado com o crescimento e armazenamento deles em obediência ao Marco Civil;
  • Configuração personalizada: arquivos .yml (formato YAML) podem ser criados e configurados no HOST no diretório /storage/traefik-app/config com as personalizações manuais do seu ambiente;

Com o uso de Docker Compose, você não precisará se preocupar com a quantidade de argumentos do container, já que um único arquivo de stack resolverá todas as suas necessidades.

Outra forma de resolver é transformar a configuração em parte dos dados persistentes do container, convertendo a configuração de argumentos do Traefik em um arquivo de configuração. Esse método permite que você edite o arquivo sem reiniciar o container pois o Traefik monitora alterações em tempo real. O único defeito é que se você errar a configuração o Traefik bug ao vivo!

Arquivo de configuração no HOST em /storage/traefik-app/config/traefik.yml:

YAML
global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  dashboard: true
  insecure: true

log:
  level: INFO
  filePath: /logs/error.log

accessLog:
  filePath: /logs/access.log

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      email: seu-email@exemplo.com.br
      storage: /etc/letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
  file:
    directory: "/etc/traefik/config"
    watch: true

Rodando o container Traefik usando o arquivo acima:

Bash
# Criar container Traefik (completo)
# - Diretorios mapeados:
mkdir -p /storage/traefik-app/letsencrypt
mkdir -p /storage/traefik-app/logs
mkdir -p /storage/traefik-app/config

# - Arquivo de configuracao manual
touch    /storage/traefik-app/config/traefik.yml

# - Remover atual:
docker rm -f traefik-app

# - Rodar
docker run \
    -d --restart=unless-stopped \
    \
    --name traefik-app -h traefik-app.intranet.br \
    --network network_public \
    \
    -p 80:80 \
    -p 443:443 \
    -p 8080:8080 \
    \
    -v /var/run/docker.sock:/var/run/docker.sock:ro \
    -v /storage/traefik-app/letsencrypt:/etc/letsencrypt \
    -v /storage/traefik-app/config:/etc/traefik \
    \
    traefik:latest \
        --configFile=/etc/traefik/traefik.yml

Com isso encerramos os principais exemplos de uso do Traefik!

Apagar Traefik:

Bash
# Remover Traefik (traefik-app)
docker stop traefik-app
docker rm   traefik-app

7 – Caddy

O Caddy segue a mesma ideia do Traefik de ser “zero-touch” e prover redirecionamentos automáticos por meio de labels mas também permitindo personalizações manuais.

Ele parece simples, mas é mais esquisito de configurar que o Traefik, logo ele não se encaixará em todas as soluções. Sua configuração é escrita em blocos e os labels são mais curtos.

O Caddy também faz a obtenção automática de certificados assinados usando ACME-Let’sEncrypt.

Rodando Caddy:

Bash
# Criar container Traefik (básico)

# Diretorio de dados persistentes
mkdir -p /storage/caddy-app
mkdir -p /storage/caddy-app/config
mkdir -p /storage/caddy-app/logs
mkdir -p /storage/caddy-app/data

# Remover container caso exista:
docker rm -f caddy-app

# Remover imagem atual para forçar download com imagem atualizada:
docker rmi lucaslorentz/caddy-docker-proxy:latest

# Obter imagem (no momento, latest = v2.9.x):
# - opcional, o comando docker run abaixo ja faz isso
docker pull lucaslorentz/caddy-docker-proxy:latest

# Criar container:
# - nome:  caddy-app
# - Colocar na rede network_public
# - portas:
#      80 = HTTP
#     443 = HTTPs (HTTP+TLS)
# - volumes mapeados:
#      arquivo /var/run/docker.sock mapeado em /var/run/docker.sock no container
#      diretorios de config e logs do host mapeados dentro do container
#
docker run \
    -d --restart=unless-stopped \
    \
    --name caddy-app -h caddy-app.intranet.br \
    --network network_public \
    \
    -p  80:80 \
    -p 443:443 \
    \
    -e CADDY_INGRESS_NETWORKS=network_public \
    \
    -v /var/run/docker.sock:/var/run/docker.sock:ro \
    -v /storage/caddy-app/data:/data \
    -v /storage/caddy-app/logs:/logs \
		\
    lucaslorentz/caddy-docker-proxy:latest

Criando um container para puxar automaticamente uma URL pelo Caddy:

Bash
# Lighttpd para receber o site exemplo.com.br e www.exemplo.com.br

# Criar a pasta no HOST:
mkdir -p /storage/hello-world-home
echo 'Ola, deu certo pelo Caddy' > /storage/hello-world-home/index.html

# Remover container hello-world-home anterior:
docker rm -f hello-world-home

# Rodar o container chamado hello-world-home
# - Observe que não há porta mapeada, esse container é 100% privado
# - Observe os labels do Caddy:
docker run -d \
    -d --restart=unless-stopped \
    --name hello-world-home -h hello-world-home.intranet.br \
    --network network_public \
    \
    -v /storage/hello-world-home:/var/www/html:ro \
    \
    --label caddy=example.com \
    --label caddy.reverse_proxy=http://hello-world-home:80 \
    \
    rtsp/lighttpd
    

Embora pareça simples e mágico, o Caddy tem suas limitações, o Traefik e o NPM ainda são superiores.

Conclusão

Recomendo focar no Traefik, ele roda sem toque, 100% invisível, suporta todos os recurso de automatização e segurança e deixa com que o serviços (containers) assumam seus nomes e diretórios HTTP para escuta.

Até a próxima!
Patrick Brandão, patrickbrandao@gmail.com