Pular para o conteúdo
Docker

Nibleaf: documentação self-hosted com Docker

Um unicórnio de crina luminosa folheando um livro mágico de documentação ao lado de contêineres empilhados e uma coruja escriba

Olá meus Unicórnios! 🦄✨

Documentação é aquele assunto que todo mundo adia. Você escreve o sistema, ele fica lindo, e a explicação de como usá-lo mora num arquivo .txt que só você entende. 😅 Quando finalmente resolvi tratar isso com carinho, esbarrei numa escolha chata: as plataformas bonitas de documentação são todas serviço pago por assento, e as gratuitas pedem que você escreva Markdown puro no editor de código, sem ver nada até publicar.

O Nibleaf resolve os dois lados. Ele é uma plataforma de documentação que você instala no seu próprio servidor, com um editor visual no estilo Notion por cima de arquivos Markdown de verdade. Você escreve clicando, e o que fica gravado é Markdown comum, que você pode levar embora quando quiser. Tem busca própria, versionamento a cada publicação e suporte a idiomas da direita para a esquerda (árabe, hebraico) desde o primeiro dia, o que é raro.

Instalei numa VPS pequena para ver quanto doía. Foi mais simples do que eu esperava, e mesmo assim tropecei duas vezes. Os dois tropeços estão aqui, porque são exatamente os que vão acontecer com você. 🙈

🧰 O que você precisa antes de começar

Este tutorial pressupõe que você tem um servidor Linux (usei Ubuntu 24.04) e sabe entrar nele por SSH. Nada além disso. Se você nunca usou Docker, tudo bem: eu explico cada comando na linha em que ele aparece.

Sobre o tamanho da máquina, um aviso que vale ouro: o repositório do Nibleaf tem dois arquivos de instalação. Um constrói o programa a partir do código-fonte, e a documentação avisa que isso precisa de 5 a 6 GB de RAM livres, derrubando servidores pequenos no meio do processo. O outro baixa a imagem pronta. Use sempre o segundo. É o docker-compose.prod.yml, e é o único que aparece neste artigo.

Na minha máquina de testes eu tinha 6,8 GB de RAM livres e 86 GB de disco, e o Nibleaf usou uma fração disso. Já já mostro os números medidos.

🐳 Instalando o Docker (se ainda não tiver)

O Docker é o programa que roda cada peça do Nibleaf dentro da sua caixinha isolada. Se o seu servidor ainda não tem, o jeito oficial é este:

curl -fsSL https://get.docker.com | sh

Traduzindo: o curl baixa o script oficial de instalação e o | sh manda executá-lo. Para conferir se deu certo, peça a versão das duas ferramentas que vamos usar:

docker --version
docker compose version

Na minha VPS as respostas foram Docker version 29.7.2 e Docker Compose version v5.5.0. Se o segundo comando reclamar que compose não existe, você tem uma versão antiga demais do Docker, de quando o comando ainda se escrevia docker-compose com hífen.

📁 Criando a pasta e baixando o arquivo de instalação

Toda a instalação vai morar numa pasta só. Eu usei /opt/nibleaf, que é o lugar tradicional para programas instalados à mão no Linux.

mkdir -p /opt/nibleaf
cd /opt/nibleaf
curl -sSLO https://raw.githubusercontent.com/lord007tn/nibleaf/main/docker-compose.prod.yml

O que cada linha faz: o mkdir cria a pasta (o -p manda criar sem reclamar se ela já existir), o cd entra nela, e o curl -O baixa o arquivo mantendo o nome original.

Confira que ele chegou:

ls -la

Tem de aparecer o docker-compose.prod.yml com uns 12 KB. Esse arquivo é a receita: ele descreve as seis peças do Nibleaf e como elas conversam entre si. A boa notícia é que você não vai editar esse arquivo. Toda a configuração vai num segundo arquivo, separado.

🔑 O arquivo .env, que é onde tudo acontece

O Nibleaf lê as suas configurações de um arquivo chamado .env, na mesma pasta. Aqui vem o primeiro detalhe que confunde quem está começando: arquivos cujo nome começa com ponto são invisíveis no Linux. Depois de criá-lo, um ls comum não mostra nada, e você acha que perdeu o arquivo. Para vê-lo, use ls -la, onde o -a quer dizer "mostre tudo, inclusive os escondidos".

Antes de escrever o arquivo, precisamos gerar os segredos. São valores aleatórios que o Nibleaf usa para assinar as sessões de login e para proteger o banco. Não invente esses valores de cabeça, e não copie os meus: gere os seus com o comando abaixo, que imprime quatro linhas prontas para copiar.

echo "BETTER_AUTH_SECRET=$(openssl rand -hex 32)"
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')"
echo "STORAGE_ACCESS_KEY_ID=$(openssl rand -hex 10)"
echo "STORAGE_SECRET_ACCESS_KEY=$(openssl rand -base64 24 | tr -d '/+=')"

O openssl rand -hex 32 sorteia 32 bytes aleatórios e os escreve em hexadecimal, dando uma cadeia de 64 caracteres. O tr -d '/+=' na segunda linha apaga as barras e sinais de mais que o formato base64 produz, porque esses caracteres atrapalham quando a senha entra numa URL de conexão.

Guarde as quatro linhas que saíram. Agora vamos criar o arquivo com o editor nano, que já vem instalado:

nano .env

A tela fica preta e vazia, com uma barra de comandos embaixo. Cole o conteúdo abaixo, trocando as quatro linhas de segredo pelas suas e o SEU_IP_AQUI pelo endereço do seu servidor.

NIBLEAF_VERSION=v0.1.1

APP_URL=http://SEU_IP_AQUI:4310
NIBLEAF_BIND=0.0.0.0
APP_PORT=4310
SITE_BASE_DOMAIN=

BETTER_AUTH_SECRET=cole_aqui_a_sua_primeira_linha
POSTGRES_PASSWORD=cole_aqui_a_sua_segunda_linha

STORAGE_PUBLIC_ENDPOINT=http://SEU_IP_AQUI:9300
STORAGE_ACCESS_KEY_ID=cole_aqui_a_sua_terceira_linha
STORAGE_SECRET_ACCESS_KEY=cole_aqui_a_sua_quarta_linha
STORAGE_BUCKET=nibleaf
STORAGE_FORCE_PATH_STYLE=true
MAXIO_SECURE_COOKIES=false

REQUIRE_EMAIL_VERIFICATION=false
DISABLE_SIGNUP=false

Para salvar e sair do nano: Ctrl+O (a letra O, de "output"), depois Enter para confirmar o nome, e Ctrl+X para fechar. Aquele símbolo ^ que aparece na barra de baixo do editor significa justamente a tecla Ctrl: onde se lê ^O, entenda Ctrl+O.

Como o arquivo guarda senhas, feche-o para os outros usuários da máquina e confira que ele existe:

chmod 600 .env
ls -la

📝 O que cada configuração significa

Vale entender o que você acabou de escrever, porque três dessas linhas são a diferença entre o painel abrir e você passar a tarde procurando o erro.

ConfiguraçãoPara que serve
APP_URLO endereço pelo qual você vai abrir o painel no navegador. É a linha mais importante do arquivo.
NIBLEAF_BINDQuem pode alcançar a porta. O padrão de fábrica é 127.0.0.1, que aceita só conexões de dentro do próprio servidor. Como eu queria abrir do meu computador, coloquei 0.0.0.0, que aceita de fora.
APP_PORTA porta do painel. O padrão é 4310.
BETTER_AUTH_SECRETA chave que assina as sessões de login. Sem ela, nada sobe.
POSTGRES_PASSWORDA senha do banco de dados que guarda as suas páginas.
STORAGE_*O armazenamento das imagens que você subir para a documentação.
REQUIRE_EMAIL_VERIFICATIONSe o Nibleaf exige confirmar o e-mail antes do primeiro login. Deixei false porque não configurei envio de e-mail; com true e sem servidor de e-mail, você cria a conta e nunca consegue entrar.

Sobre o armazenamento, uma boa surpresa: você não precisa contratar um bucket na Amazon. A instalação já traz junto um servidor de arquivos compatível com S3, chamado maxio, que cria o bucket sozinho na primeira subida. Aquelas duas chaves que você gerou não são de nenhum serviço externo: é com elas que o maxio nasce. Você as está inventando, não copiando de lugar nenhum.

💥 O primeiro erro: segredo vazio não passa

Antes de acertar, eu errei de propósito para ver o que acontecia. Deixei o BETTER_AUTH_SECRET vazio e mandei subir. O resultado não foi um contêiner com defeito: foi uma parede de erros antes de qualquer coisa iniciar.

error while interpolating services.app.environment.BETTER_AUTH_SECRET: required
variable BETTER_AUTH_SECRET is missing a value: Set BETTER_AUTH_SECRET in .env
— generate with `openssl rand -hex 32`

error while interpolating services.postgres.environment.POSTGRES_PASSWORD: required
variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env

error while interpolating services.maxio.environment.MAXIO_ACCESS_KEY: required
variable STORAGE_ACCESS_KEY_ID is missing a value: Set STORAGE_ACCESS_KEY_ID in .env

Repare no detalhe bonito: quem barrou não foi o Nibleaf, foi o próprio Docker Compose. O arquivo de instalação usa uma sintaxe chamada ${VARIAVEL:?mensagem}, que quer dizer "se esta variável estiver vazia, pare tudo e mostre esta mensagem". É por isso que o erro chega com a solução escrita dentro dele, dizendo qual comando gera o valor.

Isso é uma escolha de projeto que eu respeito muito. A alternativa comum, e péssima, seria o programa subir com um segredo padrão qualquer, funcionar lindamente, e deixar você com um servidor onde qualquer pessoa que conheça o valor padrão consegue forjar uma sessão de login. Falhar cedo e falhar barulhento é o comportamento certo aqui.

📥 Baixando as imagens (e a hora em que a internet me traiu)

Com o .env preenchido de verdade, o primeiro passo é baixar as imagens. Elas são grandes: a do Nibleaf sozinha tem 2 GB.

docker compose -f docker-compose.prod.yml pull

E foi aqui que eu tomei um susto. Depois de baixar 320 MB, o servidor do GitHub simplesmente cortou a conexão:

82133255ea51 Downloading  320.9MB
82133255ea51 Downloading  321.5MB
failed to copy: read tcp ...:57224->...:443: read: connection reset by peer

Meu primeiro instinto foi achar que eu tinha errado alguma coisa. Não tinha: é só a rede. Download de 2 GB tem chance de cair, e quando cai no meio o Docker devolve essa mensagem meio críptica. A solução é a mais boba possível: rode o mesmo comando de novo. O Docker guarda as camadas que já baixaram e continua de onde parou, em vez de recomeçar do zero.

Se a sua conexão for instável, vale mandar o comando tentar sozinho algumas vezes:

for i in 1 2 3 4 5; do
  docker compose -f docker-compose.prod.yml pull && break
  echo "tentativa $i falhou, repetindo em 5 segundos"
  sleep 5
done

O && break é a parte que importa: ele sai do laço assim que o download termina bem, para não repetir à toa. Na segunda tentativa o meu passou inteiro.

🚀 Subindo tudo

Agora sim, o comando que liga o Nibleaf:

docker compose -f docker-compose.prod.yml up -d

O up cria e inicia os contêineres; o -d os deixa rodando em segundo plano, para você poder fechar o terminal e ir tomar um café. ☕ A saída conta uma historinha bem organizada:

Container nibleaf-postgres-1   Started
Container nibleaf-dragonfly-1  Started
Container nibleaf-maxio-1      Started
Container nibleaf-postgres-1   Healthy
Container nibleaf-migrate-1    Started
Container nibleaf-migrate-1    Exited
Container nibleaf-server-1     Started
Container nibleaf-worker-1     Started
Container nibleaf-server-1     Healthy
Container nibleaf-app-1        Started

Leia essa saída com carinho, porque ela mostra a ordem certa das coisas. Primeiro sobem o banco, a fila e o armazenamento. Quando o banco fica Healthy, entra o migrate, que cria as tabelas e termina (por isso ele aparece como Exited: não é erro, é o esperado, ele é uma tarefa de uma vez só). Só depois disso sobem o server e o worker, e por último o app, que é o painel que você vê.

Para conferir o estado de todos:

docker compose -f docker-compose.prod.yml ps
SERVICE     STATUS
app         Up About a minute (healthy)
dragonfly   Up 14 minutes (healthy)
maxio       Up 14 minutes (healthy)
postgres    Up 14 minutes (healthy)
server      Up About a minute (healthy)
worker      Up About a minute (healthy)

A palavra que interessa é (healthy). Um contêiner pode aparecer como Up e ainda assim não estar pronto para atender: Up quer dizer só que o processo está vivo. O healthy é o próprio programa respondendo "estou funcionando". Se você abrir o painel cedo demais e vir erro, espere esses seis healthy aparecerem antes de se preocupar.

🔓 Liberando a porta no firewall

O Ubuntu costuma vir com um firewall chamado ufw, que bloqueia tudo que você não liberou explicitamente. Se ele estiver ligado, o painel não vai abrir do seu computador, e não há mensagem nenhuma explicando por quê: a página simplesmente fica carregando para sempre. Veja o estado dele:

ufw status

Se a resposta for Status: active e a porta 4310 não estiver na lista, libere-a:

ufw allow 4310/tcp

Agora teste, do seu computador, se o painel responde. O %{http_code} pede ao curl que mostre só o código da resposta:

curl -s -o /dev/null -w "%{http_code}\n" http://SEU_IP_AQUI:4310/sign-in

Um 200 significa que está tudo de pé. 🎉

🧙 Criando a primeira conta

Abra http://SEU_IP_AQUI:4310 no navegador. A primeira tela é a de login, e como ainda não existe ninguém, o caminho é o link Create one, embaixo do botão.

A tela de criação de conta do Nibleaf, com os campos de nome, e-mail e senha e a caixa de aceite dos termos

Repare no lado esquerdo da tela: Arabic-ready, RTL-first authoring. Esse é o diferencial que me fez querer testar o projeto. A maioria das plataformas de documentação trata escrita da direita para a esquerda como um remendo adicionado depois; esta nasceu bilíngue, com árvore de páginas por idioma.

Preencha nome, e-mail e senha (mínimo de oito caracteres), marque o aceite dos termos e clique em Create account. A primeira conta criada vira a dona da instalação.

😤 O segundo erro: Invalid origin

Foi exatamente aqui que eu levei o tapa na cara. Preenchi tudo, cliquei no botão, e apareceu uma tarja rosa com duas palavras e nenhuma explicação:

A tela de login do Nibleaf mostrando uma tarja rosa com a mensagem Invalid origin acima do botão

Nada no formulário estava errado. A senha era válida, o e-mail também, e os seis contêineres estavam healthy. Fui ler o registro do servidor, que é sempre o lugar certo para perguntar:

docker compose -f docker-compose.prod.yml logs server --tail 40

E lá estava, cristalino:

ERROR [Better Auth]: Invalid origin: http://localhost:4310

Entendi na hora, e a culpa era minha. 😳 Eu tinha escrito o endereço do servidor no APP_URL, mas naquele momento estava abrindo o painel por localhost, através de um túnel. Para o Nibleaf, aquilo era um endereço diferente do que ele conhecia, e ele recusou.

E a recusa é uma proteção de verdade, não uma implicância. Ela existe para impedir que um site malicioso qualquer faça requisições de login para o seu painel usando a sessão do seu navegador. O Nibleaf só aceita pedidos vindos dos endereços que você declarou confiar. Dá para ver isso com clareza chamando a interface de login direto:

curl -s -X POST http://127.0.0.1:4310/api/auth/sign-in/email \
  -H 'Content-Type: application/json' \
  -H 'Origin: http://localhost:4310' \
  -d '{"email":"[email protected]","password":"sua_senha"}'
{
    "message": "Invalid origin",
    "code": "INVALID_ORIGIN"
}

Um 403 limpinho. A lista de endereços confiáveis chama-se TRUSTED_ORIGINS e, quando você não a escreve, ela vale exatamente o que estiver no APP_URL, e mais nada. Como eu usava dois endereços, precisei declarar os dois. Abra o .env de novo:

nano .env

E acrescente estas duas linhas no fim, separando os endereços por vírgula, sem espaço depois dela:

TRUSTED_ORIGINS=http://SEU_IP_AQUI:4310,http://localhost:4310
CORS_ALLOWED_ORIGINS=http://SEU_IP_AQUI:4310,http://localhost:4310

Salve com Ctrl+O, Enter, Ctrl+X. Mudança em .env só vale depois de recriar os contêineres, e o comando é o mesmo de antes:

docker compose -f docker-compose.prod.yml up -d

O Docker percebe sozinho o que mudou e recria só o necessário. Repeti o mesmo curl de antes, e a resposta virou 200. Voltei ao navegador, criei a conta, e entrou. 🥳

🏠 O painel por dentro

Passado o susto, a recompensa. O Nibleaf já entra com um site de documentação de exemplo criado e publicado, com sete páginas prontas:

O painel do Nibleaf logado, mostrando o resumo com 1 projeto, 7 páginas e a lista de sites

Gostei muito dessa decisão. Plataforma que te larga numa tela vazia dizendo "crie o seu primeiro projeto" te obriga a adivinhar como as coisas funcionam; aqui você já tem um site inteiro para abrir, mexer e quebrar sem medo. Os números de visitas estão zerados porque a análise de acessos é própria do Nibleaf, sem serviço externo espionando os seus leitores.

✍️ O editor visual, que é o motivo de tudo

Clicando no site e em Open editor, chega-se ao que interessa:

O editor visual do Nibleaf com a árvore de páginas à esquerda, o texto formatado no centro e o painel de comentários à direita

À esquerda, a árvore de páginas, com pastas e o seletor de idioma no topo. No centro, o texto do jeito que o leitor vai ver, editável clicando em cima. À direita, comentários, para revisar documentação em equipe sem sair da ferramenta. E no alto, aquelas abas: Visual, WYSIWYG, Markdown e Preview.

É a aba Markdown que prova a promessa do projeto. A mesma página, um clique depois:

A mesma página aberta na aba Markdown, mostrando o texto em Markdown puro com numeração de linhas

Markdown puro, com ## nos títulos e **negrito** como sempre foi. Aquele bloco de destaque bonito que aparece na visão visual é só uma etiqueta <Note> no texto. Ou seja: você escreve clicando, sem decorar sintaxe, e o que fica guardado continua sendo um arquivo de texto comum que qualquer editor abre. Se um dia você quiser sair do Nibleaf, leva a sua documentação embora inteira. 🎒

Repare também no rodapé do editor: Words: 192 · Characters: 1103. Detalhe pequeno, mas é o tipo de coisa que só existe em ferramenta feita por quem realmente escreve.

🚪 Fechando o cadastro para estranhos

Repare numa coisa que passa batido: a instalação nasce com o cadastro aberto. Qualquer pessoa que descobrir o endereço da sua VPS pode criar uma conta e entrar. Numa documentação interna de empresa, isso é exatamente o que você não quer. 😬

Felizmente tem uma chave para isso, e ela já está no seu .env. Abra o arquivo:

cd /opt/nibleaf
nano .env

Ache a linha DISABLE_SIGNUP e troque o false por true:

DISABLE_SIGNUP=true

Salve com Ctrl+O, Enter, e saia com Ctrl+X. Agora mande o Docker aplicar a mudança:

docker compose -f docker-compose.prod.yml up -d

Espere uns 20 segundos e abra a página de cadastro. O formulário sumiu, e no lugar dele apareceu um aviso:

A página de cadastro do Nibleaf sem formulário, mostrando o aviso de que as inscrições estão desativadas nesta instância

Mas eu não confio em formulário que some. 🕵️‍♀️ Sumir da tela é uma coisa; recusar de verdade é outra. Fui bater direto na API de cadastro, que é por onde alguém mal-intencionado tentaria passar:

curl -X POST http://SEU_IP_AQUI:4310/api/auth/sign-up/email \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"SenhaQualquer123","name":"Invasor"}'

A resposta:

{
    "message": "Email and password sign up is not enabled",
    "code": "EMAIL_PASSWORD_SIGN_UP_DISABLED"
}

HTTP 400. Bloqueado no servidor, não só escondido na tela. Essa é a diferença que importa: se estivesse só sumindo o formulário, bastaria mandar a requisição na mão para criar a conta assim mesmo. 🔒

🙈 Sumindo com a página de propaganda

Tem outra coisa que incomoda. O endereço da sua instalação não abre no login: abre numa página de vendas do Nibleaf Cloud, com "Your docs, hosted and ready", preços e blog. Faz sentido para quem vende o serviço; não faz nenhum para uma documentação interna.

E não é só a raiz. Fui olhar quantas páginas dessas ficam expostas:

for r in '' pricing about blog contact self-hosting cloud terms privacy; do
  curl -s -o /dev/null -w "/: %{http_code}\n" http://127.0.0.1:4310/
done
/: 200
/pricing: 200
/about: 200
/blog: 200
/contact: 200
/self-hosting: 200
/cloud: 200
/terms: 200
/privacy: 200

Nove páginas. E aqui vem a parte chata: não existe variável no .env para desligar isso. Procurei no arquivo de instalação inteiro e a própria documentação do projeto confirma, ao descrever o contêiner app como "site de marketing, painel, editor e sites publicados" na mesma peça. Está tudo junto de propósito.

A saída é pôr um porteiro na frente: um programinha que recebe a visita antes do Nibleaf e decide o que deixa passar. Ele se chama proxy reverso, e o mais simples de configurar é o Caddy. Crie a pasta e o arquivo de regras:

mkdir -p /opt/nibleaf-porta
nano /opt/nibleaf-porta/Caddyfile

Cole isto dentro (com Ctrl+Shift+V, lembrando que Ctrl+V não funciona no terminal):

:4311 {
	# A raiz manda direto para a tela de login.
	redir / /sign-in 302

	# Cada pagina de propaganda tambem vai para o login.
	redir /pricing /sign-in 302
	redir /about /sign-in 302
	redir /blog /sign-in 302
	redir /contact /sign-in 302
	redir /cloud /sign-in 302
	redir /self-hosting /sign-in 302

	# O cadastro fica bloqueado pela porta tambem.
	redir /sign-up /sign-in 302

	# Todo o resto passa para o Nibleaf normalmente.
	reverse_proxy 172.17.0.1:4310 {
		# Sem esta linha o Nibleaf devolve 404 em tudo. Ele decide o
		# que servir pelo endereco que o navegador pediu, e um
		# endereco que ele nao conhece cai no roteamento de sites
		# publicados, nao no painel.
		header_up Host SEU_IP_AQUI:4310
	}
}

Salve, saia, e suba o Caddy:

docker run -d --name nibleaf-porta --restart unless-stopped \
  -p 4311:4311 \
  -v /opt/nibleaf-porta/Caddyfile:/etc/caddy/Caddyfile:ro \
  caddy:2-alpine

Libere a porta nova no firewall e teste as duas lado a lado:

ufw allow 4311/tcp
for r in '' pricing about sign-up sign-in app; do
  printf '/%-9s ' ""
  curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' http://127.0.0.1:4311/
done
/          302 -> http://127.0.0.1:4311/sign-in
/pricing   302 -> http://127.0.0.1:4311/sign-in
/about     302 -> http://127.0.0.1:4311/sign-in
/sign-up   302 -> http://127.0.0.1:4311/sign-in
/sign-in   200 ->
/app       200 ->

Toda propaganda cai no login, e login e painel continuam respondendo normalmente. Abrindo a porta 4311 no navegador, é isto que a visita encontra agora:

O endereço raiz da instalação abrindo direto na tela de login do Nibleaf, sem a página de vendas

Porta de entrada, só. 🚪

Aquele comentário no meio do arquivo de regras merece atenção, porque me custou um bom tempo. Na primeira tentativa eu tinha escrito só reverse_proxy 172.17.0.1:4310, do jeito que todo tutorial de Caddy ensina. Os redirecionamentos funcionaram de primeira, mas /sign-in e /app passaram a devolver 404. 😵

Levei um tempo procurando erro no Caddy até desconfiar do outro lado. O Nibleaf usa o endereço que o navegador pediu (o cabeçalho Host) para decidir o que servir: painel ou site de documentação publicado. Quando o proxy repassa o pedido, ele troca esse endereço pelo do contêiner, que o Nibleaf não reconhece, e aí a requisição cai no roteamento de sites publicados. Como não existe site naquele endereço, sai 404. Dá para ver o comportamento isolado, sem proxy nenhum:

# Com o endereco que o Nibleaf conhece:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: SEU_IP_AQUI:4310' http://172.17.0.1:4310/sign-in
# 200

# Com um endereco qualquer:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: 172.17.0.1:4310' http://172.17.0.1:4310/sign-in
# 404

O header_up Host é o que conserta: ele manda o proxy repassar o endereço que o Nibleaf espera. É uma linha só, e sem ela nada funciona. 🎯

📊 Quanto custa isso de máquina

A pergunta que eu mais queria responder: seis contêineres parecem muito para uma VPS pequena. Pedi os números ao Docker.

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}"
NAME                  MEM USAGE / LIMIT
nibleaf-app-1         26.94MiB / 7.755GiB
nibleaf-server-1      164.4MiB / 7.755GiB
nibleaf-worker-1      116.4MiB / 7.755GiB
nibleaf-postgres-1    29.34MiB / 7.755GiB
nibleaf-dragonfly-1   15.01MiB / 7.755GiB
nibleaf-maxio-1        1.35MiB / 7.755GiB

Cerca de 350 MB no total, em repouso. Isso é bem menos do que eu esperava, e é a prova de que o número assustador dos 5 a 6 GB é do build, não da execução. Quem baixa a imagem pronta nunca encosta nesse teto.

No disco, o custo é maior:

ghcr.io/lord007tn/nibleaf:v0.1.1                       2GB
postgres:17-alpine                                   424MB
docker.dragonflydb.io/dragonflydb/dragonfly:v1.39.0   196MB
coollabsio/maxio:0.4.2                               140MB

Uns 2,7 GB de imagens. No meu servidor, o disco em uso passou de 11 GB para 14 GB somando os volumes de dados. Numa VPS com 20 GB de disco isso é uma mordida considerável; vale conferir o espaço livre com df -h / antes de começar, não depois.

🧭 Os comandos que você vai usar toda semana

Todos rodam de dentro de /opt/nibleaf, e todos precisam do -f docker-compose.prod.yml, senão o Docker procura o arquivo padrão (aquele que compila do fonte) e reclama que não existe.

docker compose -f docker-compose.prod.yml ps        # o que esta de pe
docker compose -f docker-compose.prod.yml logs -f  # ver os registros ao vivo
docker compose -f docker-compose.prod.yml restart  # reiniciar
docker compose -f docker-compose.prod.yml up -d    # aplicar mudanca no .env
docker compose -f docker-compose.prod.yml down     # parar tudo

Se digitar tudo isso cansar, crie um atalho: alias nib='docker compose -f /opt/nibleaf/docker-compose.prod.yml'. Aí nib ps resolve.

Uma última coisa que me deixou tranquila: o ps mostra que só o app publica porta para fora. O banco, a fila e o armazenamento ficam numa rede interna do Docker, invisíveis para a internet. Não é você que precisa lembrar de fechá-los: o arquivo de instalação já vem assim.

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Quanta RAM o Nibleaf consome de verdade?
Os seis contêineres juntos ficaram em cerca de 350 MB em repouso: 164 MB no server, 116 MB no worker, 27 MB no app, 29 MB no Postgres, 15 MB no Dragonfly e 1,3 MB no maxio. O que pede muita memória é o build a partir do fonte, de 5 a 6 GB, e é por isso que se usa a imagem pronta.
Por que o contêiner recusa subir com BETTER_AUTH_SECRET vazio?
Nem chega a subir: quem barra é o próprio Compose. O arquivo usa a sintaxe ${VAR:?mensagem}, que aborta a leitura do YAML quando a variável está vazia. Gere o valor com openssl rand -hex 32.
O que significa o erro "Invalid origin" na tela de login?
O endereço pelo qual você abriu o painel não está na lista TRUSTED_ORIGINS, que por padrão vale só o que estiver em APP_URL. A API responde 403 com {"code": "INVALID_ORIGIN"}. A correção é listar no .env todos os endereços que você usa, separados por vírgula.
Preciso de um bucket S3 externo para usar o Nibleaf?
Não. A stack já traz um serviço de armazenamento compatível com S3 chamado maxio, que cria o bucket sozinho. Você só precisa inventar um par de chaves no .env: é com elas que o próprio maxio nasce.
Por que o download da imagem falhou no meio?
A imagem tem 2 GB e o ghcr.io cortou a conexão em 320 MB, com connection reset by peer. Rodar o pull de novo resolve, porque o Docker reaproveita as camadas que já baixaram em vez de recomeçar do zero.
Como impedir que qualquer pessoa crie conta na minha instalação?
Troque DISABLE_SIGNUP=false por true no .env e rode docker compose -f docker-compose.prod.yml up -d. O bloqueio é de servidor, não só de tela: a API de cadastro passa a responder 400 com EMAIL_PASSWORD_SIGN_UP_DISABLED. Crie a sua conta antes de desligar, senão você fica trancado do lado de fora.
Dá para tirar a página de propaganda e deixar só o login?
Não existe variável para isso: o mesmo contêiner serve o site de marketing e o painel. A saída é pôr um proxy reverso na frente (o Caddy resolve em poucas linhas) redirecionando a raiz e as páginas de propaganda para /sign-in. Só não esqueça do header_up Host, senão o painel devolve 404.

Leia também