Matrix, a protocol with the goal of secure, decentralized communication. As my employer currently does not have host a Matrix Homeserver, I took the liberty of trying to set up my own.
Now, I’m no stranger to Matrix. My university set up a demo server around the time of the COVID pandemic. At the time, Matrix and its reference implementation server, Synapse, was a buggy, slow mess. The goal of achieving one common messenger protocol for everyone is a noble one, but also nontrivial to implement. Over the years, Matrix became more usable on my university’s server, culminating with a 2.0 release towards the end of my studies.
I love Caddy! Really. It is my go-to reverse proxy that just works. For most of my self-hosted applications, including Matrix, I can just declare a virtual host and pass the reverse_proxy
directive. SSL, HTTP/3, websockets, everything is just there.
NixOS again simplifies the installation process, because I just declare the way I want things configured. This means that I can trivially connect Caddy to a Matrix homeserver. In this case, I chose tuwunel, which, despite the questionable name, seems to strike the best compromise between resource usage, stability and features.
All I had to do to get a basic configuration working was enable the services and supply configuration.
The first part of my configuration involves setting up delegation, i.e. using a (root) domain for chatting @user:example.com
with a different address, such as matrix.example.com
, processing the requests. This is exactly what I did. redirecting any non-delegation requests to my personal home page.
{ config, pkgs, ... }:
let
domain = config.networking.domain;
matrixDomain = "matrix.${config.networking.domain}";
port = toString config.services.matrix-tuwunel.settings.global.port;
in
{
imports = [ ./caddy.nix ];
services.caddy.virtualHosts = {
${domain} = {
extraConfig = ''
# Handle requests for delegation
handle /.well-known/* {
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "${matrixDomain}:443"}`
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://${matrixDomain}"}}`
}
# Redirect anything else to homepage.
handle {
redir https://johannes-arnold.de{uri}
}
'';
};
${matrixDomain} = {
extraConfig = ''
reverse_proxy /_matrix/* 127.0.0.1:${port}
reverse_proxy /_synapse/client/* 127.0.0.1${port}
encode zstd gzip
header X-Robots-Tag "noindex, nofollow"
cache
# Serve Element Web Interface
root * ${pkgs.element-web}
file_server
'';
};
};
}
Note that the matrix.example.com
also serves Element, a fully-featured Matrix chat client which can be served from a static page.
Now, I just need to set up my server of choice, which, in the case of Tuwunel, is remarkably simple as well:
services.matrix-tuwunel = {
enable = true;
settings = {
global = {
server_name = domain;
allow_registration = true;
trusted_servers = [
"matrix.org"
"matrix.uni-hannover.de"
];
well_known = {
client = "https://${matrixDomain}";
server = "${matrixDomain}:443";
};
};
};
};
Now, all that one needs to do to access the server is enable a registration token, navigate to their Matrix domain, and create an account.
Now a recipe is a lot like a computer program. A computer program’s a lot like a recipe: a series of steps to be carried out to get some result that you want. So it’s just as natural to do those same things with computer programs — hand a copy to your friend. Make changes in it because the job it was written to do isn’t exactly what you want. It did a great job for somebody else, but your job is a different job. And after you’ve changed it, that’s likely to be useful for other people. Maybe they have a job to do that’s like the job you do. So they ask, Hey, can I have a copy? Of course, if you’re a nice person, you’re going to give a copy. That’s the way to be a decent person.
— Richard Stallman
2025-08-15