Ultimamente tenho mexido no meu homelab e queria configurar um domínio personalizado com certificados SSL wildcard para todos os meus serviços. Além disso, não queria expor nenhum dos serviços à internet, então um desafio ACME HTTP-01 tradicional não ia servir.

Me deparei com este excelente blogpost do Wolfgang, que explica uma abordagem usando o desafio ACME DNS-01 para eliminar a necessidade de expor qualquer serviço à internet. Testei e funcionou muito bem, mas queria usar o Caddy como um proxy reverso, porque acho mais fácil configurar e reproduzir do que o Nginx Proxy Manager. Mexi um pouco na configuração do Caddy e cheguei a uma configuração com a qual estou bastante satisfeito, e é isso que vou compartilhar neste post.

Pré-requisitos Link para o cabeçalho

Nesse guia vou assumir que você já tem uma distribuição Linux com Docker instalado no seu home server. Estou usando o Ubuntu Server 22.04.3 LTS e o Docker version 25.0.3, mas qualquer distribuição Linux com uma versão razoavelmente recente do Docker deve ser suficiente. Também vou presumir que você está um pouco familiarizado com a linha de comando e com o funcionamento do Docker, HTTP e portas de rede.

Como funciona? Link para o cabeçalho

Num desafio HTTPS-01 tradicional, o Let’s Encrypt fornece um token ao cliente ACME, que o armazena no servidor com uma account key. Uma vez que o arquivo está pronto, o Let’s Encrypt tenta recuperá-lo fazendo uma requisição HTTP ao servidor. Se a resposta for válida, o certificado é emitido com sucesso. Isso requer que seu servidor esteja exposto à internet para que o Let’s Encrypt possa fazer uma requisição HTTP para ele. Também vale destacar que esse método não permite a emissão de certificados wildcard.

Para contornar essas limitações, podemos usar o desafio DNS-01. Esse desafio funciona colocando um valor específico em um registro TXT sob o seu nome de domínio. O Let’s Encrypt fornece um token ao cliente ACME. O cliente ACME então cria um registro TXT derivado desse token e uma chave de conta e coloca esse registro em _acme-challenge.<SEU_DOMÍNIO>. O Let’s Encrypt pode então consultar o sistema DNS para esse registro e, se encontrar correspondência, o certificado pode ser emitido com sucesso. O interessante desse método é que não é necessário expor o servidor à internet.

Se você quiser saber mais sobre os diferentes tipos de desafios ACME, recomendo fortemente a documentação do Let’s Encrypt sobre o assunto. Aprendi a maior parte dessas informações lá.

Configurando o registro DNS Link para o cabeçalho

O primeiro passo é obter um nome de domínio e apontar esse domínio para o endereço IP local do nosso servidor. Existem vários provedores de nomes de domínio por aí, mas uma opção boa e gratuita é o Duck DNS. Tenho tido uma boa experiência usando ele no meu homelab, então vou usá-lo neste guia. Caso prefira, você pode usar qualquer provedor de DNS, desde que ele suporte desafios DNS-01.

Página do Duck DNS

Anote o seu token Duck DNS. Vamos usá-lo para configurar o Caddy na próxima etapa.

Estrutura de diretórios Link para o cabeçalho

Neste guia, vamos rodar alguns serviços usando docker compose. A estrutura de diretórios deve ficar assim:

.
├── docker-compose.yml
├── caddy
│   ├── Caddyfile
│   └── Dockerfile
└── homepage

Configurando o Caddy Link para o cabeçalho

Usaremos a imagem Docker oficial do Caddy para configurar e executar nosso proxy reverso.

O Caddy tem o conceito de módulos que são basicamente plugins para estender sua funcionalidade. Existem vários módulos que fornecem integração com diferentes provedores DNS sob a organização caddy-dns no GitHub. Vamos usar o módulo duckdns porque é o provedor DNS que estamos usando neste guia, mas você pode escolher outro módulo de acordo com o seu provedor DNS.

Para usar os módulos do Caddy, precisamos construir um binário personalizado usando a ferramenta xcaddy. Felizmente, o Caddy fornece uma imagem Docker sob a tag <versão>-builder que nos ajuda a construir imagens Docker personalizadas com os módulos de que precisamos. Vamos definir nosso Dockerfile da seguinte forma:

FROM caddy:2-builder AS builder

RUN xcaddy build --with github.com/caddy-dns/duckdns

FROM caddy:2

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

Usamos uma multi-stage build que constrói nosso binário personalizado com o módulo duckdns e o copia para a imagem Docker padrão. Isso garante que a imagem final não contenha dependências de build, reduzindo seu tamanho.

Em seguida, precisamos configurar nosso Caddyfile para que o Caddy gerencie os certificados para nossos domínios usando o módulo duckdns e atue como um proxy reverso para nossos serviços. Neste exemplo, quero acessar meu serviço homepage usando o domínio ssl-blog-demo.duckdns.org e acessar meus outros serviços usando os subdomínios *.ssl-blog-demo.duckdns.org.

ssl-blog-demo.duckdns.org {
    tls {
        dns duckdns {env.DUCKDNS_API_TOKEN}
    }

    reverse_proxy localhost:3000
}

*.ssl-blog-demo.duckdns.org {
    tls {
        dns duckdns {env.DUCKDNS_API_TOKEN}
    }

    @jellyfin host jellyfin.ssl-blog-demo.duckdns.org
    handle @jellyfin {
        reverse_proxy localhost:8096
    }

    @grafana host grafana.ssl-blog-demo.duckdns.org
    handle @grafana {
        reverse_proxy localhost:3001
    }
}

Neste exemplo, o Caddy vai solicitar e gerenciar automaticamente um certificado para ssl-blog-demo.duckdns.org e um certificado wildcarded para *.ssl-blog-demo.duckdns.org. Definimos as regras do proxy reverso usando o host das requisições para corresponder à porta do serviço na máquina local. Observe também que fazemos referência a uma variável de ambiente DUCKDNS_API_TOKEN, para que não precisemos expor essa informação no arquivo de configuração.

Em seguida, declaramos nossos serviços usando um arquivo docker compose. Eu preparei um docker-compose.yaml de exemplo para esse guia com alguns serviços apenas para fins de demonstração. O nosso principal foco de atenção é o serviço caddy.

Note que especificamos a variável de ambiente DUCKDNS_API_TOKEN no serviço caddy. Você deve configurar essa variável com o valor do token do Duck DNS que foi obtido no primeiro passo desse guia.

Também é importante montar um volume persistente no caminho /data no serviço caddy, pois é onde os arquivos de certificado são armazenados e não queremos perdê-los se o contêiner for recriado.

version: "3.8"

volumes:
  caddy_data:
  caddy_config:

services:
  caddy:
    build:
      dockerfile: "./caddy/Dockerfile"
    container_name: caddy
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    environment:
      DUCKDNS_API_TOKEN: <SEU_TOKEN_DUCKDNS_API>

  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    restart: unless-stopped
    ports:
      - 3000:3000
    volumes:
      - ./homepage:/app/config
      - /var/run/docker.sock:/var/run/docker.sock

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    restart: unless-stopped
    ports:
      - 3001:3000

  jellyfin:
    image: lscr.io/linuxserver/jellyfin:nightly
    container_name: jellyfin
    restart: unless-stopped
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/Sao_Paulo
      - JELLYFIN_PublishedServerUrl=192.168.0.243
    ports:
      - 8096:8096

E é isso! Executar docker compose up no diretório atual deve iniciar todos os serviços, e devemos poder acessá-los usando nosso domínio e verificar que temos certificados SSL válidos.

Ao acessar a URL ssl-blog-demo.duckdns.org, podemos verificar que a requisição é redirecionada para o serviço homepage, e o certificado SSL é válido.

Homepage com um certificado SSL válido

Isso também vale para os serviços sob o domínio wildcarded:

Grafana com um certificado SSL válido

Jellyfin com um certificado SSL válido

Com essa configuração, adicionar novos serviços e domínios é apenas uma questão de adicionar uma nova entrada no Caddyfile.