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.