DEV Community

Kir Axanov
Kir Axanov

Posted on • Edited on

NixOS. Snikket. Self-host E2EE messenger with calls

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>
Enter fullscreen mode Exit fullscreen mode

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 - what compose2nix generated 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.
  };
}
Enter fullscreen mode Exit fullscreen mode

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
      };
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 mount to prepare directory for ACME challenges
  • as prepare-certs.sh copy to 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
Enter fullscreen mode Exit fullscreen mode

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";
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If cert files are there (.pem files at /var/lib/acme/chat.example.com), then restart Nginx for good measure:

systemctl restart nginx.service
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
guefra profile image
guefra

Thank you soooo much! You saved me!

Collapse
 
axkira profile image
Kir Axanov

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-challenge directory 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 (previously copy-certs.sh), prepare-certs-job.nix (previously copy-certs-job.nix) and Running section at the end.