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
│ │ └── nixgemini.md
│ ├── index.md
│ └── markdown.html
├── flake.lock
├── flake.nix
├── fly.toml
├── result
└── static
├── Caddyfile
├── Caddyfile.dev
└── Caddyfile.main
The Caddy webserver has the ability to transform Markdown into HTML natively. Two files achieve this, Caddyfile
and Caddyfile.main
.
Caddyfile
:
outsidethe.net
import Caddyfile.main
Caddyfile.main
:
root * /public
file_server
templates
encode gzip zstd
try_files {path} {path}.html
rewrite * /markdown.html
The reason for the seemingly pointless import
is that Caddyfile.dev
contains:
localhost:80
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 "/%s.md" $markdownFilename]]
[[else]]
[[$markdownFilePath = printf "/articles/%s.md" $markdownFilename]]
[[end]]
[[if not (fileExists $markdownFilePath)]][[httpError 404]][[end]]
[[$markdownFile := (include $markdownFilePath | splitFrontMatter)]]
[[$title := default $markdownFilename $markdownFile.Meta.title]]
<!DOCTYPE html>
<html>
<head>
<title>outsidethe.net</title>
</head>
<body>
[[markdown $markdownFile.Body]]
</body>
</html>
(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.
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.
We bring all of this together in a Nix Flake that looks like this:
{
description = "outsidethe.net";
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:
let
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 "builder.sh" ''
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 index.md
# 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)
done
'';
};
container = pkgs.dockerTools.buildImage {
name = "registry.fly.io/outsidethenet";
tag = "latest";
config = {
Cmd = [
"bash"
"-c"
"${x86Linux.caddy}/bin/caddy start; ${x86Linux.agate}/bin/agate --content /public --hostname outsidethe.net"
];
};
copyToRoot = [
content
./static
x86Linux.agate
x86Linux.bash
x86Linux.cacert
x86Linux.caddy
];
};
debugContainer = pkgs.dockerTools.buildImage {
name = "debug";
tag = "latest";
fromImage = container;
config = {
Cmd = [
"bash"
];
};
copyToRoot = [
x86Linux.bash
x86Linux.coreutils
x86Linux.curl
x86Linux.vim
];
};
in {
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
pkgs.agate
pkgs.amfora # Browser
pkgs.caddy
pkgs.castor # Browser
pkgs.flyctl
pkgs.lagrange # Browser
];
};
packages.container = container;
packages.content = content;
packages.debug = debugContainer;
packages.default = container;
}
);
}
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 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.
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 127.0.0.1:8080:80 -p 127.0.0.1:1965:1965 debug
I intentionally do not start any of the servers. If I wish to I can start Caddy with:
caddy run --config Caddyfile.dev
and, as mentioned before, avoid any complications with TLS certificate generation.
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 fly.io deployment is a simple matter of bin/deploy
which contains:
#!/usr/bin/env bash
nix build &&\
docker load < result &&\
docker push registry.fly.io/outsidethenet:latest &&\
fly deploy
The fly.toml
configuration is as simple as I can make it:
app = "outsidethenet"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
[build]
image = "registry.fly.io/outsidethenet:latest"
# Gemini
[[services]]
internal_port = 1965
protocol = "tcp"
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
port = 1965
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
# HTTP
[[services]]
internal_port = 80
protocol = "tcp"
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
port = 80
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
# HTTPS
[[services]]
internal_port = 443
protocol = "tcp"
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"