This blog now answers on Gemini as well as HTTPS. Open gemini://rezhajul.io/ in a Gemini client and you get the same posts as plain text: no JavaScript, no CSS, no tracking, no cookie banner. The protocol is that small on purpose.
Gemini is a protocol for documents. A client opens a TLS connection to port 1965, sends one URL, and gets back a two digit status, a MIME type, and a body. That is the whole exchange:
gemini://rezhajul.io/20 text/gemini;lang=en# Rezha JulioThe first line is the request. The second is the status and the type. The third is the start of the document. The native document format is gemtext: headings, lists, quotes, preformatted blocks, and whole-line links. There is no inline anything. An image is a link the client can open separately. Certificates work on trust on first use, shortened to TOFU. The client pins the first certificate it sees and warns if it changes. That detail decided how I handle certificates.
Generate once, serve static
I had three ways to do this. Render gemtext per request with a dynamic server. Convert the built HTML into gemtext. Or generate the gemtext at build time from the same Markdown the site already uses. I picked the third.
A dynamic server is a process to watch for a site that is already static. Converting HTML would drag in whatever the templates produce, including navigation and scripts, and then I would be stripping it back out. Generating from source keeps one input and two outputs. The generator is a Bun script, scripts/build-gemini.ts, and it runs right after astro build in CI and in the deploy workflow. It reads the content collections, converts each post and note, and writes .gmi files. Nothing in it reads the rendered HTML, so no widget or script can leak into the capsule.
One deploy, not two
The generator writes to dist/gemini/<hostname>/, and the deploy step I already had carries it to the VPS unchanged. Agate serves /www/rezhajulio/gemini/ on the VPS. The capsule ships with the site in the same deploy.
Agate is one process serving both rezhajul.io and www.rezhajul.io. With more than one hostname flag, Agate serves each host from its own subdirectory, so the trees are identical files under different names. All internal links in the gemtext are relative, which keeps that true. A hostname or certificate change needs an Agate restart, because certificates load only at startup. Content changes do not.
Certificates that never rotate
Agate generates its own self-signed certificates, and I let it. Reusing the Let’s Encrypt certificate Caddy uses for HTTPS was possible in theory. In practice it meant converting the certificate format, copying it on every renewal, and restarting Agate. Worse, Let’s Encrypt certificates rotate about every 60 days, and every rotation would trip the TOFU warning for every reader. That trains people to click past the exact warning the system exists to produce. The Agate certificate expires in the year 4096. The fingerprint effectively never changes.
What survives the conversion
Headings, lists, quotes, and code blocks map straight across. Code becomes preformatted text. A link in a paragraph becomes a link line after the paragraph, because gemtext has no inline links. Internal links to my own posts rewrite to their Gemini addresses. Other internal paths rewrite to their HTTPS addresses. Images become link lines with the alt text.
Tables do not exist in gemtext, so Markdown tables and HTML tables flatten into preformatted rows separated by pipes. Raw HTML is stripped down to its inner text, with links and images converted first. MDX components are dropped and the prose inside them is kept. A post that renders empty fails the build instead of publishing a blank page. Drafts and scheduled posts are excluded with the same filter the site uses, so a draft never leaks into the capsule.
Posts and notes are in scope. Links, tags, archives, and search are not. The capsule is a reading copy of the site.
Diagrams become pictures
The site renders Mermaid diagrams to inline SVG at build time. SVG has no place in gemtext, and raw Mermaid source in a preformatted block is ugly and useless. The generator renders each diagram to a PNG with the same Pele and resvg chain the site uses, at 800 pixels wide, and puts a link line under the post text with a caption built from the diagram labels. When I built this, 23 diagrams rendered and the 24th sat in a draft, correctly skipped. A diagram that fails to render fails the whole build, with the file and the Pele error named. I would rather have a red build than a post with a broken picture link.
The homepage, and one small art decision
The capsule homepage lists the 20 latest posts and notes, then a Projects section under Latest. Each project shows its description and tech, and links out to the HTTPS page or repo. A “Gemini version” link in the web footer points to gemini://rezhajul.io/.
The homepage also carries a small skyline with Monas as preformatted art, captioned “Monumen Nasional (Monas), Jakarta, Indonesia”. The art is Gemini only. It briefly lived on the web homepage in the branch, which was a mistake on my part, and I took it off before merging. The web homepage stays clean. The capsule gets to have a little personality.
What is missing on purpose
There is no search, no comments, and no clap button in the capsule. Pagefind is a web thing, comments live on Mastodon, and claps need a backend counter. Each post page links back to its web version for those. I did not try to rebuild them in Gemini. The protocol does not want them.
Running it
On the VPS, Agate 3.3.25 is a single static binary in /usr/local/bin/agate. Certificates live in /var/lib/agate/certs, outside the web root, so a deploy can never wipe them. The systemd unit is short:
[Unit]Description=Agate Gemini serverAfter=network-online.targetWants=network-online.target
[Service]ExecStart=/usr/local/bin/agate \ --content /www/rezhajulio/gemini \ --certs /var/lib/agate/certs \ --hostname rezhajul.io \ --hostname www.rezhajul.io \ --lang enRestart=on-failure
[Install]WantedBy=multi-user.targetPort 1965 is open in the firewall. Caddy is untouched and keeps serving HTTPS on port 443. The two servers do not know about each other, and that is fine.
To read it, use a Gemini client. Lagrange works on phones and desktops, and Amfora works in a terminal. The address is gemini://rezhajul.io/. If your client warns about the certificate on first connect, that is TOFU doing its job. It should only ever warn once.