[{"content":" Why build a hub of your own # Keeping all your LLM conversations in one place, running on your own machine, fixes three problems at once: scattered history, what leaves your computer, and the cost of several subscriptions.\nI used to bounce between AIs in different tabs and apps, and every conversation stayed stuck in the service where it started. This article describes the setup I settled on: Ollama to run the models, Open WebUI as the chat interface, and OpenCode as a coding agent, all in containers with rootless Podman and a 16 GB AMD RX 9070 XT.\nRunning Ollama, Open WebUI and OpenCode in containers is also a decision about security and control. The container isolates each piece from the operating system it runs on and adds one more layer to keep the AI away from what it was not asked to touch, such as files outside the project, credentials or the network. It does not guarantee that nothing can happen, but it narrows the reach of a model mistake or a careless command.\nTwo caveats up front. The hub centralizes conversations going forward, but it does not import your old history from cloud services by itself. And local models in the 14B to 20B parameter range are useful day to day, but they make more mistakes on large tasks than cloud models do.\nHow the pieces fit together # The browser talks only to Open WebUI, and Open WebUI and OpenCode talk only to Ollama, over the internal network. The egress network is used only by Ollama and Open WebUI, to download models; OpenCode, highlighted, has no way out to the internet and sees only the project folder.\nChoosing the pieces # Every layer of the hub has more than one good option. I picked the lowest-friction one for daily use and noted the alternatives.\nLayer My pick Alternatives Why Chat interface Open WebUI LibreChat, LobeChat, AnythingLLM One container, and it finds Ollama on its own Inference Ollama llama.cpp (llama-server), LM Studio Handles model download, loading and unloading Coding agent OpenCode Codex CLI, Aider, Goose, Crush Works with 75+ providers and with local models Runtime Rootless Podman Docker Root inside the container maps to a regular user on the host Three notes on those choices:\nOllama or llama.cpp. llama.cpp is the engine under Ollama and gives you more control, such as a quantized KV cache and partial offloading. In exchange, you manage the GGUF files yourself and run one model per server process. For daily use, Ollama\u0026rsquo;s convenience won. ROCm or Vulkan. On an AMD card, Ollama can use either path, but it makes no sense to combine them on the same GPU. Performance reports disagree, so the right call is to measure on your own hardware. OpenCode and subscriptions. OpenCode does not accept a login from a Claude subscription. Using Claude models in it requires an API key billed per token. If you want to keep your subscription, Claude Code is still the better fit. Host prerequisites # Rootless Podman only sees the GPU if your user has permission on the devices and SELinux lets the container use them. Four one-time adjustments:\nAdd your user to the groups that guard the GPU devices, then log in again: sudo usermod -aG render,video $USER If SELinux is enabled, allow containers to use devices: sudo setsebool -P container_use_devices on Install podman-compose (the commands below use dnf; adapt them to your distribution): sudo dnf install podman-compose Make sure the driver is in place. Vulkan uses the standard driver (Mesa). The ROCm path needs the ROCm 7 driver on the host, according to Ollama\u0026rsquo;s GPU documentation. One detail that costs time: in rootless Podman, the container needs the special value keep-groups (--group-add keep-groups), which passes your user\u0026rsquo;s real groups through. Passing video or render by name often fails, because the name is looked up inside the container, where those groups may not exist.\nOllama and Open WebUI with compose # The compose file starts only Ollama and Open WebUI. OpenCode stays out on purpose, because it is interactive and runs on demand (next section). Two networks separate what may reach the outside from what may not: backend, with no internet access, and egress, used only to download models.\nThis is the Vulkan variant (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: The ROCm variant (compose.rocm.yml) changes only the ollama service: the image becomes docker.io/ollama/ollama:rocm, the /dev/kfd:/dev/kfd device is added next to /dev/dri, and OLLAMA_VULKAN=1 goes away. Keep only one of the two files up at a time; the volumes are shared, so you can switch back and forth without downloading the models again.\nTo start everything and pull a model:\npodman-compose -f compose.vulkan.yml up -d podman exec -it ollama ollama pull qwen3:14b The interface lives at http://127.0.0.1:3000. The port is published on the loopback address only, on purpose. On first visit you create the administrator account; after that, set ENABLE_SIGNUP=false and bring the stack up again to close registration.\nTo confirm the GPU is in use, run podman exec ollama ollama ps and check that the PROCESSOR column says GPU.\nIsolating OpenCode # OpenCode is the only component that runs commands and changes files, so that is where isolation matters. The rule is simple: it sees the project folder, its own configuration and Ollama, and nothing else.\nI used the image published by the project itself, ghcr.io/anomalyco/opencode. Inspecting it, I found that it runs as root (with HOME=/root), ships rg, and does not ship git. The missing git in the image is a known issue, and OpenCode features such as identifying the project and tracking changes depend on it. Because the container has no internet access, git has to go into the image itself. A three-line Containerfile does it:\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 Pin a version instead of latest. An agent with access to your project should not change behavior without notice.\nRunning it is a podman run with the essentials for isolation:\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; What each part does:\n--network hub-llm-backend: the internal network, with no way out to the internet. The container can only reach Ollama. --cap-drop=ALL and no-new-privileges: no extra capabilities and no privilege escalation. :Z: SELinux relabels the project folder for the container. Use dedicated project folders, because everything inside the mounted folder gets relabeled. No --userns=keep-id: the image runs as root, and in rootless Podman the container\u0026rsquo;s root maps to your user on the host. Files the agent creates remain yours. OPENCODE_CONFIG: points to the config file explicitly. OpenCode finds opencode.json even if the image\u0026rsquo;s $HOME changes, instead of relying on /root/.config/opencode/. I wrapped all of this in a script called opencode, placed in ~/.local/bin. With no arguments it opens in the current folder; with a path, it opens there. Since it is easy to open the wrong folder, the script also refuses $HOME, system folders and credential folders (.ssh, .kube, .aws and similar), and asks for confirmation when the folder is not the root of a git repository. The script, the Containerfile and opencode.json live in ~/.config/hub-llm/.\nPointing OpenCode at Ollama # The opencode.json file declares Ollama as an OpenAI-compatible provider and picks the default model:\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 } } } } } } A few points deserve attention:\nModel names must match the output of ollama list exactly. limit.context should follow the OLLAMA_CONTEXT_LENGTH set in the compose file. Ollama\u0026rsquo;s default is usually too small for a coding agent. share: disabled prevents public conversation links, and autoupdate: false makes sense in a container with no internet. With bash: ask, every shell command asks for confirmation. If that is too much, switch it to allow: the container is isolated and the project is under git. OpenCode\u0026rsquo;s configuration schema changes between versions. If a key is rejected, the error message at startup tells you which one. Skills, plugins and online mode # OpenCode runs without internet day to day, but some extensions need it. The way out is to separate what is just files from what needs a download.\nSkills are folders with a SKILL.md and need no network. Download them on the host, read them, and put them in ~/.config/hub-llm/skills/. The script mounts that folder, read-only, at /root/.config/opencode/skills/. Project-specific skills can live in .opencode/skills/ inside the repository. Local plugins (.js or .ts files) work the same way, in ~/.config/hub-llm/plugins/. npm plugins, declared under the \u0026quot;plugin\u0026quot; key in opencode.json, are installed by OpenCode with Bun at startup and cached in ~/.cache/opencode/node_modules/. That path is the opencode-cache volume, so internet is needed only once. For that last case, the script has an online mode. It asks for confirmation and applies to that session only:\nOPENCODE_ONLINE=1 opencode In this mode the container also joins the hub-llm-egress network. Install the plugin, exit, and go back to plain opencode: the plugin stays in the cache and works offline.\nWhy not stay online all the time? A local model does not remove the risk, because the agent is what runs commands. With internet, a prompt injection hidden in a README or a dependency can lead the agent to send the folder\u0026rsquo;s contents out, and 14B to 20B models fall for that kind of trick more easily. Without a network, the worst case is a broken folder, and git rolls it back. With a network, the worst case is a leaked folder, and there is no undo for that.\nPitfalls I hit # Nearly every stumble came from the differences between Docker and rootless Podman. Here are the main ones, so you do not have to repeat the trip.\nSymptom Cause Fix Unable to find group keep-groups when bringing up compose docker-compose was talking to the Podman socket, and the compatibility API treats keep-groups as a regular group Use podman-compose, or force it with PODMAN_COMPOSE_PROVIDER=podman-compose Container has no GPU access, or a missing-group error group_add with video and render by name in rootless mode Use keep-groups and add your user to the groups on the host Containerfile.opencode: no such file or directory The script was moved to /usr/local/bin without its support files Look for the files in ~/.config/hub-llm/ No git inside the container The official image does not include git A derived image with apk add git Cannot create the first Open WebUI account ENABLE_SIGNUP=false from the start blocks the admin signup Start with true and close it afterward Ollama models missing from OpenCode\u0026rsquo;s /models list; only the free OpenCode Zen models show The opencode.json was mounted where OpenCode never looks: an older script used /home/node/.config/opencode/, but the official image uses HOME=/root Mount it at /etc/opencode/opencode.json and pass -e OPENCODE_CONFIG=/etc/opencode/opencode.json. To check: opencode debug config The ollama provider disappears with no clear error Invalid JSON in opencode.json, such as a missing } at the end of each model Validate before use: python3 -m json.tool opencode.json One more point of attention: if OpenCode needs to download a package on first run, such as the provider package or a plugin, use OPENCODE_ONLINE=1 just that once. Whatever gets installed stays in the cache volume, and later sessions run without internet again.\nIf you use SELinux, remember the :Z on bind mounts and the container_use_devices boolean. Without them, permission problems show up as a bare \u0026ldquo;permission denied\u0026rdquo; with little to go on.\nModels for 16 GB of VRAM # With 16 GB, the starting point is models from 14B to 20B parameters, which fit entirely on the GPU. Two that I use as a base: qwen3:14b and gpt-oss:20b.\nLarger models, such as the 30B variants, tend to exceed the VRAM and spill partly into system RAM. The result works, but it is much slower. Three habits help keep everything on the GPU:\nContext. The larger the context, the larger the KV cache. If VRAM runs short, lower OLLAMA_CONTEXT_LENGTH before switching models. Quantized KV cache. OLLAMA_KV_CACHE_TYPE=q8_0, which requires OLLAMA_FLASH_ATTENTION=1, shrinks the space the context takes. Measurement. Run the same prompt on each backend and compare the generation rate: podman exec -it ollama ollama run --verbose qwen3:14b \u0026#34;explain what an operator is in OpenShift\u0026#34; The eval rate field at the end shows tokens per second. Discard the first run, which includes loading the model. This is how you choose between ROCm and Vulkan on your own machine, instead of trusting third-party numbers.\nA realistic expectation for the coding agent: local models in this range handle small and medium tasks, such as explaining a repository, writing tests and making targeted edits. On large changes and long chains of tool calls, they make more mistakes than cloud models. Start with short, specific requests, use the agent\u0026rsquo;s plan mode (the Tab key) when you are unsure what it will do, and commit before every session.\nSecurity, limits and next steps # Isolation lowers the risk but does not remove it. The limits I keep in mind:\nA container is not a virtual machine. It shares the host kernel. Rootless Podman helps, because an escape lands in an unprivileged user, but for stronger isolation consider running the whole stack inside a dedicated VM. The mounted folder is read and changed by the agent. Keep secrets out of it and commit before every session so you can roll back. The Open WebUI port stays on 127.0.0.1 only. To reach it from another machine, prefer an SSH tunnel (ssh -L 3000:127.0.0.1:3000 user@host) over opening the port on the network. Open WebUI\u0026rsquo;s code execution and tool features. If you do not use them, leave them off. Local models have quality limits. For heavy work, the hub coexists well with cloud providers. The safest route is a gateway such as LiteLLM, attached to both the backend and egress networks: OpenCode stays offline and talks only to the gateway, and the API keys stay outside the agent\u0026rsquo;s container. Even so, whatever part of the project folder enters the context goes to the provider. Possible next steps: try llama.cpp with Vulkan in place of Ollama to gain control over VRAM, and convert the files to Quadlet so systemd manages the containers and they come up with the system.\nThe practical result is a single place to talk to models and to code with an agent, running on my own machine, with the agent confined to the project folder. The complete files (compose, Containerfile, script and opencode.json) are short and reproducible.\nFiles # The files used in this post, ready to download:\ncompose.vulkan.yml: Ollama (Vulkan) and Open WebUI compose.rocm.yml: Ollama (ROCm) and Open WebUI Containerfile.opencode: the official OpenCode image plus git run-opencode.sh: the opencode script, with online mode opencode.json: OpenCode config pointing at Ollama Sources # Ollama: Docker documentation Ollama: GPU selection and support Red Hat: Podman with AMD ROCm accelerators and SELinux Running AMD GPU containers with Podman (keep-groups) Podman troubleshooting guide (SELinux, :Z, keep-id) OpenCode: project repository OpenCode: issue about the missing git in the image ","date":"October 21, 2026","externalUrl":null,"permalink":"/en/posts/private-ai-hub-rootless-podman/","section":"Posts","summary":"","title":"A private AI hub on rootless Podman","type":"posts"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/llm/","section":"Tags","summary":"","title":"Llm","type":"tags"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/","section":"Mario Neto","summary":"","title":"Mario Neto","type":"page"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/ollama/","section":"Tags","summary":"","title":"Ollama","type":"tags"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/opencode/","section":"Tags","summary":"","title":"Opencode","type":"tags"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/podman/","section":"Tags","summary":"","title":"Podman","type":"tags"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"October 21, 2026","externalUrl":null,"permalink":"/en/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"When I decided to build my own site on GitHub Pages, I already had a few articles published on Medium. Copying and pasting each one by hand was out of the question: code blocks lose their formatting, images get left behind and some detail always slips through. I wanted to bring everything over at once, as clean Markdown and with as little rework as possible.\nIn this article I show the path I took: Medium\u0026rsquo;s official export and a small Python script, medium-export2md, that turns that export into posts ready for Hugo or Jekyll.\nWhy keep your writing outside Medium? # Your content is yours: as Markdown, versioned in Git, it doesn\u0026rsquo;t depend on any platform. Your own domain: links point to your address, and the audience you build stays with you. Freedom: theme, languages, diagrams, layout\u0026hellip; on your site, you call the shots. Zero cost: GitHub Pages hosts static sites for free. Nothing stops you from keeping on publishing on Medium. The idea is to have your own home and bring what you have already written into it.\nFirst things first: no scraping # The first idea many people have is to scrape the Medium pages. That works until their HTML changes again, not to mention the bot blocking and the paywall. It isn\u0026rsquo;t worth the effort, because Medium itself hands you everything you\u0026rsquo;ve written in a .zip file.\nStep 1: export your data from Medium # On Medium, click your profile picture and go to Settings. Open the Security and apps tab. Click Download your information and confirm with Export. When the archive is ready, you get an e-mail with a link to download medium-export.zip. The zip contains much more than your posts: profile, lists, reading history, sessions and even IP addresses. Keep this file somewhere safe and never put it in your site\u0026rsquo;s repository. What matters here is the posts folder:\nmedium-export/ ├── posts/ │ ├── 2026-06-16_Taming-the-Chaos--Mastering-Log-Management-in-OpenShift-at-Scale-e14af3701206.html │ ├── 2026-10-13_Local-AI-with-Podman-and-Fedora-44--...-d3c8ad3a18e3.html │ └── draft_numCoresPerSocket-on-OpenShift--...-bd0e3ef554bc.html ├── profile/ ├── lists/ ├── reading-history/ └── ... Each post becomes an HTML file. Published ones start with the date and drafts with draft_. The comments you left on other people\u0026rsquo;s stories also show up in this folder, mixed with your posts.\nThe problem: this HTML is not Markdown # You can open the files in a browser, but running a generic HTML-to-Markdown converter on them leaves you with a lot of cleanup. In my own posts alone I found:\nDuplicated title: it shows up in the header and again in the body. Chopped-up code: every line is broken with \u0026lt;br\u0026gt; and full of highlighting \u0026lt;span\u0026gt; tags. Wrong language on code blocks: Medium\u0026rsquo;s auto-detection labeled shell commands as typescript, cpp and even lua, and the output of lscpu as scss. Stray labels: that \u0026ldquo;YAML\u0026rdquo; or \u0026ldquo;bash\u0026rdquo; that comes along when you paste code from somewhere else becomes a paragraph right before the block. Dirty links: tracking parameters like ?source=... and even the sentence\u0026rsquo;s final period inside the link. Images on Medium\u0026rsquo;s CDN, not in your repository. No tags at all: the export simply doesn\u0026rsquo;t include your posts\u0026rsquo; tags. Automatic subtitle: when you don\u0026rsquo;t write one, Medium uses the first words of the text with \u0026ldquo;…\u0026rdquo; at the end. Fixing this post by post is exactly the kind of repetitive work worth automating.\nStep 2: meet medium-export2md # medium-export2md is a single-file script that only uses Python\u0026rsquo;s standard library. All you need is Python 3.9 or newer, which already ships with most Linux distributions and with macOS. It:\nreads the zip directly, no need to extract it, and picks only your posts, skipping the comments; writes one Markdown file per post with front matter (date, slug, title, description and tags); removes the duplicated title and uses your subtitle, or the first sentences of the text, as the description; rebuilds code blocks with the right language, merges blocks Medium split in two and drops the stray labels; downloads the images next to the post and keeps their captions; turns links between your own posts into internal site links; recognizes translations: if a Portuguese post links to its English version right at the top, the two become a single page in two languages; escapes anything that would break the build, such as Hugo shortcode syntax or Liquid\u0026rsquo;s {{ }} inside code blocks. To get it:\ngit clone https://github.com/mariorpn/medium-export2md.git cd medium-export2md Step 3: convert # Always start with --dry-run, which shows what would be written without touching anything:\npython3 medium-export2md.py ~/Downloads/medium-export.zip -o ~/my-site --dry-run If everything looks right, run it for real. This is the command I used for my site, which is a bilingual Hugo site (Portuguese as the default language, plus English):\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 What each option does:\n-o: your site\u0026rsquo;s root. With Hugo, posts go to content/posts/. --lang-codes pt=pt-br,en=en: turns on multilingual mode. The script detects each post\u0026rsquo;s language and writes index.pt-br.md or index.en.md, using your site\u0026rsquo;s language codes. The first one in the list is the default language. --tz: the time zone for the dates in the front matter. --include-drafts: also brings your drafts, marked with draft: true. Hugo only publishes them once you switch to false. --tags: since the export has no tags, you provide them in a text file. tags.txt has one line per post, using the ID at the end of the Medium URL (.../taming-the-chaos-...-e14af3701206):\ne14af3701206: openshift, logging, loki, observability d3c8ad3a18e3: linux, fedora, podman, ollama, artificial intelligence The output looks like this:\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. Notice that the Ollama post, which I had published in both Portuguese and English, ended up in the same folder with both languages.\nEach post becomes a Hugo page bundle, with the text and the images side by side:\ncontent/posts/20261018-numcorespersocket-on-openshift-the-complete-guide-for-vsphere-7-and-8/ ├── index.en.md ├── img-01.png ├── img-02.png └── ... And the front matter already comes out in the format Hugo expects:\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;] --- The result on the site, with the Blowfish theme:\nPost imported from Medium: code blocks keep their indentation and get syntax highlighting Using Jekyll? # Jekyll is GitHub Pages\u0026rsquo; default generator, and the script supports it too:\npython3 medium-export2md.py ~/Downloads/medium-export.zip -o ~/my-jekyll-site --format jekyll Posts go to _posts/YYYY-MM-DD-slug.md, drafts to _drafts/ and images to assets/images/posts/. Jekyll support is still in beta: the output follows kramdown syntax, but it has been tested much less than the Hugo output.\nRunning the script again is safe, because files that already exist are skipped. If you want to overwrite them, use --force.\nStep 4: review, test and publish # Before publishing, run the site locally:\nhugo server -D -D includes drafts. Open http://localhost:1313 and go through the posts. With Jekyll, the equivalent is bundle exec jekyll serve --drafts.\nTake your time checking:\nThe description: when a post had no subtitle, the script uses the first sentences of the text. Sometimes it\u0026rsquo;s worth rewriting. The tags: adjust them in tags.txt before converting or directly in the front matter. The content itself: rereading old posts during the move is a great time to fix typos and outdated information. Once everything is right, commit and push:\ngit add content/posts git commit -m \u0026#34;Import posts from Medium\u0026#34; git push In my case, a GitHub Actions workflow builds the Hugo site and publishes it to GitHub Pages on every push to the main branch. With Jekyll, GitHub Pages takes care of the build itself.\nGotchas I ran into # A post in only one language doesn\u0026rsquo;t show up in the other: on a multilingual Hugo site, each language has its own list of posts. My article about logs on OpenShift, which only existed in English, didn\u0026rsquo;t appear on the Portuguese version of the site until I translated it. Images: the script downloads images from Medium\u0026rsquo;s CDN. If a download fails, it keeps the original URL and warns you in the terminal, so the post still works and you can run it again later. Keep the zip out of the repository: it contains personal data, and your site only needs the generated Markdown. Conclusion # With the official export and a script, a migration that looked like a lot of work became a single command. The result is clean Markdown, versioned in Git and published on your own domain. From then on, every new post is just another file in the repository.\nmedium-export2md is on GitHub. If you use it and find a case it doesn\u0026rsquo;t handle well, open an issue there!\n","date":"October 18, 2026","externalUrl":null,"permalink":"/en/posts/from-medium-to-github-pages/","section":"Posts","summary":"","title":"From Medium to GitHub Pages: bringing your posts over without the headache","type":"posts"},{"content":"","date":"October 18, 2026","externalUrl":null,"permalink":"/en/tags/github-pages/","section":"Tags","summary":"","title":"Github Pages","type":"tags"},{"content":"","date":"October 18, 2026","externalUrl":null,"permalink":"/en/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"October 18, 2026","externalUrl":null,"permalink":"/en/tags/markdown/","section":"Tags","summary":"","title":"Markdown","type":"tags"},{"content":"","date":"October 18, 2026","externalUrl":null,"permalink":"/en/tags/medium/","section":"Tags","summary":"","title":"Medium","type":"tags"},{"content":"","date":"October 18, 2026","externalUrl":null,"permalink":"/en/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"October 17, 2026","externalUrl":null,"permalink":"/en/tags/ansible/","section":"Tags","summary":"","title":"Ansible","type":"tags"},{"content":" What\u0026rsquo;s up!? # ","date":"October 17, 2026","externalUrl":null,"permalink":"/en/posts/hello-world/","section":"Posts","summary":"","title":"Hello, World!","type":"posts"},{"content":"","date":"October 17, 2026","externalUrl":null,"permalink":"/en/tags/openshift/","section":"Tags","summary":"","title":"Openshift","type":"tags"},{"content":"","externalUrl":null,"permalink":"/en/about/","section":"Mario Neto","summary":"","title":"About","type":"page"},{"content":"","externalUrl":null,"permalink":"/en/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/en/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","externalUrl":null,"permalink":"/en/series/","section":"Series","summary":"","title":"Series","type":"series"}]