Serving HTML and Gemini from Markdown with Nix

This article details how I use Nix to build a container image that can publish the same Markdown documents over both HTML and Gemini.

The layout of the directory structure to do this is below. We'll discuss each of these files as we go.

├── bin
│   ├── debug
│   └── deploy
├── content
│   └── public
│       ├── articles
│       │   └──
│       ├──
│       └── markdown.html
├── flake.lock
├── flake.nix
├── fly.toml
├── result
└── static
    ├── Caddyfile
    └── Caddyfile.main

Serving Markdown as HTML

The Caddy webserver has the ability to transform Markdown into HTML natively. Two files achieve this, Caddyfile and Caddyfile.main.


import Caddyfile.main


root * /public
encode gzip zstd
try_files {path} {path}.html
rewrite * /markdown.html

The reason for the seemingly pointless import is that contains:


import Caddyfile.main

which allows us to start Caddy without any complications from the automatic TLS certificate generation. Later on we'll see how we produce a debugging container that uses this.

The rewrite configuration option takes every path and renders /markdown.html regardless. It is in here we use Caddy's Go templating to render the markdown at the correct path. Let's look at the contents:

[[$pathParts := splitList "/" .OriginalReq.URL.Path]]
[[$markdownFilename := default "index" (slice $pathParts 2 | join "/")]]

[[$markdownFilePath := "" ]]

[[if eq $markdownFilename "index"]]
[[$markdownFilePath = printf "/" $markdownFilename]]
[[$markdownFilePath = printf "/articles/" $markdownFilename]]

[[if not (fileExists $markdownFilePath)]][[httpError 404]][[end]]
[[$markdownFile := (include $markdownFilePath | splitFrontMatter)]]
[[$title := default $markdownFilename $markdownFile.Meta.title]]
<!DOCTYPE html>
    [[markdown $markdownFile.Body]]

(I've substituted double braces for square braces above to avoid complications with double-rendering of templates).

The upshot of all of this is that if there is a Markdown file at the correct path it is rendered as HTML.

Serving Markdown as Gemini

For the Gemini versions of each Markdown we generate a .gmi file using the md2gemini program. This is done as part of preparing the contents of the container image and I'll talk about that in a bit more detail shortly.

The Gemini files are served with the Agate server which is very similar to Caddy insofar as it also handles TLS certificates for us. One downside to the manner in which I deploy all of this is that each new deployment generates a new TLS certificate. Since Gemini works on the basis of trust-on-first-use this means that frequent deployments could be bothersome for those who visit often. That said, being Gemini, I don't think that's a great concern and certainly not one worth the extra complication of persistent volumes.

Building a Container Image with Nix

We bring all of this together in a Nix Flake that looks like this:

  description = "";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";

  outputs = { self, nixpkgs, flake-utils }: 
    flake-utils.lib.eachDefaultSystem (system:
        pkgs = import nixpkgs { system = system; };
        x86Linux = import nixpkgs { system =  "x86_64-linux"; };

        content = pkgs.stdenv.mkDerivation {
          name = "content";
          buildInputs = [ pkgs.md2gemini ];
          src = ./content;
          builder = builtins.toFile "" ''
            source $stdenv/setup
            mkdir $out
            cp -r $src/* $out

            # The "source code" is immutable by default but
            # we need to set the write bit on the directories
            # to be able to generate new files alongside it.
            chmod u+w $out/public
            chmod u+w $out/public/articles

            # Build the index page with the copy link style.
            cd $out/public
            md2gemini --links copy --plain --write

            # Fix article links to have .gmi extension
            # (this presumes GNU sed).
            sed -i "s|\(=> /articles/[a-z]\+\)|\1.gmi|g" index.gmi

            # Build the articles with an alternative link style.
            cd $out/public/articles
            for f in $(find . -name "*.md"); do
              md2gemini --links 'at-end' --plain --write $(basename $f)


        container = pkgs.dockerTools.buildImage {
          name = "";
          tag = "latest";
          config = {
            Cmd = [
              "${x86Linux.caddy}/bin/caddy start; ${x86Linux.agate}/bin/agate --content /public --hostname"
          copyToRoot = [

        debugContainer = pkgs.dockerTools.buildImage {
          name = "debug";
          tag = "latest";
          fromImage = container;
          config = {
            Cmd = [
          copyToRoot = [

      in {
        devShells.default = pkgs.mkShell {
          nativeBuildInputs = [
            pkgs.amfora   # Browser
            pkgs.castor   # Browser
            pkgs.lagrange # Browser

        packages.container = container;
        packages.content = content;
        packages.debug = debugContainer;
        packages.default = container;

General Points

Unusually, perhaps, we import nixpkgs twice:

pkgs = import nixpkgs { system = system; };                                                                       
x86Linux = import nixpkgs { system =  "x86_64-linux"; };

This is to ensure that no matter the host operating system we have the native standard library available (via pkgs) but can always build an x86_64-linux container image.

The Content Derivation

The first derivation we build contains the content we want to serve. This is the /content tree containing our Markdown and HTML. The custom builder for this derivation runs the md2gemini code and generates our .gmi files alongside their .md counterparts.

Apart from us having to do a slightly distasteful sed to ensure that the links (which will work without a file extension when served via HTML thanks to the try_files {path} {path}.html stanza in the Caddy configuration) are rewritten with a .gmi extension, which the Gemini specification insists upon it is all pretty straightforward.

The Container Image Derivations

Here I use the dockerTools.buildImage builder to ensure all of our dependencies are present within the image: the content, the configuration, the two pieces of server software and our root certificate chains. Notably, thanks to us constructing the content as a derivation we don't ship md2gemini in the deployed container image.

The debugContainer derivation produces another container that is byte-for-byte identical to the deployed container image but contains some additional software to make debugging any issues a little easier. I build and run this using the bin/debug script which looks like this:

#!/usr/bin/env bash
nix build .#debug && \
docker load < result && \
docker run -it --rm -p -p debug

I intentionally do not start any of the servers. If I wish to I can start Caddy with:

caddy run --config

and, as mentioned before, avoid any complications with TLS certificate generation.

Development Shell

The final thing we configure in the Nix Flake is a development shell that contains the same software that we bake into the container images and a few different Gemini browsers to make it easy to see how things render.


Since this container image is hosted on deployment is a simple matter of bin/deploy which contains:

#!/usr/bin/env bash
nix build &&\
docker load < result &&\
docker push &&\
fly deploy

The fly.toml configuration is as simple as I can make it:

app = "outsidethenet"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []


  image = ""

# Gemini
  internal_port = 1965
  protocol = "tcp"
    hard_limit = 25
    soft_limit = 20
    type = "connections"

    port = 1965

    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

  internal_port = 80
  protocol = "tcp"
    hard_limit = 25
    soft_limit = 20
    type = "connections"

    port = 80

    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

  internal_port = 443
  protocol = "tcp"
    hard_limit = 25
    soft_limit = 20
    type = "connections"

    port = 443

    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"