[{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/llm/","section":"Tags","summary":"","title":"Llm","type":"tags"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/","section":"Mario Neto","summary":"","title":"Mario Neto","type":"page"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/ollama/","section":"Tags","summary":"","title":"Ollama","type":"tags"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/opencode/","section":"Tags","summary":"","title":"Opencode","type":"tags"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/podman/","section":"Tags","summary":"","title":"Podman","type":"tags"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" Por que montar um hub próprio # Concentrar as conversas com LLMs em um único lugar, rodando na sua máquina, resolve três problemas de uma vez: o histórico espalhado, o que sai do seu computador e o custo de várias assinaturas.\nEu usava várias IAs em abas e aplicativos diferentes, e cada conversa ficava presa no serviço onde nasceu. Este artigo descreve a montagem que adotei: Ollama para rodar os modelos, Open WebUI como interface de chat e OpenCode como agente de código, tudo em containers com Podman rootless e uma placa AMD RX 9070 XT de 16 GB.\nRodar o Ollama, o Open WebUI e o OpenCode em containers também é uma decisão de segurança e controle. O container isola cada peça do sistema operacional em que ela roda e cria mais uma camada para evitar que a IA acesse o que não foi pedido, como arquivos fora do projeto, credenciais ou a rede. Isso não garante que nada aconteça, mas reduz o alcance de um erro do modelo ou de um comando mal pensado.\nDuas ressalvas desde já. O hub centraliza as conversas daqui para a frente, mas não importa sozinho o histórico antigo dos serviços de nuvem. E modelos locais de 14B a 20B parâmetros são úteis no dia a dia, mas erram mais em tarefas grandes do que os modelos de nuvem.\nComo as peças se encaixam # O navegador só fala com o Open WebUI, e o Open WebUI e o OpenCode só falam com o Ollama, pela rede interna. A rede egress é usada apenas pelo Ollama e pelo Open WebUI, para baixar modelos; o OpenCode, em destaque, não tem saída para a internet e só enxerga a pasta do projeto.\nEscolhendo as peças # Cada camada do hub tem mais de uma boa opção. Escolhi a de menor atrito para o dia a dia e deixei as alternativas anotadas.\nCamada Minha escolha Alternativas Motivo Interface de chat Open WebUI LibreChat, LobeChat, AnythingLLM Sobe com um único container e encontra o Ollama sozinho Inferência Ollama llama.cpp (llama-server), LM Studio Gerencia download, carga e descarga dos modelos Agente de código OpenCode Codex CLI, Aider, Goose, Crush Funciona com mais de 75 provedores e com modelos locais Execução Podman rootless Docker O root do container vira um usuário comum no host Três observações sobre essas escolhas:\nOllama ou llama.cpp. O llama.cpp é o motor por baixo do Ollama e dá mais controle, como cache KV quantizado e offload de partes do modelo. Em troca, você gerencia os arquivos GGUF e sobe um modelo por processo. Para o uso diário, a praticidade do Ollama pesou mais. ROCm ou Vulkan. No Ollama, uma placa AMD pode usar os dois caminhos, mas não faz sentido combiná-los para a mesma GPU. Os relatos de desempenho divergem, então a decisão certa é medir no seu hardware. OpenCode e assinaturas. O OpenCode não aceita login com uma assinatura do Claude. Para usar modelos Claude nele, é preciso chave de API com cobrança por token. Quem quer manter a assinatura continua melhor servido pelo Claude Code. Pré-requisitos no host # O Podman rootless só enxerga a GPU se o seu usuário tiver permissão nos dispositivos e se o SELinux deixar o container usá-los. Quatro ajustes, feitos uma única vez:\nColoque seu usuário nos grupos que protegem os dispositivos da GPU e faça login de novo: sudo usermod -aG render,video $USER Se o SELinux estiver ativo, permita que containers usem dispositivos: sudo setsebool -P container_use_devices on Instale o podman-compose (os comandos abaixo usam dnf; adapte à sua distribuição): sudo dnf install podman-compose Garanta o driver. O Vulkan usa o driver padrão (Mesa). O caminho ROCm exige o driver ROCm 7 no host, segundo a documentação de GPU do Ollama. Um detalhe que custa tempo: no Podman rootless, o container precisa do valor especial keep-groups (--group-add keep-groups), que repassa os grupos reais do seu usuário. Passar video ou render pelo nome costuma falhar, porque o nome é procurado dentro do container, onde esses grupos podem não existir.\nOllama e Open WebUI com compose # O compose sobe só o Ollama e o Open WebUI. O OpenCode fica fora de propósito, porque ele é interativo e roda sob demanda (próxima seção). Duas redes separam o que pode sair do que não pode: backend, sem acesso à internet, e egress, usada apenas para baixar modelos.\nEsta é a variante com Vulkan (compose.vulkan.yml):\nname: hub-llm services: ollama: image: docker.io/ollama/ollama container_name: ollama restart: unless-stopped devices: - /dev/dri:/dev/dri group_add: - keep-groups environment: - OLLAMA_VULKAN=1 - OLLAMA_CONTEXT_LENGTH=32768 - OLLAMA_FLASH_ATTENTION=1 - OLLAMA_KV_CACHE_TYPE=q8_0 - OLLAMA_KEEP_ALIVE=10m volumes: - ollama:/root/.ollama networks: [backend, egress] openwebui: image: ghcr.io/open-webui/open-webui:main container_name: openwebui restart: unless-stopped depends_on: - ollama ports: - \u0026#34;127.0.0.1:3000:8080\u0026#34; environment: - OLLAMA_BASE_URL=http://ollama:11434 - ENABLE_SIGNUP=${ENABLE_SIGNUP:-true} volumes: - openwebui:/app/backend/data networks: [backend, egress] networks: backend: name: hub-llm-backend internal: true egress: name: hub-llm-egress volumes: ollama: openwebui: A variante com ROCm (compose.rocm.yml) muda apenas o serviço ollama: a imagem passa a ser docker.io/ollama/ollama:rocm, o dispositivo /dev/kfd:/dev/kfd é adicionado ao lado de /dev/dri, e a variável OLLAMA_VULKAN=1 sai. Mantenha só um dos dois arquivos de pé por vez; os volumes são os mesmos, então dá para alternar sem baixar os modelos de novo.\nPara subir e baixar um modelo:\npodman-compose -f compose.vulkan.yml up -d podman exec -it ollama ollama pull qwen3:14b A interface fica em http://127.0.0.1:3000. A porta é publicada só no endereço local, de propósito. No primeiro acesso você cria a conta de administrador; depois, defina ENABLE_SIGNUP=false e suba o compose de novo para fechar o cadastro.\nPara confirmar que a GPU está em uso, rode podman exec ollama ollama ps e veja se a coluna PROCESSOR mostra GPU.\nIsolando o OpenCode # O OpenCode é o único componente que executa comandos e altera arquivos, então é nele que o isolamento importa. A regra é simples: ele enxerga a pasta do projeto, a sua configuração e o Ollama, e mais nada.\nUsei a imagem publicada pelo próprio projeto, ghcr.io/anomalyco/opencode. Ao inspecionar a imagem, vi que ela roda como root (com HOME=/root), traz o rg e não traz o git. A falta do git na imagem é um problema conhecido, e recursos do OpenCode, como identificar o projeto e acompanhar alterações, dependem dele. Como o container não tem internet, o git precisa entrar na própria imagem. Um Containerfile de três linhas resolve:\nARG OPENCODE_TAG=latest FROM ghcr.io/anomalyco/opencode:${OPENCODE_TAG} RUN apk add --no-cache git \\ \u0026amp;\u0026amp; git config --system --add safe.directory /workspace Fixe uma versão no lugar de latest. Um agente com acesso ao seu projeto não deve mudar de comportamento sem aviso.\nA execução é um podman run com o essencial para isolar:\npodman run --rm -it \\ --network hub-llm-backend \\ --cap-drop=ALL \\ --security-opt no-new-privileges \\ --memory 4g --pids-limit 512 \\ -w /workspace \\ -v \u0026#34;$PROJECT_DIR\u0026#34;:/workspace:Z \\ -v ~/.config/hub-llm/opencode.json:/etc/opencode/opencode.json:ro,z \\ -e OPENCODE_CONFIG=/etc/opencode/opencode.json \\ -v opencode-data:/root/.local/share/opencode \\ -v opencode-cache:/root/.cache/opencode \\ localhost/opencode-hub:\u0026lt;tag\u0026gt; O que cada parte faz:\n--network hub-llm-backend: a rede interna, sem saída para a internet. O container só alcança o Ollama. --cap-drop=ALL e no-new-privileges: sem capacidades extras e sem escalonamento de privilégio. :Z: o SELinux reetiqueta a pasta do projeto para o container. Use pastas de projeto dedicadas, porque tudo dentro da pasta montada é reetiquetado. Sem --userns=keep-id: a imagem roda como root, e no Podman rootless o root do container corresponde ao seu usuário no host. Os arquivos que o agente cria continuam sendo seus. OPENCODE_CONFIG: aponta o arquivo de configuração de forma explícita. Assim o OpenCode encontra o opencode.json mesmo que o $HOME da imagem mude, em vez de depender de /root/.config/opencode/. Eu embrulhei isso em um script chamado opencode, colocado em ~/.local/bin. Sem argumentos, ele abre na pasta atual; com um caminho, abre naquela pasta. Como é fácil abrir na pasta errada, o script também recusa a $HOME, pastas de sistema e pastas de credenciais (.ssh, .kube, .aws e similares), e pede confirmação quando a pasta não é a raiz de um repositório git. O script, o Containerfile e o opencode.json ficam em ~/.config/hub-llm/.\nApontando o OpenCode para o Ollama # O arquivo opencode.json declara o Ollama como um provedor compatível com a API da OpenAI e escolhe o modelo padrão:\n{ \u0026#34;$schema\u0026#34;: \u0026#34;https://opencode.ai/config.json\u0026#34;, \u0026#34;model\u0026#34;: \u0026#34;ollama/qwen3:14b\u0026#34;, \u0026#34;share\u0026#34;: \u0026#34;disabled\u0026#34;, \u0026#34;autoupdate\u0026#34;: false, \u0026#34;permission\u0026#34;: { \u0026#34;edit\u0026#34;: \u0026#34;allow\u0026#34;, \u0026#34;bash\u0026#34;: \u0026#34;ask\u0026#34; }, \u0026#34;provider\u0026#34;: { \u0026#34;ollama\u0026#34;: { \u0026#34;npm\u0026#34;: \u0026#34;@ai-sdk/openai-compatible\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Ollama (container)\u0026#34;, \u0026#34;options\u0026#34;: { \u0026#34;baseURL\u0026#34;: \u0026#34;http://ollama:11434/v1\u0026#34; }, \u0026#34;models\u0026#34;: { \u0026#34;qwen3:14b\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;Qwen3 14B\u0026#34;, \u0026#34;limit\u0026#34;: { \u0026#34;context\u0026#34;: 32768, \u0026#34;output\u0026#34;: 8192 } }, \u0026#34;gpt-oss:20b\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;gpt-oss 20B\u0026#34;, \u0026#34;limit\u0026#34;: { \u0026#34;context\u0026#34;: 32768, \u0026#34;output\u0026#34;: 8192 } } } } } } Pontos que merecem atenção:\nOs nomes dos modelos precisam ser idênticos aos de ollama list. O limit.context deve acompanhar o OLLAMA_CONTEXT_LENGTH do compose. O padrão do Ollama costuma ser pequeno demais para um agente de código. share: disabled impede a criação de links públicos de conversa, e autoupdate: false faz sentido num container sem internet. Com bash: ask, cada comando de shell pede confirmação. Se achar demais, mude para allow: o container é isolado e o projeto está no git. O esquema de configuração do OpenCode muda entre versões. Se uma chave for rejeitada, a mensagem de erro na abertura indica qual. Skills, plugins e o modo online # O OpenCode fica sem internet no dia a dia, mas algumas extensões precisam dela. A saída é separar o que é só arquivo do que precisa de download.\nSkills são pastas com um SKILL.md e não precisam de rede. Baixe no host, leia o conteúdo e coloque em ~/.config/hub-llm/skills/. O script monta essa pasta, somente leitura, em /root/.config/opencode/skills/. Skills de um projeto específico podem ficar em .opencode/skills/ dentro do repositório. Plugins locais (arquivos .js ou .ts) seguem o mesmo caminho, em ~/.config/hub-llm/plugins/. Plugins npm, declarados na chave \u0026quot;plugin\u0026quot; do opencode.json, são instalados pelo OpenCode com Bun na inicialização e ficam em ~/.cache/opencode/node_modules/. Esse caminho é o volume opencode-cache, então basta ter internet uma vez. Para esse último caso, o script tem um modo online. Ele pede confirmação e vale só para aquela sessão:\nOPENCODE_ONLINE=1 opencode Nesse modo o container entra também na rede hub-llm-egress. Instale o plugin, saia e volte a usar só opencode: o plugin continua no cache e funciona offline.\nPor que não deixar online sempre? Usar um modelo local não elimina o risco, porque quem executa comandos é o agente. Com internet, um prompt injection escondido num README ou numa dependência pode levar o agente a enviar o conteúdo da pasta para fora, e modelos de 14B a 20B caem nesse tipo de truque com mais facilidade. Sem rede, o pior caso é estragar a pasta, e o git reverte. Com rede, o pior caso é vazar a pasta, e isso não tem volta.\nProblemas que encontrei # Quase todos os tropeços vieram da diferença entre Docker e Podman rootless. Registrrei os principais para você não repetir mesmo caminho quue segui.\nSintoma Causa Solução Unable to find group keep-groups ao subir o compose O docker-compose estava falando com o socket do Podman, e a API de compatibilidade trata keep-groups como um grupo comum Usar o podman-compose, ou forçar com PODMAN_COMPOSE_PROVIDER=podman-compose Container sem acesso à GPU, ou erro de grupo inexistente group_add com video e render pelo nome no rootless Usar keep-groups e colocar o usuário nos grupos do host Containerfile.opencode: no such file or directory O script foi movido para /usr/local/bin sem os arquivos de apoio Procurar os arquivos em ~/.config/hub-llm/ Sem git dentro do container A imagem oficial não inclui o git Imagem derivada com apk add git Não consigo criar a primeira conta no Open WebUI ENABLE_SIGNUP=false desde o início bloqueia o cadastro do administrador Começar com true e fechar depois Os modelos do Ollama não aparecem no /models do OpenCode, só os gratuitos do OpenCode Zen O opencode.json foi montado num caminho que o OpenCode não lê: o script antigo usava /home/node/.config/opencode/, mas a imagem oficial usa HOME=/root Montar em /etc/opencode/opencode.json e passar -e OPENCODE_CONFIG=/etc/opencode/opencode.json. Para conferir: opencode debug config O provider ollama some sem mensagem de erro clara JSON inválido no opencode.json, como uma } faltando ao fim de cada modelo Validar antes de usar: python3 -m json.tool opencode.json Um ponto extra de atenção: se o OpenCode precisar baixar algum pacote na primeira execução, como o do provedor ou um plugin, use o modo OPENCODE_ONLINE=1 só nessa vez. O que for instalado fica no volume de cache, e as próximas sessões voltam a rodar sem internet.\nPara quem usa SELinux, vale lembrar o :Z nos bind mounts e o booleano container_use_devices. Sem eles, os erros de permissão aparecem como \u0026ldquo;permission denied\u0026rdquo; sem muita pista.\nModelos para 16 GB de VRAM # Com 16 GB, o ponto de partida são modelos de 14B a 20B parâmetros, que cabem inteiros na GPU. Dois que uso como base: qwen3:14b e gpt-oss:20b.\nModelos maiores, como as variantes de 30B, tendem a exceder a VRAM e a ir parcialmente para a RAM. O resultado funciona, mas fica bem mais lento. Três cuidados ajudam a manter tudo na GPU:\nContexto. Quanto maior o contexto, maior o cache KV. Se faltar VRAM, reduza OLLAMA_CONTEXT_LENGTH antes de trocar de modelo. Cache KV quantizado. OLLAMA_KV_CACHE_TYPE=q8_0, que exige OLLAMA_FLASH_ATTENTION=1, reduz o espaço ocupado pelo contexto. Medição. Rode o mesmo prompt em cada backend e compare a taxa de geração: podman exec -it ollama ollama run --verbose qwen3:14b \u0026#34;explique o que é um operator no OpenShift\u0026#34; O campo eval rate no final mostra os tokens por segundo. Descarte a primeira execução, que inclui o carregamento do modelo. É assim que se decide entre ROCm e Vulkan na sua máquina, em vez de confiar em números de terceiros.\nUma expectativa realista para o agente de código: modelos locais dessa faixa dão conta de tarefas pequenas e médias, como explicar um repositório, escrever testes e fazer ajustes localizados. Em mudanças grandes e no uso encadeado de ferramentas, eles erram mais do que os modelos de nuvem. Comece com pedidos curtos e específicos, use o agente em modo de plano (tecla Tab) quando não tiver certeza do que ele vai fazer e faça commit antes de cada sessão.\nSegurança, limites e próximos passos # O isolamento reduz o risco, mas não o elimina. Os limites que eu tenho em mente:\nContainer não é máquina virtual. Ele compartilha o kernel do host. O Podman rootless ajuda, porque um escape cai em um usuário sem privilégios, mas para um isolamento mais forte considere rodar tudo em uma VM dedicada. A pasta montada é lida e alterada pelo agente. Mantenha segredos fora dela e faça commit antes de cada sessão para poder reverter. A porta do Open WebUI fica só em 127.0.0.1. Para acessar de outra máquina, prefira um túnel SSH (ssh -L 3000:127.0.0.1:3000 usuario@host) a abrir a porta na rede. Recursos de execução de código e ferramentas do Open WebUI. Se não for usar, deixe desligados. Modelos locais têm limites de qualidade. Para trabalho pesado, o hub convive bem com provedores de nuvem. O caminho mais seguro é um gateway como o LiteLLM, ligado às redes backend e egress: o OpenCode continua sem internet e fala só com o gateway, e as chaves de API ficam fora do container do agente. Mesmo assim, o conteúdo da pasta do projeto que entra no contexto vai para o provedor. Próximos passos possíveis: testar o llama.cpp com Vulkan no lugar do Ollama para ganhar controle sobre a VRAM, e converter os arquivos para Quadlet, para que o systemd gerencie os containers e eles subam junto com o sistema.\nO resultado prático é um lugar único para conversar com modelos e programar com um agente, rodando na minha máquina, com o agente confinado à pasta do projeto. Os arquivos completos (compose, Containerfile, script e opencode.json) são curtos e reproduzíveis.\nArquivos # Os arquivos usados neste post, prontos para baixar:\ncompose.vulkan.yml: Ollama (Vulkan) e Open WebUI compose.rocm.yml: Ollama (ROCm) e Open WebUI Containerfile.opencode: imagem oficial do OpenCode com git run-opencode.sh: o script opencode, com o modo online opencode.json: configuração do OpenCode apontando para o Ollama Fontes # Ollama: documentação de Docker Ollama: seleção e suporte de GPU Red Hat: Podman com aceleradores AMD ROCm e SELinux Rodando containers com GPU AMD no Podman (keep-groups) Podman: guia de solução de problemas (SELinux, :Z, keep-id) OpenCode: repositório do projeto OpenCode: issue sobre a ausência do git na imagem ","date":"21 de outubro de 2026","externalUrl":null,"permalink":"/posts/hub-ia-privado-podman-rootless/","section":"Posts","summary":"","title":"Um hub de IA privado com Podman rootless","type":"posts"},{"content":"Quando decidi montar meu próprio site no GitHub Pages, eu já tinha alguns artigos publicados no Medium. Copiar e colar cada um à mão estava fora de cogitação: bloco de código perde a formatação, imagem fica para trás e sempre escapa algum detalhe. Eu queria trazer tudo de uma vez, em Markdown limpo e com o mínimo de retrabalho.\nNeste artigo mostro o caminho que usei: o export oficial do Medium e um pequeno script Python, o medium-export2md, que transforma esse export em posts prontos para Hugo ou Jekyll.\nPor que ter seus textos fora do Medium? # O conteúdo é seu: em Markdown e versionado no Git, ele não depende de nenhuma plataforma. Domínio próprio: os links apontam para o seu endereço, e a audiência que você constrói fica com você. Liberdade: tema, idiomas, diagramas, layout\u0026hellip; no seu site, quem decide é você. Custo zero: o GitHub Pages hospeda sites estáticos de graça. Nada impede de continuar publicando no Medium. A ideia é ter a sua casa e levar para ela o que você já escreveu.\nAntes de tudo: nada de scraping # A primeira ideia de muita gente é raspar as páginas do Medium. Isso funciona até a próxima mudança no HTML deles, sem contar o bloqueio a bots e o paywall. Não vale o esforço, porque o próprio Medium entrega tudo o que você escreveu num arquivo .zip.\nPasso 1: exporte seus dados do Medium # No Medium, clique na sua foto de perfil e vá em Settings. Abra a aba Security and apps. Clique em Download your information e confirme em Export. Quando o arquivo fica pronto, chega um e-mail com o link para baixar o medium-export.zip. O zip traz bem mais do que os posts: perfil, listas, histórico de leitura, sessões e até IPs. Guarde esse arquivo com cuidado e nunca o coloque no repositório do site. O que interessa aqui é a pasta posts:\nmedium-export/ ├── posts/ │ ├── 2026-06-16_Taming-the-Chaos--Mastering-Log-Management-in-OpenShift-at-Scale-e14af3701206.html │ ├── 2026-09-09_IA-Local-com-Podman-e-Fedora-44--...-50d17c83f46a.html │ └── draft_numCoresPerSocket-on-OpenShift--...-bd0e3ef554bc.html ├── profile/ ├── lists/ ├── reading-history/ └── ... Cada post vira um arquivo HTML. Os publicados começam com a data e os rascunhos com draft_. Os comentários que você deixou em textos de outras pessoas também aparecem nessa pasta, misturados com os seus posts.\nO problema: esse HTML não é Markdown # Dá para abrir os arquivos no navegador, mas passar um conversor genérico de HTML para Markdown neles gera bastante retrabalho. Só nos meus posts eu encontrei:\nTítulo duplicado: ele aparece no cabeçalho e de novo no corpo do texto. Código picotado: cada linha vem quebrada com \u0026lt;br\u0026gt; e cheia de \u0026lt;span\u0026gt; de highlight. Linguagem errada nos blocos de código: a detecção automática do Medium marcou comandos de shell como typescript, cpp e até lua, e a saída de um lscpu como scss. Rótulos soltos: aquele \u0026ldquo;YAML\u0026rdquo; ou \u0026ldquo;bash\u0026rdquo; que vem junto quando você cola código de outro lugar vira um parágrafo antes do bloco. Links sujos: parâmetros de rastreamento como ?source=... e até o ponto final da frase dentro do link. Imagens no CDN do Medium, e não no seu repositório. Nenhuma tag: o export simplesmente não traz as tags dos posts. Subtítulo automático: quando você não escreve um, o Medium usa as primeiras palavras do texto com \u0026ldquo;…\u0026rdquo; no final. Resolver isso post a post é exatamente o tipo de trabalho repetitivo que vale automatizar.\nPasso 2: conheça o medium-export2md # O medium-export2md é um script de um arquivo só, que usa apenas a biblioteca padrão do Python. Basta ter Python 3.9 ou mais novo, que já vem na maioria das distribuições Linux e no macOS. Ele:\nlê o zip direto, sem precisar descompactar, e pega só os seus posts, ignorando os comentários; gera um Markdown por post com front matter (data, slug, título, descrição e tags); remove o título duplicado e usa o seu subtítulo, ou as primeiras frases do texto, como description; reconstrói os blocos de código com a linguagem certa, junta blocos que o Medium partiu em dois e descarta os rótulos soltos; baixa as imagens para junto do post e mantém as legendas; transforma links entre os seus próprios posts em links internos do site; reconhece traduções: se um post em português aponta logo no começo para a versão em inglês, os dois viram uma página só, com dois idiomas; escapa o que quebraria o build, como sintaxe de shortcode do Hugo ou {{ }} do Liquid dentro de blocos de código. Para baixar:\ngit clone https://github.com/mariorpn/medium-export2md.git cd medium-export2md Passo 3: converta # Comece sempre com --dry-run, que mostra o que seria gravado sem tocar em nada:\npython3 medium-export2md.py ~/Downloads/medium-export.zip -o ~/meu-site --dry-run Estando tudo certo, é só rodar de verdade. Este é o comando que usei no meu site, que é um Hugo bilíngue (português como idioma padrão, mais o inglês):\npython3 medium-export2md.py ~/Downloads/medium-export.zip -o ~/mariorpn.github.io \\ --lang-codes pt=pt-br,en=en \\ --tz America/Sao_Paulo \\ --include-drafts \\ --tags tags.txt O que cada opção faz:\n-o: a raiz do seu site. No Hugo, os posts vão para content/posts/. --lang-codes pt=pt-br,en=en: liga o modo multilíngue. O script detecta o idioma de cada post e grava index.pt-br.md ou index.en.md, com os códigos de idioma do seu site. O primeiro da lista é o idioma padrão. --tz: o fuso horário das datas no front matter. --include-drafts: traz também os rascunhos, marcados com draft: true. O Hugo só publica quando você muda para false. --tags: como o export não tem tags, você informa as tags num arquivo de texto. O tags.txt tem uma linha por post, usando o ID que fica no fim da URL do Medium (.../taming-the-chaos-...-e14af3701206):\ne14af3701206: openshift, logging, loki, observability 50d17c83f46a: linux, fedora, podman, ollama, inteligência artificial A saída fica assim:\nwrite [post, en] content/posts/20260616-taming-the-chaos-mastering-log-management-in-openshift-at-scale/index.en.md write [post, pt] content/posts/20260909-ia-local-com-podman-e-fedora-44-dominando-o-ollama-e-open-webui-com-igpu-amd-radeon-780m/index.pt-br.md write [post, en] content/posts/20260909-ia-local-com-podman-e-fedora-44-dominando-o-ollama-e-open-webui-com-igpu-amd-radeon-780m/index.en.md write [draft, en] content/posts/20261018-numcorespersocket-on-openshift-the-complete-guide-for-vsphere-7-and-8/index.en.md 4 file(s) written, 0 skipped. Repare que o post do Ollama, que eu tinha publicado em português e em inglês, foi parar na mesma pasta, com os dois idiomas.\nCada post vira um page bundle do Hugo, com o texto e as imagens lado a lado:\ncontent/posts/20261018-numcorespersocket-on-openshift-the-complete-guide-for-vsphere-7-and-8/ ├── index.en.md ├── img-01.png ├── img-02.png └── ... E o front matter já sai no formato que o Hugo espera:\n--- date: \u0026#39;2026-06-16T16:57:31-03:00\u0026#39; draft: false slug: \u0026#34;taming-the-chaos-mastering-log-management-in-openshift-at-scale\u0026#34; title: \u0026#34;Taming the Chaos: Mastering Log Management in OpenShift at Scale\u0026#34; description: \u0026#34;Logs are the heartbeat of any system. They are the diary of your infrastructure, telling the story of what is happening under the hood.\u0026#34; tags: [\u0026#34;openshift\u0026#34;, \u0026#34;logging\u0026#34;, \u0026#34;loki\u0026#34;, \u0026#34;observability\u0026#34;] --- O resultado no site, com o tema Blowfish:\nPost importado do Medium: os blocos de código mantêm a indentação e ganham highlight Usa Jekyll? # O Jekyll é o gerador padrão do GitHub Pages, e o script também atende:\npython3 medium-export2md.py ~/Downloads/medium-export.zip -o ~/meu-site-jekyll --format jekyll Os posts vão para _posts/AAAA-MM-DD-slug.md, os rascunhos para _drafts/ e as imagens para assets/images/posts/. O suporte a Jekyll ainda é beta: a saída segue a sintaxe do kramdown, mas foi bem menos testada que a do Hugo.\nRodar o script de novo é seguro, porque arquivos que já existem são pulados. Se quiser sobrescrever, use --force.\nPasso 4: revise, teste e publique # Antes de publicar, suba o site localmente:\nhugo server -D O -D inclui os rascunhos. Abra http://localhost:1313 e passe pelos posts. No Jekyll, o equivalente é bundle exec jekyll serve --drafts.\nVale conferir com calma:\nA descrição: quando o post não tinha subtítulo, o script usa as primeiras frases do texto. Às vezes vale reescrever. As tags: ajuste no tags.txt antes de converter ou direto no front matter. O próprio conteúdo: reler os posts antigos durante a mudança é uma ótima hora para corrigir erros de digitação e informações desatualizadas. Com tudo certo, é commit e push:\ngit add content/posts git commit -m \u0026#34;Importa posts do Medium\u0026#34; git push No meu caso, um workflow do GitHub Actions faz o build do Hugo e publica no GitHub Pages a cada push na branch main. No Jekyll, o próprio GitHub Pages cuida do build.\nPegadinhas que encontrei no caminho # Post em um idioma só não aparece no outro: num site Hugo multilíngue, cada idioma tem a sua própria lista de posts. Meu artigo sobre logs no OpenShift, que só existia em inglês, não aparecia na versão em português do site até eu traduzi-lo. Imagens: o script baixa as imagens do CDN do Medium. Se o download falhar, ele mantém a URL original e avisa no terminal, então o post continua funcionando e você pode rodar de novo depois. O zip fica fora do repositório: ele tem dados pessoais, e o site só precisa dos Markdown gerados. Conclusão # Com o export oficial e um script, a migração que parecia trabalhosa virou um comando. O resultado é Markdown limpo, versionado no Git e publicado no seu domínio. A partir daí, cada post novo é só mais um arquivo no repositório.\nO medium-export2md está no GitHub. Se você usar e encontrar algum caso que ele não trata bem, abre uma issue por lá!\n","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/posts/do-medium-para-o-github-pages/","section":"Posts","summary":"","title":"Do Medium para o GitHub Pages: como trazer seus posts sem dor de cabeça","type":"posts"},{"content":"","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/tags/github-pages/","section":"Tags","summary":"","title":"Github Pages","type":"tags"},{"content":"","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/tags/markdown/","section":"Tags","summary":"","title":"Markdown","type":"tags"},{"content":"","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/tags/medium/","section":"Tags","summary":"","title":"Medium","type":"tags"},{"content":"","date":"18 de outubro de 2026","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"17 de outubro de 2026","externalUrl":null,"permalink":"/tags/ansible/","section":"Tags","summary":"","title":"Ansible","type":"tags"},{"content":" E aí!? # ","date":"17 de outubro de 2026","externalUrl":null,"permalink":"/posts/ola-mundo/","section":"Posts","summary":"","title":"Olá, Mundo!","type":"posts"},{"content":"","date":"17 de outubro de 2026","externalUrl":null,"permalink":"/tags/openshift/","section":"Tags","summary":"","title":"Openshift","type":"tags"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","externalUrl":null,"permalink":"/about/","section":"Mario Neto","summary":"","title":"Sobre","type":"page"}]