Self-Hosting Prosody XMPP on NixOS
Introduction
In the previous post, I built the Conversations XMPP client from source and got it running on my phone. That post ended with “now I just need the server.” This is the server.
My homelab machine (Myshkin) runs NixOS, so the plan was simple: add a Prosody config, rebuild, done. It was mostly that, with a few detours along the way.
Architecture
The setup is intentionally minimal. All my devices are on a WireGuard network, so Prosody only needs to be reachable from there. The topology looks like this:
Phone (WG) ──┐
Raskolnikov (LAN)──┤──► Myshkin (Prosody)
Bots ──┘
I considered running it on my VPS (Shatov), but that would mean message archives living on hardware I don’t control. Shatov is mainly just my WireGuard bastion. Adding XMPP would increase the attack surface for no real benefit.
The NixOS Config
NixOS has a built-in Prosody module, so there’s no need to
write systemd units or manage config files manually. The
module generates prosody.cfg.lua from your Nix
expressions.
Here’s the full config:
{config, ...}: let
domain = "xmpp.skwort.dev";
mucDomain = "conference.${domain}";
uploadDomain = "upload.${domain}";
certDir = "/var/lib/acme/${domain}";
wgAddr = "10.0.0.3";
lanAddr = "192.168.20.10";
in {
services.prosody = {
enable = true;
admins = ["sam@${domain}"];
ssl = {
cert = "${certDir}/fullchain.pem";
key = "${certDir}/key.pem";
};
virtualHosts.${domain} = {
enabled = true;
domain = domain;
ssl = {
cert = "${certDir}/fullchain.pem";
key = "${certDir}/key.pem";
};
};
muc = [
{
domain = mucDomain;
name = "Chat Rooms";
restrictRoomCreation = false;
}
];
httpFileShare = {
domain = uploadDomain;
size_limit = 104857600; # 100 MB
expires_after = "86400"; # 1 day
};
httpsInterfaces = [wgAddr lanAddr];
httpInterfaces = [wgAddr lanAddr];
modules = {
roster = true;
saslauth = true;
tls = true;
disco = true;
ping = true;
register = false;
blocklist = true;
carbons = true;
smacks = true;
mam = true;
pep = true;
vcard_legacy = true;
bookmarks = true;
private = true;
csi = true;
cloud_notify = true;
dialback = false;
admin_adhoc = false;
admin_telnet = false;
http_files = false;
proxy65 = false;
version = false;
uptime = false;
time = false;
};
extraConfig = ''
interfaces = { "${wgAddr}", "${lanAddr}" }
s2s_enabled = false
http_external_url = "https://${domain}:5281"
'';
};
}
A few things worth noting.
Modules are explicit. Many of these are enabled by default in the NixOS module, but I listed them all anyway. Maybe defaults change between releases, so I’d rather the config be self-documenting than rely on implicit behaviour.
No registration, no federation. register is off and
s2s_enabled is false—meaning no server-to-server
communication with other XMPP servers. This is a personal
server, so accounts are created manually with
prosodyctl adduser.
Interface binding. The interfaces setting in
extraConfig controls which addresses Prosody listens on
for XMPP (port 5222). httpsInterfaces and
httpInterfaces control the HTTP server (port 5281) used
for file uploads. I initially only bound the WireGuard
address and couldn’t figure out why my desktop—which is
on the LAN but not WireGuard—couldn’t connect. Adding
the LAN address fixed it.
MUC is required. The NixOS module enforces XMPP compliance by default, which requires a MUC (multi-user chat) component. Without it, the build just fails; this is actually a nice feature, I can use a single MUC channel for bots.
File sharing. httpFileShare enables sending images
and files in chats. Prosody handles the HTTPS for this
on port 5281. One gotcha: expires_after is a string,
not an integer.
TLS Behind NAT
Myshkin sits behind NAT with no public ports, so the standard ACME challenge doesn’t work—Let’s Encrypt can’t reach the machine. I already had a Cloudflare API token in sops-nix for Caddy’s DNS challenge, so reusing it for Prosody’s cert was just a matter of wiring up another sops template:
sops.templates."acme-cloudflare.env".content = ''
CLOUDFLARE_DNS_API_TOKEN=${
config.sops.placeholder."caddy/cloudflareAuthToken"
}
'';
One thing that tripped me up: the NixOS ACME module uses
a different environment variable name
(CLOUDFLARE_DNS_API_TOKEN) than Caddy does. The token
itself is the same, it just needs a different variable
name in the env file.
The ACME cert config:
security.acme = {
acceptTerms = true;
defaults.email = "your@email.here";
certs.${domain} = {
dnsProvider = "cloudflare";
environmentFile =
config.sops.templates."acme-cloudflare.env".path;
group = "prosody";
postRun = "systemctl reload prosody.service";
};
};
group = "prosody" lets the Prosody process read the cert
files. postRun reloads Prosody when the cert renews.
Client Setup
Conversations (Android) was the easiest. You set
the JID to sam@xmpp.skwort.dev, manually override the
server address to the WireGuard IP (10.0.0.3), port
5222, and it just works. No cert mismatch issues despite
connecting by IP.
Gajim (desktop) didn’t work initially. I spent some
time chasing DNS resolution errors before realising the
actual problem was the interface binding—Prosody wasn’t
listening on the LAN address yet. Once I added lanAddr
to the interfaces list, it connected fine.
What’s Next
The server is running, clients are connected, messages sync across devices, and file sharing works. The original motivation mentioned bots and shopping lists—that’s still on the list. The nice thing about XMPP is that a bot is just another account that connects to the server. Any language with an XMPP library can do it.
But that’s a project for another day. For now, the infrastructure is in place.