2026.03.02, 2026.07.09
Hi!
Snikket is a modern federated messenger based on good ol' XMPP. It has attachments, group chats, audio / video messages and calls with end-to-end encryption and it is Docker / Podman ready.
If you too have a NixOS server and desperately searching for a self-hosted instant messenger with E2EE, calls and a high chance to keep your sanity during configuring and deploying... then continue reading (:
DNS
First of all, you should have a domain name.
My server holds a lot of stuff, so I host Snikket on a subdomain, like chat.example.com.
Besides chat.example.com you'll also need these two A-type DNS records (naming is up to you):
-
groups.chat.example.com- for all things about group chats -
share.chat.example.com- for files sharing
NixOS and OCI containers
To be short, OCI (Open Container Initiative) containers is what Docker has. Podman is a Docker replacement (DYOR what you should choose) and also works with OCI.
NixOS does not (yet) have a nix module for Snikket, however there is wonderful compose2nix project that turns any docker-compose.yml into nix file which you can import to your server's configuration (more about it later).
Configs
Configs tree:
<your other nixos configs>
├── nginx.nix
├── snikket.nix
├── snikket
│ ├── prepare-certs-job.nix
│ ├── prepare-certs.sh
│ ├── docker-compose.yml
│ ├── snikket.conf
│ └── snikket.nix
<your other nixos configs>
Where:
-
nginx.nix- general Nginx configuration shared between all deployed services on your server -
snikket.nix- all the specific configs for Snikket, other than Docker configuration -
snikket/docker-compose.yml- original Docker configuration for Snikket -
snikket/snikket.conf- environment file for Snikket Docker configuration -
snikket/snikket.nix- whatcompose2nixgenerated from Docker configuration -
snikket/prepare-certs.sh- bash script to mount Snikket's container volumes for ACME challenges and copy SSL certificate files -
snikket/prepare-certs-job.nix- systemd configuration that executes said bash script for persistence
nginx.nix
{ pkgs, config, ... }:
{
networking.firewall.allowedTCPPorts = [ 80 443 ];
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
appendHttpConfig = ''
proxy_headers_hash_max_size 1024;
proxy_headers_hash_bucket_size 128;
'';
resolver.ipv6 = false;
};
security.acme = {
acceptTerms = true;
defaults.email = "admin@example.com"; # <--- Change this.
};
}
snikket.nix
Look at Quick-start, Firewall ports and Advanced configuration for details.
For comments like Keep in sync with SNIKKET_DOMAIN see the snikket/snikket.conf for said env variable.
In allowedUDPPortRanges pay attention to port range, see How many ports does the TURN service need? section.
You can change client_max_body_size value in extraConfig = "client_max_body_size 104857616;"; to whatever suits you, I left the defaults.
{ lib, pkgs, config, ... }:
let
domain = "chat.example.com"; # Keep in sync with `SNIKKET_DOMAIN`.
groups_domain = "groups.${domain}";
share_domain = "share.${domain}";
port = 5443; # Keep in sync with `SNIKKET_TWEAK_HTTPS_PORT`.
challenges_root="/var/www/snikket-letsencrypt";
ssl_root="/var/lib/acme";
in
{
imports = [
./snikket/snikket.nix
./snikket/prepare-certs-job.nix
];
networking.firewall = {
allowedTCPPorts = [ 5222 5269 5000 3478 3479 5349 5350 ];
allowedUDPPorts = [ 3478 3479 5349 5350 ];
# Keep in sync with `SNIKKET_TWEAK_TURNSERVER_MIN_PORT` and `SNIKKET_TWEAK_TURNSERVER_MAX_PORT`.
allowedUDPPortRanges = [ {from = 49152; to = 49252;} ];
};
services.nginx = {
virtualHosts."${domain}" = {
# Snikket manages ACME certs.
enableACME = false;
forceSSL = true;
sslCertificate = "${ssl_root}/${domain}/cert.pem";
sslCertificateKey = "${ssl_root}/${domain}/privkey.pem";
locations."^~ /.well-known/acme-challenge/" = {
extraConfig = ''
default_type "text/plain";
root ${challenges_root};
'';
};
locations."/" = {
proxyPass = "https://localhost:${toString port}";
extraConfig = "client_max_body_size 104857616;"; # 100MB + 16 bytes
};
};
virtualHosts."${groups_domain}" = {
# Snikket manages ACME certs.
enableACME = false;
forceSSL = true;
sslCertificate = "${ssl_root}/${domain}/cert.pem";
sslCertificateKey = "${ssl_root}/${domain}/privkey.pem";
locations."^~ /.well-known/acme-challenge/" = {
extraConfig = ''
default_type "text/plain";
root ${challenges_root};
'';
};
locations."/" = {
proxyPass = "https://localhost:${toString port}";
extraConfig = "client_max_body_size 104857616;"; # 100MB + 16 bytes
};
};
virtualHosts."${share_domain}" = {
# Snikket manages ACME certs.
enableACME = false;
forceSSL = true;
sslCertificate = "${ssl_root}/${domain}/cert.pem";
sslCertificateKey = "${ssl_root}/${domain}/privkey.pem";
locations."^~ /.well-known/acme-challenge/" = {
extraConfig = ''
default_type "text/plain";
root ${challenges_root};
'';
};
locations."/" = {
proxyPass = "https://localhost:${toString port}";
extraConfig = "client_max_body_size 104857616;"; # 100MB + 16 bytes
};
};
};
}
snikket/docker-compose.yml
Download docker-compose.yml from Snikket.
Here's what it looks like (listing it for full coverage, I've changed nothing):
version: "3.3"
services:
snikket_proxy:
container_name: snikket-proxy
image: snikket/snikket-web-proxy:stable
env_file: snikket.conf
network_mode: host
volumes:
- snikket_data:/snikket
- acme_challenges:/var/www/html/.well-known/acme-challenge
restart: "unless-stopped"
snikket_certs:
container_name: snikket-certs
image: snikket/snikket-cert-manager:stable
network_mode: host
env_file: snikket.conf
volumes:
- snikket_data:/snikket
- acme_challenges:/var/www/.well-known/acme-challenge
restart: "unless-stopped"
snikket_portal:
container_name: snikket-portal
image: snikket/snikket-web-portal:stable
network_mode: host
env_file: snikket.conf
restart: "unless-stopped"
snikket_server:
container_name: snikket
image: snikket/snikket-server:stable
network_mode: host
volumes:
- snikket_data:/snikket
env_file: snikket.conf
restart: "unless-stopped"
volumes:
acme_challenges:
snikket_data:
snikket/snikket.conf
Be sure to keep in sync with snikket.nix.
# The primary domain of your Snikket instance.
SNIKKET_DOMAIN=chat.example.com
# An email address where the admin can be contacted
# (also used to register your Let's Encrypt account to obtain certificates).
SNIKKET_ADMIN_EMAIL=admin@example.com
# For reverse-proxy.
SNIKKET_TWEAK_HTTP_PORT=5080
SNIKKET_TWEAK_HTTPS_PORT=5443
SNIKKET_TWEAK_TURNSERVER_MIN_PORT=49152
SNIKKET_TWEAK_TURNSERVER_MAX_PORT=49252
snikket/snikket.nix
Generate this file with (run in snikket directory):
nix-shell -p compose2nix --run "compose2nix -inputs docker-compose.yml -output snikket.nix -project snikket"
You need to regenerate it in case docker-compose.yml was changed.
snikket/prepare-certs.sh
Snikket handles ACME with its own service with certbot, so we need mount its directory for Nginx to be able to verify SSL certificates. Also, we need to copy certs generated by Snikket to Nginx.
So we run this script:
- as
prepare-certs.sh mountto prepare directory for ACME challenges - as
prepare-certs.sh copyto copy generated cert files from Snikket to Nginx
#!/usr/bin/env bash
# Mounts Snikket's container volumes for ACME challenges and SSL cert files.
# Run with either `mount` or `copy` argument.
set -e
DOMAIN="chat.example.com"
SNIKKET_GROUP="letsencrypt"
SNIKKET_GROUP_ID=1000 # Get it from Snikket's volume owner.
OCI_CHALLENGES_PATH="/var/lib/containers/storage/volumes/snikket_acme_challenges/_data"
CHALLENGES_ROOT="/var/www/snikket-letsencrypt"
OCI_SSL_ROOT="/var/lib/containers/storage/volumes/snikket_snikket_data/_data/letsencrypt/live/$DOMAIN"
SSL_ROOT="/var/lib/acme"
set_permissions() {
groupadd -fg $SNIKKET_GROUP_ID "$SNIKKET_GROUP"
usermod -aG "$SNIKKET_GROUP" nginx
usermod -aG "$SNIKKET_GROUP" acme
mkdir -p "$1"
chown -R nginx:nginx "$1"
chmod -R g+rwX "$1"
chmod -R g+rs "$1"
}
mount_oci() {
mkdir -p "$OCI_CHALLENGES_PATH"
set_permissions "$CHALLENGES_ROOT"
mount --bind "$OCI_CHALLENGES_PATH" "$CHALLENGES_ROOT/.well-known/acme-challenge"
}
copy_files() {
mkdir -p "$OCI_SSL_ROOT"
cp -rL "$OCI_SSL_ROOT" "$SSL_ROOT"
set_permissions "$SSL_ROOT/$DOMAIN"
}
case $1 in
mount) mount_oci;;
copy) copy_files;;
*) echo "No such operation: '$1'. Run either `mount` or `copy`.";;
esac
snikket/prepare-certs-job.nix
Systemd service to mount ACME challenges directory once and a daily job to copy generated certs at 00:00 UTC:
# Mounts Snikket's container volumes for ACME challenges and SSL cert files.
{ config, pkgs, ... }:
{
systemd.services."snikket-mount-acme" = {
path = with pkgs; [ shadow busybox bash ];
description = "Mount Snikket's OCI volume for ACME challenges";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "/root/nixos/services/snikket/prepare-certs.sh mount";
};
};
systemd.services."snikket-copy-certs" = {
path = with pkgs; [ shadow busybox bash ];
description = "Copies Snikket's certificate files to Nginx";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "/root/nixos/services/snikket/prepare-certs.sh copy";
};
};
systemd.timers."snikket-copy-certs" = {
wantedBy = [ "timers.target" ];
after = [ "local-fs.target" ];
timerConfig = {
OnBootSec = "5m";
OnCalendar = "*-*-* 00:00:00 UTC";
Unit = "snikket-copy-certs.service";
};
};
}
Running
Certs
Check that all Snikket services are up and ready (certs service can take a while to verify newly created certs):
systemctl status podman-snikket*
Replace podman-snikket* with docker-snikket* if you've chosen the Docker backend for compose2nix command.
Copy generated certs from Snikket:
snikket/prepare-certs.sh copy
If there is an error and Certbot inside of Snikket didn't generate certs, than you can inspect its log for details (replace podman with docker, if you use it instead):
podman exec --interactive --tty snikket-certs bash
cat /var/log/letsencrypt/letsencrypt.log
If cert files are there (.pem files at /var/lib/acme/chat.example.com), then restart Nginx for good measure:
systemctl restart nginx.service
First admin user
To create your first user (who is admin), run this command and follow the invitation link:
docker exec snikket create-invite --admin --group default
All other users must join Snikket by an invite link, which you can create in admin panel at chat.example.com/admin/invitations. Invites can be one-time only and not restricted (also you can choose user role and circle).
Bye!
Top comments (2)
Thank you soooo much! You saved me!
Hi, @guefra! I've updated the contents a bit - somehow Snikket's Certbot couldn't renew.
The fix is (as I've managed to come up with) to bind-mount its
acme-challengedirectory and change permissions so Nginx could access it during cert verification. No idea why this was working without mount previously (my docker containers are pinned) 😅So, if you are still hosting Snikket, look at
snikket.nix,prepare-certs.sh(previouslycopy-certs.sh),prepare-certs-job.nix(previouslycopy-certs-job.nix) andRunningsection at the end.