XMPP is 27 years old and refuses to die. Every few years someone declares it dead, and every few years a wave of self-hosters rediscovers it and wonders why they ever left. The protocol is federated, decentralized, extensible, and runs on hardware that would embarrass a Raspberry Pi. It’s everything Matrix promises but without a 4 GB RAM requirement and a PostgreSQL instance that needs babysitting.
Prosody is the XMPP server you actually want to run. It’s written in Lua, ships as a single binary with a clean config, and the community module ecosystem covers everything from push notifications to OMEMO key management. The official source and documentation live at https://prosody.im, and the community module repository is at https://modules.prosody.im.
This guide gets you from a blank Debian/Ubuntu VPS to a fully functional Prosody instance with group chats (MUC), publish-subscribe feeds (PubSub), OMEMO-compatible encryption infrastructure, file sharing, and modern clients connecting cleanly. No hand-waving, no "consult the docs for the rest."
Prerequisites
- A VPS or dedicated server running Debian 12 or Ubuntu 22.04/24.04
- A domain you control — we’ll use
example.comthroughout - Ports 5222 (client), 5269 (server-to-server), 5280/5281 (HTTP/HTTPS BOSH+API), 3478/5349 (TURN if you want calls) open in your firewall
- Basic familiarity with DNS — you’ll need A records and SRV records
No Docker here. Prosody is lightweight enough that containerizing it adds complexity with zero benefit. Run it on metal (or a VM), let the init system manage it, sleep well.
Installation
The packages in Debian’s default repo are often one major version behind. Use Prosody’s own APT repository.
# Add the Prosody repository
wget https://prosody.im/files/prosody-debian-packages.key -O /etc/apt/keyrings/prosody.gpg
echo "deb [signed-by=/etc/apt/keyrings/prosody.gpg] https://packages.prosody.im/debian $(lsb_release -sc) main" \
> /etc/apt/sources.list.d/prosody.list
apt update
apt install prosody prosody-modules lua-sec lua-event lua-bitop
# Verify the version — should be 0.12.x or newer
prosodyctl about
The prosody-modules package gives you the community module collection (stored in /usr/lib/prosody/modules-community/). Many critical features — push notifications, OMEMO helpers, HTTP file upload — live there rather than in core.
Base Configuration
The main config is /etc/prosody/prosody.cfg.lua. Don’t be alarmed by the Lua syntax — it reads almost like YAML once you get past the curly braces.
-- /etc/prosody/prosody.cfg.lua
admins = { "[email protected]" }
-- Load community modules from the extra path
plugin_paths = { "/usr/lib/prosody/modules-community" }
modules_enabled = {
-- Core: absolutely required
"roster", -- contact list management
"saslauth", -- authentication
"tls", -- encryption in transit
"dialback", -- server-to-server auth
"disco", -- service discovery
"posix", -- POSIX compatibility
-- Quality of life
"carbons", -- sync messages across devices
"mam", -- message archive (history)
"csi_simple", -- battery-friendly client state indication
"smacks", -- stream management / resumption
-- File transfer
"http_file_share", -- modern HTTP upload (XEP-0363)
-- PubSub / PEP
"pep", -- personal eventing (required for OMEMO)
"pubsub", -- full publish-subscribe service
-- Misc
"vcard4", -- user profiles
"blocklist", -- contact blocking
"ping", -- keepalives
"uptime", -- server info
"time",
"version",
-- Push notifications (for mobile clients)
"cloud_notify",
}
-- Authentication: use hashed passwords stored in flat files
-- Switch to sql later if you need thousands of users
authentication = "internal_hashed"
-- Logging — errors to syslog, info to file
log = {
error = "/var/log/prosody/prosody.err",
info = "/var/log/prosody/prosody.log",
-- { levels = { min = "debug" }, to = "console" }, -- uncomment when debugging
}
-- HTTP server — needed for file upload and BOSH
http_ports = { 5280 }
http_interfaces = { "0.0.0.0", "::" }
https_ports = { 5281 }
https_interfaces = { "0.0.0.0", "::" }
-- File upload limits
http_file_share_size_limit = 52428800 -- 50 MB per file
http_file_share_expires_after = 60 * 60 * 24 * 30 -- 30 days
-- MAM defaults: archive everything, keep 6 months
default_archive_policy = true
archive_expires_after = "180d"
-- Your virtual host
VirtualHost "example.com"
ssl = {
key = "/etc/letsencrypt/live/example.com/privkey.pem",
certificate = "/etc/letsencrypt/live/example.com/fullchain.pem",
}
TLS with Let’s Encrypt
Prosody needs to read its own certificates. The easiest approach is a deploy hook that copies and chowns the cert after every renewal.
# /etc/letsencrypt/renewal-hooks/deploy/prosody.sh
#!/bin/bash
set -e
DOMAIN="example.com"
TARGET="/etc/prosody/certs"
mkdir -p "$TARGET"
cp /etc/letsencrypt/live/$DOMAIN/privkey.pem "$TARGET/$DOMAIN.key"
cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem "$TARGET/$DOMAIN.crt"
chown -R prosody:prosody "$TARGET"
chmod 640 "$TARGET/$DOMAIN.key"
systemctl reload prosody
chmod +x /etc/letsencrypt/renewal-hooks/deploy/prosody.sh
Then update your config to point at the copies:
VirtualHost "example.com"
ssl = {
key = "/etc/prosody/certs/example.com.key",
certificate = "/etc/prosody/certs/example.com.crt",
}
If you also need the conference.example.com MUC subdomain covered, request a wildcard cert or add it as a SAN: certbot certonly --cert-name example.com -d example.com -d conference.example.com -d upload.example.com.
DNS — Get This Right First
Nothing causes more "it works on my machine but federation is broken" than sloppy DNS. You need SRV records, not just A records.
; A records
example.com. A 203.0.113.10
conference.example.com. A 203.0.113.10
upload.example.com. A 203.0.113.10
; SRV records — client connections
_xmpp-client._tcp.example.com. IN SRV 0 5 5222 example.com.
; SRV records — server-to-server federation
_xmpp-server._tcp.example.com. IN SRV 0 5 5269 example.com.
; Also needed for conference subdomain federation
_xmpp-server._tcp.conference.example.com. IN SRV 0 5 5269 example.com.
Gotcha: Forget the MUC SRV record and federated servers can’t deliver messages to your conference rooms. This breaks group chat with people on other XMPP servers silently — no error, messages just vanish.
Multi-User Chat (MUC)
Group chats in XMPP live in a Component, which is a separate process that connects to the main server. For Prosody, it’s just a Component block in the config:
Component "conference.example.com" "muc"
name = "Example Conferences"
-- Who can create rooms? "anyone", "local" (local users only), or "admin"
restrict_room_creation = "local"
modules_enabled = {
"muc_mam", -- archive group chat history
"muc_vcard", -- room avatars
}
-- Archive group chat messages, 1 year
muc_log_by_default = true
max_history_messages = 50
archive_expires_after = "365d"
Create a room via your client or via the admin console:
prosodyctl shell
> muc:create("general", "conference.example.com", { persistent = true, name = "General Chat" })
Gotcha: By default, rooms created by users are temporary — they vanish when the last person leaves. Set persistent = true when creating rooms, or configure the default in the Component block (default_room_config = { persistent = true }). You’ll forget this at 2am and wonder where your room went.
PubSub
PubSub is where XMPP gets interesting beyond simple chat. It powers: contact avatars, OMEMO device lists, user activity/mood status, and any publish-subscribe application you want to build on top of your server.
The basic module is already in the modules_enabled list above (pubsub). For a dedicated PubSub service (separate from PEP which runs per-user):
Component "pubsub.example.com" "pubsub"
-- Anyone on your server can publish; others can subscribe
pubsub_max_items = 50
PEP (Personal Eventing Protocol) runs as mod_pep on the main virtual host and is the piece that OMEMO actually relies on. Make sure it’s in your modules_enabled — it’s already in the config above.
Real-world use: If you’re building bots or home-automation integrations, PubSub is cleaner than polling. Publish sensor readings to a node, clients subscribe and react. The protocol overhead is tiny.
OMEMO — What the Server Actually Does
Here’s where people get confused. OMEMO is a client-side protocol. The server doesn’t encrypt or decrypt anything. What the server must do is:
- Let clients publish their device lists and key bundles via PEP (XEP-0384)
- Store and deliver those PEP items reliably
- Archive OMEMO-encrypted messages in MAM (they stay encrypted at rest — the server never sees plaintext)
The mod_pep and mod_mam you’ve already enabled handle all of this. There’s one community module worth adding:
-- In modules_enabled, add:
"omemo_all_access", -- allows fetching OMEMO bundles without prior subscription
Without omemo_all_access, some clients can’t fetch key bundles from contacts they haven’t exchanged messages with yet, breaking the initial OMEMO handshake. This is a common "OMEMO just doesn’t work" complaint that has nothing to do with the client.
Also add mod_cloud_notify properly for push — mobile clients need this to receive OMEMO-encrypted messages while backgrounded:
-- In modules_enabled:
"cloud_notify",
"cloud_notify_encrypted", -- sends encrypted push payloads (privacy-preserving)
Gotcha: MAM archiving and OMEMO work fine together, but clients need to implement MAM correctly to decrypt archived OMEMO messages. Conversations (Android) and Dino handle this correctly. Some older or less-maintained clients archive encrypted blobs they can’t decrypt on retrieval. Test this before rolling out to users.
Stream Management and Carbons — Don’t Skip These
These two modules make multi-device use bearable.
mod_smacks (stream management) lets clients resume a TCP session after a brief disconnect without losing messages. Critical for mobile clients on flaky connections.
mod_carbons copies outgoing messages to all your other connected devices. So if you send from your phone, your desktop client sees it too.
Both are already in the config above. The only thing to tune is the smacks queue:
-- In your VirtualHost block
smacks_max_unacked_stanzas = 5
smacks_hibernation_time = 60 -- seconds to keep a session alive after disconnect
HTTP File Upload
Modern XMPP clients expect to share images and files. The old in-band base64 method is dead. Use mod_http_file_share:
-- In your main config (already included above):
-- http_file_share_size_limit and http_file_share_expires_after are set
-- In VirtualHost block, point clients at the upload URL:
http_file_share_base_url = "https://upload.example.com:5281/upload"
You’ll need a reverse proxy (nginx or Caddy) in front of port 5281 if you want a clean HTTPS URL without the port. Alternatively, keep it on 5281 with the Let’s Encrypt cert — modern clients handle it fine.
Nginx snippet for clean upload URLs:
server {
listen 443 ssl;
server_name upload.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location /upload {
proxy_pass https://127.0.0.1:5281;
proxy_ssl_verify off; # local loopback, self-ref is fine
client_max_body_size 55m;
}
}
Creating Your First User
# Create admin user (matches admins = {} in config)
prosodyctl adduser [email protected]
# Check the server health
prosodyctl check
# Reload config without dropping connections
prosodyctl reload
prosodyctl check is your friend. Run it after every config change — it validates DNS, TLS, and module loading and prints specific, actionable errors.
Modern Clients Worth Using
The XMPP client ecosystem has improved dramatically. These are the ones that implement the full modern XEP stack and don’t feel like they were designed in 2009.
Android: Conversations — The gold standard. Handles OMEMO, MUC, file transfer, and push flawlessly. Available on F-Droid (free) and Google Play (paid). If your users are on Android, start here.
iOS: Siskin IM or Monal — Siskin is actively maintained and handles OMEMO correctly. Monal is open-source and catching up fast. Both work with Prosody; test OMEMO key exchange before deploying to users.
Linux Desktop: Dino — GTK4, clean UI, excellent OMEMO and MUC support. Install from Flatpak for the latest version — distro packages lag. Gajim is the veteran alternative with more features but a busier interface.
Windows/macOS: Gajim — Cross-platform, feature-complete, and the go-to recommendation for non-Linux desktops. The OMEMO plugin is separate but well-maintained.
Web: XMPP over WebSocket — Prosody supports WebSocket connections out of the box via mod_websocket. Add it to modules_enabled and clients like Converse.js can connect from a browser without any plugins.
Production Hardening
A few things that separate a server that runs for years from one that breaks at 3am.
Rate limiting — prevent brute force and spam:
-- In VirtualHost block
limits = {
c2s = {
rate = "10kb/s",
burst = "30kb",
},
s2s = {
rate = "30kb/s",
burst = "60kb",
},
}
Registration — disable it unless you want to run a public server:
-- Global setting
allow_registration = false
Server-to-server security — require TLS for all federation:
s2s_require_encryption = true
s2s_secure_auth = false -- set true only if you're okay dropping federation with servers that can't do TLS auth
Backup — Prosody stores data in /var/lib/prosody/. The MAM archive grows fast. A simple daily rsync to object storage is enough:
# Cron: daily at 3am
0 3 * * * rsync -az /var/lib/prosody/ s3://your-bucket/prosody-backup/
Monitoring — prosodyctl check runs cleanly in a cron and returns non-zero on failure. Pipe it into your alerting stack. The built-in mod_prometheus exposes metrics if you run a Prometheus/Grafana setup.
Compliance Testing
Run your server through the XMPP Compliance Tester at https://compliance.conversations.im. It checks 30+ XEPs and gives you a score with per-feature pass/fail. A properly configured Prosody instance hits 95%+ easily. Anything below 80% means you’re missing something important for modern clients.
The tool pokes your server from the outside, so it validates DNS, TLS, and feature discovery exactly as a federated server would see you.
Gotchas Summary
DNS SRV records for MUC subdomain — federated servers can’t reach your rooms without them.
mod_omemo_all_access — without it, first-time OMEMO contact key exchange fails for some clients. Silent failure, no useful error in logs.
Certificate permissions — Prosody runs as the prosody user and needs to read its private key. The deploy hook above handles this, but if you renew manually, check ownership.
Room persistence — MUC rooms are transient by default. Set persistent = true explicitly or configure it as the default.
MAM + OMEMO on iOS — Siskin and Monal have historically had edge cases with decrypting MAM-archived OMEMO messages. Test this in staging before rolling out.
Port 5269 firewall — federation breaks silently if 5269 is blocked. External servers try to connect and time out; you see nothing in your client.
Prosody is genuinely one of the better-designed servers in the self-hosting ecosystem. The config is readable, the module system is clean, and the resource footprint is so small it can share a VPS with three other services without anyone noticing. Once it’s running, it mostly just works — no weekly "the database needs vacuuming" incidents, no surprise RAM spikes, no mandatory upgrades that break your config format.
The hardest part of running XMPP in 2026 isn’t the server. It’s convincing your contacts to install Conversations. That part is still on you.