# Self-Hosting a Matrix Server with Tuwunel, Portainer, and NPMPlus This tutorial sets up a private Matrix chat server you control. By the end you will have: * **Tuwunel**, a lightweight Matrix homeserver written in Rust (the successor to conduwuit). * **Invite-only registration**, so only people you give a token to can create accounts. * **coturn**, a TURN server for legacy 1:1 voice and video calls. * **MatrixRTC** (LiveKit + lk-jwt-service), the backend for Element Call and calling in Element X. * **NPMPlus** in front of everything, handling HTTPS certificates. The main path uses Docker Compose deployed as a Portainer stack. A Podman version is at the end. coturn and MatrixRTC are both optional. If you only want text chat, skip every section and line marked **(Calls)**. Getting text chat working first and adding calls afterwards makes problems much easier to isolate. > **Versions.** Settings and commands in this guide were checked against Tuwunel 1.9.3, LiveKit 1.13.7, lk-jwt-service 0.7.0, coturn 4.18, and NPMPlus as of September 2026. The compose file uses `latest` image tags, so a later release can change defaults. Section 15.3 explains how to pin versions instead. ### What you need before you start * A Linux host running Docker Engine and Portainer. (Section 16 covers Podman instead.) * NPMPlus already running and able to issue Let's Encrypt certificates. It must use host networking (the NPMPlus default) or be attached to the `proxy` network from section 5.2, so it can reach the containers. * A domain whose DNS records you control. * A router you can forward ports on, and a real public IPv4 address. If your ISP puts you behind carrier-grade NAT, port forwarding cannot work and this guide does not apply as written. * SSH access to the host with `sudo`. --- ## Contents 1. [How the pieces fit together](#1-how-the-pieces-fit-together) 2. [Plan your names and values](#2-plan-your-names-and-values) 3. [DNS, port forwarding, and firewall](#3-dns-port-forwarding-and-firewall) 4. [Generate secrets](#4-generate-secrets) 5. [Prepare the host](#5-prepare-the-host) 6. [Write tuwunel.toml](#6-write-tuwuneltoml) 7. [Write turnserver.conf (Calls)](#7-write-turnserverconf-calls) 8. [Write livekit.yaml (Calls)](#8-write-livekityaml-calls) 9. [Deploy the stack in Portainer](#9-deploy-the-stack-in-portainer) 10. [Configure NPMPlus proxy hosts](#10-configure-npmplus-proxy-hosts) 11. [Verify the server](#11-verify-the-server) 12. [Create your admin account and connect clients](#12-create-your-admin-account-and-connect-clients) 13. [Lock down registration](#13-lock-down-registration) 14. [Test and troubleshoot calls (Calls)](#14-test-and-troubleshoot-calls-calls) 15. [Maintenance](#15-maintenance) 16. [Podman version](#16-podman-version) --- ## 1. How the pieces fit together ```text Internet | +----------------------+-----------------------------+ | | | 80, 443/tcp 3478, 5349 + relay ports 7881/tcp + 50100-50200/udp | | | NPMPlus coturn LiveKit (reverse proxy) (host network) (host network) | | | +--> rtc.chat.example.com --> LiveKit :7880 (signaling) | --> lk-jwt-service :8081 (token endpoints) | +--> chat.example.com --> Tuwunel :6167 ``` | Component | What it does | Needed for | | --- | --- | --- | | Tuwunel | Stores accounts, rooms, and messages. Talks to clients and other Matrix servers. | Everything | | NPMPlus | Terminates HTTPS and forwards requests to Tuwunel, LiveKit, and lk-jwt-service. | Everything | | coturn | Relays call media when two clients cannot connect directly. Used by legacy 1:1 calls. | Legacy calls | | LiveKit | The media server (SFU) for Element Call. Every participant sends media to it. | Element Call, Element X calls | | lk-jwt-service | Confirms that a caller is a real Matrix user, then hands out a LiveKit access token. | Element Call, Element X calls | **Which calls use what:** classic 1:1 calls in Element Web and Desktop (and other clients' legacy calls) use TURN (coturn). Element Call and calls in Element X use MatrixRTC (LiveKit). The two systems are independent, and you can run both. --- ## 2. Plan your names and values Every example value below appears throughout this guide. Fill in the last column first, then replace each example with your value everywhere you see it. | Example value | What it is | Your value | | --- | --- | --- | | `chat.example.com` | **Server name.** Appears in every user ID (`@alice:chat.example.com`). Also where Tuwunel is served. | | | `turn.chat.example.com` | TURN server hostname (Calls). | | | `rtc.chat.example.com` | MatrixRTC hostname for LiveKit and lk-jwt-service (Calls). | | | `10.0.0.81` | The Docker host's **LAN** IP address. | | | `203.0.113.10` | Your **public** IP address. | | | `172.20.0.0/16` | Subnet of the Docker network shared with NPMPlus. | | | `172.20.0.9` | Fixed IP for Tuwunel on that network. | | | `172.20.0.10` | Fixed IP for lk-jwt-service on that network (Calls). | | | `/data/compose/tuwunel` | Host folder holding config files, the database, and backups. | | | `/opt/npmplus` | NPMPlus's data folder on the host (the path in NPMPlus's default compose file). | | | `npm-20` | Folder name of the NPMPlus certificate for `turn.chat.example.com` (Calls; you find it in section 7.1). | | > **The server name is permanent.** It becomes part of every user ID and room ID. Changing it later means wiping the database and starting over. If you want user IDs like `@alice:example.com` while hosting at a subdomain, read Tuwunel's [root domain delegation guide](https://github.com/matrix-construct/tuwunel/blob/main/docs/deploying/root-domain-delegation.md) before continuing. This tutorial uses the simpler setup where the server name and the host are the same. --- ## 3. DNS, port forwarding, and firewall ### DNS records Create `A` records pointing at your public IP (`203.0.113.10`). Add matching `AAAA` records only if your server is reachable over IPv6. | Hostname | Points to | Needed for | | --- | --- | --- | | `chat.example.com` | `203.0.113.10` | Everything | | `turn.chat.example.com` | `203.0.113.10` | Legacy calls | | `rtc.chat.example.com` | `203.0.113.10` | Element Call | ### Router port forwarding Forward these from your router to the Docker host (`10.0.0.81`). Keep each external port equal to its internal port: TURN and LiveKit tell clients which port numbers to use, so a translated port breaks call media. | Port(s) | Protocol | Service | Needed for | | --- | --- | --- | --- | | 80 | TCP | NPMPlus (Let's Encrypt HTTP challenge, redirect to HTTPS) | Everything | | 443 | TCP | NPMPlus (HTTPS) | Everything | | 443 | UDP | NPMPlus (HTTP/3), only if you turn on **HTTP/3 Support** for a host | Optional | | 3478 | TCP + UDP | coturn (STUN/TURN) | Legacy calls | | 5349 | TCP + UDP | coturn (TURN over TLS/DTLS) | Legacy calls | | 49152-49999 | UDP | coturn relay range | Legacy calls | | 7881 | TCP | LiveKit (media over TCP fallback) | Element Call | | 50100-50200 | UDP | LiveKit (media) | Element Call | Do **not** forward 6167 (Tuwunel), 7880 (LiveKit signaling), or 8081 (lk-jwt-service). NPMPlus reaches those internally. Port 8448 (the traditional federation port) is not needed either; Tuwunel tells other servers to use 443 instead (see `[global.well_known]` in section 6). ### Host firewall If the host runs a firewall, open the same ports there. coturn and LiveKit use host networking, as does NPMPlus with its default compose file, so the host firewall applies to them directly. Skip the lines for services you are not running. firewalld (openSUSE, Fedora, RHEL): ```bash sudo firewall-cmd --permanent --add-port=80/tcp --add-port=443/tcp # Calls: coturn sudo firewall-cmd --permanent --add-port=3478/tcp --add-port=3478/udp \ --add-port=5349/tcp --add-port=5349/udp --add-port=49152-49999/udp # Calls: LiveKit sudo firewall-cmd --permanent --add-port=7881/tcp --add-port=50100-50200/udp sudo firewall-cmd --reload ``` ufw (Ubuntu, Debian): ```bash sudo ufw allow 80,443/tcp # Calls: coturn sudo ufw allow 3478,5349/tcp sudo ufw allow 3478,5349/udp sudo ufw allow 49152:49999/udp # Calls: LiveKit sudo ufw allow 7881/tcp sudo ufw allow 50100:50200/udp ``` If you publish `AAAA` records, remember that IPv6 has no NAT: instead of port forwards, your router's IPv6 firewall needs allow rules for the same ports. --- ## 4. Generate secrets Run these on any Linux machine and save the output somewhere safe, such as a password manager. Hex output avoids characters that would need escaping in TOML, YAML, or the shell. ```bash # Registration token (share only with people you invite, or keep private; see section 13) openssl rand -hex 32 # TURN shared secret (Calls: coturn <-> Tuwunel) openssl rand -hex 32 # LiveKit API key, 20 characters (Calls) openssl rand -hex 10 # LiveKit API secret, 64 characters (Calls) openssl rand -hex 32 ``` This guide refers to them as `REGISTRATION_TOKEN`, `TURN_SECRET`, `LIVEKIT_KEY`, and `LIVEKIT_SECRET`. --- ## 5. Prepare the host SSH into the Docker host. ### 5.1 Create the folders and files ```bash sudo mkdir -p /data/compose/tuwunel/data /data/compose/tuwunel/backups sudo touch /data/compose/tuwunel/tuwunel.toml sudo chmod 600 /data/compose/tuwunel/tuwunel.toml # Calls only: sudo touch /data/compose/tuwunel/turnserver.conf /data/compose/tuwunel/livekit.yaml sudo chmod 600 /data/compose/tuwunel/turnserver.conf /data/compose/tuwunel/livekit.yaml ``` Create the files **before** deploying. If a bind-mounted file does not exist, Docker creates an empty *directory* with that name instead, and the container fails to start with a confusing error. The `chmod 600` matters because these files will hold your secrets. Every container in this guide runs as root inside, so root-only files are still readable to them. Then check which filesystem the data folder is on: ```bash stat -f -c %T /data/compose/tuwunel/data ``` If it prints `btrfs` or `zfs`, section 6 has one extra setting for you. `xfs` and `ext2/ext3` (which is how ext4 is reported) need nothing extra. ### 5.2 Create the shared Docker network Tuwunel and lk-jwt-service join a network called `proxy` with fixed IP addresses, so NPMPlus can forward to them without their ports being published on the host. NPMPlus can reach those IPs when it runs on the host network (the NPMPlus default) or is attached to `proxy` itself. If NPMPlus sits on some other Docker network, Docker isolates it from `proxy`; attach NPMPlus to `proxy`, or use the published-ports variant noted in section 9. If you already have a `proxy` network (from NPMPlus or another stack), check its subnets and skip creation: ```bash docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}' ``` Otherwise create it: ```bash # IPv4 only: docker network create --subnet 172.20.0.0/16 proxy # Or IPv4 + IPv6: docker network create --subnet 172.20.0.0/16 --ipv6 --subnet fd00:20::/64 proxy ``` The IPs you assign in the compose file (`172.20.0.9`, `172.20.0.10`) must fall inside this subnet and must not be used by any other container. --- ## 6. Write tuwunel.toml ```bash sudo nano /data/compose/tuwunel/tuwunel.toml ``` ```toml [global] # --- Identity (permanent) --- server_name = "chat.example.com" # --- Storage and networking --- database_path = "/var/lib/tuwunel" address = ["0.0.0.0"] port = 6167 max_request_size = "24 MiB" # Take the real client IP from the X-Forwarded-For header NPMPlus sets. # Only safe while Tuwunel is reachable exclusively through NPMPlus # (see the table below). ip_source = "rightmost_x_forwarded_for" # --- Registration (token-gated) --- allow_registration = true registration_token = "REGISTRATION_TOKEN" grant_admin_to_first_user = true new_user_displayname_suffix = "" # --- Federation --- allow_federation = true trusted_servers = ["matrix.org"] # --- Backups (section 15.4) --- database_backup_path = "/var/lib/tuwunel-backups" database_backups_to_keep = 7 # Run a database backup whenever the container receives SIGUSR2. admin_signal_execute = ["server backup-database"] # --- Legacy calls via coturn (Calls; delete this block if not using coturn) --- turn_uris = [ "turns:turn.chat.example.com:5349?transport=tcp", "turn:turn.chat.example.com:3478?transport=udp", "turn:turn.chat.example.com:3478?transport=tcp", ] turn_secret = "TURN_SECRET" turn_ttl = 86400 # --- Discovery files and MatrixRTC --- # Keep this section header. Without it, the settings below are read as # unknown [global] options and have no effect. [global.well_known] client = "https://chat.example.com" server = "chat.example.com:443" # Calls (Element Call). Delete this line if not using MatrixRTC. livekit_url = "https://rtc.chat.example.com" ``` > **btrfs or ZFS only.** If `stat` in section 5.1 printed `btrfs` (the default root filesystem on openSUSE and Fedora Workstation), add `rocksdb_allow_fallocate = false` to the `[global]` section. On btrfs, RocksDB's preallocated log files hold on to far more disk than `du` reports. On ZFS, add both `rocksdb_allow_fallocate = false` and `rocksdb_direct_io = false`. Tuwunel's [maintenance docs](https://github.com/matrix-construct/tuwunel/blob/main/docs/maintenance.md) explain why, and Tuwunel logs a warning at startup if `rocksdb_allow_fallocate` is still on for either filesystem. ### What each setting does | Setting | Meaning | What to change | | --- | --- | --- | | `server_name` | The domain in every user ID. **Cannot be changed later** without wiping the database. | Your domain. | | `database_path` | Where the database and uploaded media live inside the container. Matches the `data` volume in the compose file. | Leave as is. | | `address` | Interfaces to listen on. Must be `0.0.0.0` in a container so NPMPlus and other containers can connect. You can use `["0.0.0.0", "::"]` if your `proxy` network has IPv6. Every address listed must bind, so `::` on a network without IPv6 stops Tuwunel from starting. | Leave as is unless using IPv6. | | `port` | Port Tuwunel listens on inside the container. NPMPlus forwards to it. Tuwunel's default is 8008; any port works as long as NPMPlus matches. | Leave as is. | | `max_request_size` | Largest upload allowed (images, files). 24 MiB is also Tuwunel's default; the line is here so you know where to raise it. | Raise for larger uploads. | | `ip_source` | Where Tuwunel reads the real client IP for rate limiting and logs. `rightmost_x_forwarded_for` fits NPMPlus. If port 6167 is ever published on the host, clients could bypass NPMPlus and forge the header, so remove the line in that case. Changing it requires a restart. | Leave as is with this setup. | | `allow_registration` | Whether new accounts can be created. With a token set, only people who have a valid token can register. | See section 13. | | `registration_token` | Static token required to register. Tuwunel refuses to start if registration is on and no token (or an empty one) is set, unless you explicitly opt into open registration. | Your `REGISTRATION_TOKEN`. | | `grant_admin_to_first_user` | The first account created becomes server admin. This is already the default. (Precisely: admin goes to the next account registered while the admin room has no human members.) | Leave `true`, and register your own account first. | | `new_user_displayname_suffix` | Text appended to new users' display names. Tuwunel's default is a heart emoji; `""` disables it. | Optional. | | `allow_federation` | Whether users can talk to people on other Matrix servers (matrix.org and others). `false` makes your server an isolated island. Calls keep working either way. | Your choice. | | `trusted_servers` | Servers used to fetch other servers' signing keys. Tuwunel's docs say this list should only contain Synapse servers; matrix.org is one. | Leave as is. | | `database_backup_path` | Where `!admin server backup-database` writes backups. Matches the `backups` volume in the compose file. Backup commands fail while this is unset. | Leave as is. | | `database_backups_to_keep` | How many backups to keep before the oldest is deleted. | Optional. | | `admin_signal_execute` | Admin commands to run when Tuwunel receives `SIGUSR2`. Here it runs a database backup, so a cron job can schedule backups (section 15.4). Sending the signal does nothing else. | Optional. | | `turn_uris` | TURN addresses sent to clients for legacy calls. `turns:` is TLS on 5349; `turn:` is plain on 3478. | Your TURN hostname. | | `turn_secret` | Shared secret used to generate short-lived TURN credentials. **Must match** `static-auth-secret` in `turnserver.conf`. | Your `TURN_SECRET`. | | `turn_ttl` | How long (seconds) generated TURN credentials stay valid. | Leave as is. | | `[global.well_known] client` | Published at `/.well-known/matrix/client` so clients can find your server from just the domain. | `https://` + your server name. | | `[global.well_known] server` | Published at `/.well-known/matrix/server`. Tells other servers to federate over port 443 instead of 8448. | Your server name + `:443`. | | `livekit_url` | The URL of lk-jwt-service (Tuwunel's config calls it the LiveKit JWT endpoint). Advertised to clients in `.well-known/matrix/client` and at the `rtc/transports` API endpoint that Element X and Element Call query. | `https://` + your RTC hostname. | The full list of options, with defaults and a `reloadable: yes` marker on each one that can change without a restart, is in Tuwunel's [example config](https://github.com/matrix-construct/tuwunel/blob/main/tuwunel-example.toml). --- ## 7. Write turnserver.conf (Calls) Skip if you are not using coturn. Two things to know first. Synapse's TURN documentation warns that running TURN behind NAT is known to cause problems even when everything is configured correctly, so plan time for testing (section 14). It also recommends getting plain TURN working before relying on TURN over TLS; if `turns:` gives you trouble, you can remove that URI from `turn_uris` and come back to it later. ### 7.1 Request a certificate for the TURN hostname coturn needs its own certificate for `turn.chat.example.com`, and you need its folder name for the config file. 1. In NPMPlus, open **Certificates** and add a new certificate for `turn.chat.example.com`. Use the HTTP challenge (leave **Use DNS Challenge** off unless DNS validation is what you normally use). No proxy host is needed for this name. 2. Find the folder NPMPlus stored it in: ```bash sudo sh -c 'for f in /opt/npmplus/tls/certbot/live/npm-*/cert.pem; do echo "$f: $(openssl x509 -noout -subject -in "$f")"; done' ``` The line whose subject names `turn.chat.example.com` gives the folder, for example `.../live/npm-20/cert.pem` means `npm-20`. ### 7.2 Write the file ```bash sudo nano /data/compose/tuwunel/turnserver.conf ``` ```ini # --- Authentication --- use-auth-secret static-auth-secret=TURN_SECRET realm=turn.chat.example.com # --- Networking --- listening-ip=10.0.0.81 relay-ip=10.0.0.81 external-ip=203.0.113.10/10.0.0.81 listening-port=3478 tls-listening-port=5349 min-port=49152 max-port=49999 # --- TLS certificate from NPMPlus --- cert=/certs/live/npm-20/fullchain.pem pkey=/certs/live/npm-20/privkey.pem # --- General --- fingerprint no-cli log-file=stdout # --- Security: stop the relay from reaching private networks --- no-tcp-relay no-multicast-peers denied-peer-ip=0.0.0.0-0.255.255.255 denied-peer-ip=10.0.0.0-10.255.255.255 denied-peer-ip=100.64.0.0-100.127.255.255 denied-peer-ip=127.0.0.0-127.255.255.255 denied-peer-ip=169.254.0.0-169.254.255.255 denied-peer-ip=172.16.0.0-172.31.255.255 denied-peer-ip=192.0.0.0-192.0.0.255 denied-peer-ip=192.0.2.0-192.0.2.255 denied-peer-ip=192.88.99.0-192.88.99.255 denied-peer-ip=192.168.0.0-192.168.255.255 denied-peer-ip=198.18.0.0-198.19.255.255 denied-peer-ip=198.51.100.0-198.51.100.255 denied-peer-ip=203.0.113.0-203.0.113.255 denied-peer-ip=240.0.0.0-255.255.255.255 denied-peer-ip=::1 denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255 denied-peer-ip=100::-100::ffff:ffff:ffff:ffff denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff allowed-peer-ip=10.0.0.81 # --- Limits --- user-quota=12 total-quota=1200 ``` ### What each setting does | Setting | Meaning | What to change | | --- | --- | --- | | `use-auth-secret` / `static-auth-secret` | Tuwunel generates temporary credentials from this shared secret; coturn verifies them. Must match `turn_secret` in `tuwunel.toml`. | Your `TURN_SECRET`. | | `realm` | Your TURN hostname. | Your TURN hostname. | | `listening-ip` / `relay-ip` | The host's LAN IP. coturn uses host networking, so it binds directly to this address. | Your LAN IP. | | `external-ip` | `PUBLIC/PRIVATE` mapping. Tells clients to reach relayed media at your public IP. Without it, coturn behind NAT hands out its private address and relayed calls fail. | `your public IP/your LAN IP`. See the dynamic IP note below. | | `listening-port` / `tls-listening-port` | Plain TURN (3478) and TURN over TLS (5349). | Leave as is. | | `min-port` / `max-port` | UDP range used to relay call media. Each relayed call uses several ports, so a range of only a few ports supports just one or two relayed calls at once. The range must be forwarded on your router, and must stay below 50100 so it does not overlap LiveKit. | Widen or narrow to taste; update your port forward to match. | | `cert` / `pkey` | TLS certificate and key for `turn.chat.example.com`, read from NPMPlus's certificate folder (section 7.1). | Your `npm-XX` folder. | | `fingerprint` | Adds fingerprints to TURN messages, which WebRTC clients expect. | Leave as is. | | `no-cli` | Disables coturn's telnet admin interface. | Leave as is. | | `log-file=stdout` | Sends logs to `docker logs coturn`. | Leave as is. | | `no-tcp-relay` | Call media is UDP; there is no reason to let the relay open TCP connections. | Leave as is. | | `denied-peer-ip` | **Security.** Without these, anyone holding TURN credentials could use your relay to reach devices on your LAN. This list matches Synapse's recommended coturn configuration. | Leave as is. | | `allowed-peer-ip` | Exception so the relay can reach itself (needed when both callers are relayed through this server). | Your LAN IP. | | `user-quota` / `total-quota` | Caps relayed streams per user and in total, limiting abuse. 12 streams is about 3 simultaneous relayed calls per user. | Optional. | **Dynamic public IP.** The coturn image's default command detects your public IP at startup (it runs `detect-external-ip` and passes the result as `--external-ip`). Section 9 overrides that command so `turnserver.conf` is the only source of settings, which is what you want with a static IP. If your public IP changes: 1. Delete the `external-ip` line from `turnserver.conf`. 2. Delete the `command:` line from the coturn service in section 9 (or the `Exec=` line in the Podman Quadlet, section 16.4), so the image's default command runs. The IP is detected only when the container starts, so restart coturn after your public IP changes. If detection fails at boot (for example, the internet connection is not up yet), coturn starts without a public IP and relayed calls fail until you restart it. **Let's Encrypt and TURN over TLS.** Synapse's coturn guide states that TURN over TLS/DTLS with a Let's Encrypt certificate does not work in clients built on Chromium's WebRTC library, naming Element Android and iOS at the time of writing, and suggests a ZeroSSL certificate instead. Tuwunel's MatrixRTC guide repeats the ZeroSSL suggestion for TLS TURN. Clients that fail on the `turns:` URI still try the plain `turn:` URIs, so this rarely breaks calls outright. --- ## 8. Write livekit.yaml (Calls) Skip if you are not using Element Call. ```bash sudo nano /data/compose/tuwunel/livekit.yaml ``` ```yaml port: 7880 bind_addresses: - "" rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 # Gather media candidates only on the host's LAN address, so Docker # bridge addresses (172.17.x.x and up) are never offered to clients. ips: includes: - 10.0.0.81/32 # Static public IP: advertise it explicitly. See "Choosing the advertised IP". use_external_ip: false node_ip: "203.0.113.10" # Also advertise the LAN address, so devices on your own network can # connect even if your router lacks NAT loopback. advertise_internal_ip: true enable_loopback_candidate: false room: auto_create: false keys: LIVEKIT_KEY: LIVEKIT_SECRET logging: level: info ``` ### What each setting does | Setting | Meaning | What to change | | --- | --- | --- | | `port` | LiveKit's signaling (WebSocket) port. NPMPlus forwards `rtc.chat.example.com` here. | Leave as is. | | `bind_addresses: [""]` | Listen on all interfaces. | Leave as is. | | `rtc.tcp_port` | Media over TCP, used when UDP is blocked. Must be forwarded. | Leave as is. | | `rtc.port_range_start` / `end` | UDP media ports. Must be forwarded. | Leave as is. | | `rtc.ips.includes` | Only addresses inside these ranges (CIDR notation) are used for media. `/32` means exactly one IPv4 address. This keeps Docker bridge addresses out; it also leaves out IPv6 unless you add your IPv6 address or prefix. | Your LAN IP + `/32`. | | `use_external_ip` / `node_ip` | Which public IP LiveKit tells clients to send media to. `node_ip` only applies while `use_external_ip` is `false`. | See "Choosing the advertised IP" below. | | `advertise_internal_ip` | Offers the LAN address alongside the public one, so LAN devices can reach LiveKit directly. The tradeoff is that other participants' clients learn your server's LAN address. This key is recent (it is in LiveKit 1.13.7); if you pin an older image, check that its `config-sample.yaml` lists it. | Leave `true`, or remove it if all your users are remote. | | `enable_loopback_candidate` | Advertise `127.0.0.1` to clients. Only useful when the public IP is mapped to the loopback interface. | Leave `false`. | | `room.auto_create` | `false` means only lk-jwt-service can create call rooms, so only users of your server can start calls. lk-jwt-service's documentation calls this required for restricting room creation. | Leave `false`. | | `keys` | API key and secret pair. **Both halves must match** `LIVEKIT_KEY` and `LIVEKIT_SECRET` in the lk-jwt-service environment. | Replace both words with your generated values, e.g. `a1b2c3...: 9f8e7d...`. | | `logging.level` | Log verbosity. `debug` helps when troubleshooting. | Optional. | **Choosing the advertised IP.** LiveKit runs with host networking, so it can see every interface on the host, including Docker's internal bridges. Clients cannot reach those, and offering them can delay or break calls. The `ips.includes` filter above removes them whichever option you pick here: | Your situation | `rtc` settings | | --- | --- | | Static public IP, server behind a router (the example above) | `use_external_ip: false` and `node_ip` set to your public IP. | | Dynamic public IP, server behind a router | `use_external_ip: true`, and delete the `node_ip` line. LiveKit discovers your public IP via STUN at startup, so restart it after your IP changes. If your router lacks NAT loopback, also add `skip_external_ip_validation: true`: LiveKit otherwise verifies the discovered address by sending a packet to itself through the router. | | VPS with the public IP on its own network interface (no NAT) | `use_external_ip: false`, `node_ip` set to the public IP, the public IP in `ips.includes`, and no `advertise_internal_ip`. This is the case Tuwunel's MatrixRTC guide recommends `node_ip` for. | To confirm what LiveKit chose, check its log after starting (section 9): ```bash docker logs livekit 2>&1 | grep -iE "nat1to1|external ips" ``` The addresses listed there should be your public IP (and, with `advertise_internal_ip`, your LAN IP). A `172.x.x.x` address means the `ips.includes` filter is missing or wrong. --- ## 9. Deploy the stack in Portainer In Portainer, go to **Stacks > Add stack**, name it `matrix`, choose **Web editor**, and paste the following. Delete the services you are not using. ```yaml services: homeserver: image: jevolk/tuwunel:latest container_name: tuwunel restart: unless-stopped # The first start after an upgrade can run a database migration that must # not be interrupted. The default 10-second stop timeout is far too short. stop_grace_period: 30m # Uncomment to publish on the host and point NPMPlus at 10.0.0.81:6167 # instead of the fixed IP below. If you do, remove `ip_source` from tuwunel.toml. #ports: # - "6167:6167" volumes: - /data/compose/tuwunel/data:/var/lib/tuwunel - /data/compose/tuwunel/backups:/var/lib/tuwunel-backups - /data/compose/tuwunel/tuwunel.toml:/etc/tuwunel.toml:ro environment: TUWUNEL_CONFIG: /etc/tuwunel.toml networks: proxy: ipv4_address: 172.20.0.9 # Only if your proxy network has IPv6 enabled: #ipv6_address: fd00:20::9 # ---- Legacy calls (optional) ---- coturn: image: coturn/coturn:latest container_name: coturn restart: unless-stopped # Use only turnserver.conf. The image's default command would also detect # and add a public IP (see "Dynamic public IP" in section 7). command: ["-c", "/etc/coturn/turnserver.conf"] # Needed to read the private key, which NPMPlus stores as root-only. user: root # Host networking avoids mapping hundreds of UDP ports through Docker. network_mode: host volumes: - /data/compose/tuwunel/turnserver.conf:/etc/coturn/turnserver.conf:ro # Mount the whole certbot folder so the live/ symlinks still resolve, # and so renewed certificates are visible without recreating the container. - /opt/npmplus/tls/certbot:/certs:ro # ---- Element Call / MatrixRTC (optional) ---- livekit: image: livekit/livekit-server:latest container_name: livekit restart: unless-stopped command: --config /etc/livekit.yaml network_mode: host volumes: - /data/compose/tuwunel/livekit.yaml:/etc/livekit.yaml:ro lk-jwt: image: ghcr.io/element-hq/lk-jwt-service:latest container_name: lk-jwt restart: unless-stopped environment: LIVEKIT_JWT_BIND: ":8081" LIVEKIT_URL: "wss://rtc.chat.example.com" LIVEKIT_KEY: "LIVEKIT_KEY" LIVEKIT_SECRET: "LIVEKIT_SECRET" LIVEKIT_FULL_ACCESS_HOMESERVERS: "chat.example.com" # Lets this container reach your homeserver through NPMPlus on the LAN, # even if your router does not support NAT loopback. extra_hosts: - "chat.example.com:10.0.0.81" networks: proxy: ipv4_address: 172.20.0.10 networks: proxy: external: true ``` Click **Deploy the stack**. In **Containers**, `tuwunel` should show as `healthy` within a minute or two (the image has a built-in health check). After an upgrade that migrates the database it can stay `starting` for much longer, which is expected. Portainer keeps a copy of this stack file, secrets included, in its own data volume. Treat that volume and any backups of it as sensitive. ### What each part does **homeserver (Tuwunel)** | Line | Meaning | What to change | | --- | --- | --- | | `image` | Official image from Docker Hub. The same image is published as `ghcr.io/matrix-construct/tuwunel:latest`. `latest` is the newest tagged release (roughly monthly). | Optional. | | `stop_grace_period: 30m` | Gives database migrations time to finish during an upgrade. Tuwunel's docs warn that a migration killed partway through leaves the database half migrated, and no admin command repairs that. | Leave as is. | | `ports` | Commented out: Tuwunel is reachable only through the `proxy` network. | Uncomment only if using host ports instead. | | volume `data` | Database and uploaded media (media lives in its `media` folder). **Back this up** (section 15.4). | Your host path. | | volume `backups` | Where database backups are written (`database_backup_path`). | Your host path. | | volume `tuwunel.toml` | Your config, mounted read-only. | Your host path. | | `TUWUNEL_CONFIG` | Tells Tuwunel where the config file is inside the container. | Leave as is. | | `ipv4_address` | Fixed IP that NPMPlus forwards to. | An unused IP inside your `proxy` subnet. | | `ipv6_address` | Optional. Use an address inside your network's IPv6 subnet (the `fd00:20::/64` example from section 5.2), not the `2001:db8::/32` range, which is reserved for documentation examples. | Only if your network has IPv6. | **coturn** | Line | Meaning | What to change | | --- | --- | --- | | `command` | Starts coturn with only `turnserver.conf`. Without it, the image's default command also auto-detects a public IP and passes it on the command line, on top of your config file. | Leave as is, unless your public IP is dynamic (section 7). | | `user: root` | The coturn image normally runs as `nobody`, which cannot read NPMPlus's root-only private key. Running as root is the simplest fix; the tradeoff is that coturn runs with root privileges on the host network. | Leave as is, or copy the key somewhere readable and drop this line. | | `network_mode: host` | coturn binds directly to the host. Docker port mapping would need hundreds of UDP mappings and adds overhead. | Leave as is. | | turnserver.conf mount | The config from section 7. | Your host path. | | certbot mount | NPMPlus's `live/npm-XX/` files are symlinks into `archive/`. Mounting the individual files would pin whichever certificate existed at deploy time, and coturn would keep serving it after renewal. Mounting the whole folder keeps the symlinks working. Section 15.1 covers reloading after renewal. | Your NPMPlus path. | **livekit** | Line | Meaning | What to change | | --- | --- | --- | | `network_mode: host` | Recommended by Tuwunel's docs; LiveKit needs direct access to its UDP port range. | Leave as is. | | livekit.yaml mount | The config from section 8. | Your host path. | **lk-jwt (lk-jwt-service)** | Variable | Meaning | What to change | | --- | --- | --- | | `LIVEKIT_JWT_BIND` | Listen address inside the container. The service's own default is `:8080`. | Leave as is. | | `LIVEKIT_URL` | Public WebSocket URL of LiveKit, handed to clients. | `wss://` + your RTC hostname. | | `LIVEKIT_KEY` / `LIVEKIT_SECRET` | Must match the `keys:` pair in `livekit.yaml`. | Your generated values. | | `LIVEKIT_FULL_ACCESS_HOMESERVERS` | Users on these servers can start calls. Users from other servers can only join calls that already exist. | Your server name. | | `extra_hosts` | lk-jwt-service verifies each caller by asking your homeserver, over HTTPS, who the caller is. From inside your network, `chat.example.com` resolves to your public IP, which fails on routers without NAT loopback. This maps it to the host's LAN IP, where NPMPlus answers with the same valid certificate. | Your server name and LAN IP. | --- ## 10. Configure NPMPlus proxy hosts Current NPMPlus already enables HTTP/2 and passes WebSocket upgrades on every proxy host, and it sets no upload size limit (`client_max_body_size 0`), so large uploads reach Tuwunel, which enforces `max_request_size` itself. If your NPMPlus shows a **Websockets Support** or **HTTP/2 Support** toggle, turning it on does no harm. ### 10.1 Proxy host for Tuwunel **Hosts > Proxy Hosts**, then add a proxy host: | Field | Value | | --- | --- | | Domain Names | `chat.example.com` | | Scheme | `http` | | Forward Hostname / IP | `172.20.0.9` (or `10.0.0.81` if you published port 6167) | | Forward Port | `6167` | | Certificate | Request a new certificate for this host and enable **Force HTTPS**. **HTTP/3 Support** is optional; if you enable it, forward 443/UDP too (section 3). | No custom Nginx configuration is needed. This one proxy host covers everything Tuwunel serves: `/_matrix/` (client and federation APIs), `/_tuwunel/`, and `/.well-known/matrix/`. ### 10.2 Proxy host for MatrixRTC (Calls) **Hosts > Proxy Hosts**, then add a proxy host: | Field | Value | | --- | --- | | Domain Names | `rtc.chat.example.com` | | Scheme | `http` | | Forward Hostname / IP | `10.0.0.81` | | Forward Port | `7880` | | Certificate | Request a new certificate for this host and enable **Force HTTPS**. | In the **Advanced** tab, paste this into **Custom Nginx Configuration**. It sends lk-jwt-service's endpoints to lk-jwt-service; everything else on `rtc.chat.example.com` goes to LiveKit on port 7880. ```nginx location ~ ^/(sfu/get|get_token|delegate_delayed_leave|healthz) { proxy_pass http://172.20.0.10:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } ``` The paths: `get_token` is what current clients use, `sfu/get` serves older clients, and `healthz` is a health check. `delegate_delayed_leave` is only used with delayed events, which Tuwunel does not support yet (section 14.7); routing it now costs nothing. Tuwunel's own Nginx example routes the other three. If you published port 8081 on the host instead of using the `proxy` network, change the `proxy_pass` line to `http://10.0.0.81:8081`. --- ## 11. Verify the server Run these from any machine. Each should succeed before you move on. ```bash # Tuwunel is up and reachable through NPMPlus; returns its name and version as JSON curl https://chat.example.com/_tuwunel/server_version # Client discovery file; should contain "https://chat.example.com" curl https://chat.example.com/.well-known/matrix/client # Federation discovery file; should show "chat.example.com:443" curl https://chat.example.com/.well-known/matrix/server ``` On the host, the health check should report `healthy`: ```bash docker ps --filter name=tuwunel --format '{{.Names}}: {{.Status}}' ``` Then check federation with the [Matrix Federation Tester](https://federationtester.matrix.org/): enter `chat.example.com`. It should report success. Skip this if you disabled federation. Container logs are in Portainer (**Containers > tuwunel > Logs**) or via `docker logs -f tuwunel`. | Symptom | Likely cause | | --- | --- | | `502 Bad Gateway` from NPMPlus | NPMPlus cannot reach `172.20.0.9:6167`. Check that the container is running and that NPMPlus can reach the `proxy` network (section 5.2). | | `/.well-known/matrix/client` returns 404 | The `[global.well_known]` header or its `client` line is missing from `tuwunel.toml`. | | Container restarts in a loop | Read `docker logs tuwunel`. A configuration error names the offending option, for example registration enabled without a token. | | Certificate errors | The NPMPlus certificate request failed. Check that port 80 is forwarded and the DNS record points at your public IP. | --- ## 12. Create your admin account and connect clients ### 12.1 Register the first account (becomes admin) Do this immediately after deployment. The first account registered gets admin rights. 1. Open [Element Web](https://app.element.io) or install **Element Desktop**. 2. Click **Create account**. 3. Next to the homeserver, click **Edit** and enter `chat.example.com`. 4. Choose a username and password. 5. When asked, enter your `REGISTRATION_TOKEN`. You will be joined to the server's **admin room**. Server commands are sent there as chat messages starting with `!admin`. Send `!admin --help` to list them. ### 12.2 Signing in on other devices | Client | How to connect | | --- | --- | | Element Web / Desktop | **Sign in > Edit** the homeserver > `chat.example.com`. | | Element X (iOS / Android) | Choose **Other** / change the account provider and enter `chat.example.com`, then sign in with your username and password. Tuwunel supports the simplified sliding sync that Element X requires. Element X creates accounts through OAuth-based sign-in, which this setup does not enable by default, so create accounts in Element Web first or see 12.3. | | Other clients (FluffyChat, Cinny, and others) | Enter `chat.example.com` as the homeserver. | ### 12.3 Optional: account creation in Element X Tuwunel includes its own OAuth 2.0 server for "next-generation" Matrix sign-in. Turning on its built-in login page lets OAuth-capable clients such as Element X register and sign in against your server directly. Registration there enforces the same `allow_registration` and registration token rules as the classic flow. Add to the `[global]` section of `tuwunel.toml`: ```toml oidc_native_auth = true ``` Restart Tuwunel afterwards (`docker restart tuwunel`); the first time, a config reload is not enough. OAuth-capable clients may then switch to a browser-based sign-in page instead of the classic username and password form, so test sign-in with the clients your users actually have. Details are in Tuwunel's [OIDC server documentation](https://github.com/matrix-construct/tuwunel/blob/main/docs/authentication/oidc-server.md). ### 12.4 Set up encryption backup In Element, go to **Settings > Encryption** and set up recovery. Without it, signing out of your last device loses access to your encrypted message history. --- ## 13. Lock down registration `allow_registration = true` plus a `registration_token` already means strangers cannot sign up. How you hand out access is up to you. Pick one approach. Except for `allow_federation`, the config changes in this section apply without a restart. After editing `tuwunel.toml`, reload with either of these: ```bash docker kill --signal=SIGUSR1 tuwunel ``` ```text !admin server reload-config ``` ### Option A: One shared token (simplest) Give the `registration_token` from `tuwunel.toml` to the people you invite. Anyone who has it can create accounts until you change it. To rotate it, change the value in `tuwunel.toml` and reload. ### Option B: Single-use or expiring tokens (recommended) Keep the static token in `tuwunel.toml` long, random, and secret. Never share it. (Tuwunel requires a static token or token file whenever registration is enabled, even if you only hand out the tokens below.) Instead, issue a separate token per person from the admin room: ```text !admin token issue --once !admin token issue --max-age 7d !admin token issue --max-uses 3 --max-age 2d ``` Manage them with: ```text !admin token list !admin token revoke ``` A token issued with `--once` works for exactly one registration, so a leaked token cannot be reused. ### Option C: Closed registration (admin creates every account) Set in `tuwunel.toml`, then reload: ```toml allow_registration = false ``` The `registration_token` line can stay; it has no effect while registration is off. Create each account from the admin room: ```text !admin users create-user alice ``` Tuwunel replies with the new user ID and a generated password. Send it to the person over a separate channel and have them change it. If someone forgets their password later, generate a new one with: ```text !admin users reset-password alice ``` Both commands also accept a password as a second argument if you would rather choose it yourself. ### Related lockdown options Add any of these to the `[global]` section of `tuwunel.toml`: | Setting | Effect | | --- | --- | | `allow_federation = false` | Users can only talk to other users on your server. Calls still work. Needs a restart, not just a reload. | | `lockdown_public_room_directory = true` | Only admins can publish rooms to your server's public room directory. | | `allow_guest_registration = false` | Blocks guest accounts. This is already the default. | **Never** set `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true` on a server you care about. It allows anyone to register with no token, and open Matrix servers are routinely abused by spammers. --- ## 14. Test and troubleshoot calls (Calls) This section combines the troubleshooting steps from Tuwunel's [MatrixRTC guide](https://github.com/matrix-construct/tuwunel/blob/main/docs/calls/matrix_rtc.md) with Element's [Matrix RTC documentation](https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/) (written for their Kubernetes product), adapted to this setup. ### 14.1 Check discovery ```bash # Must include "org.matrix.msc4143.rtc_foci" with your livekit_service_url curl https://chat.example.com/.well-known/matrix/client # Must list your LiveKit transport. An empty list means livekit_url is missing in tuwunel.toml curl https://chat.example.com/_matrix/client/unstable/org.matrix.msc4143/rtc/transports ``` Element X and Element Call read the `rtc/transports` endpoint (no access token is needed to query it). If it is empty, clients report that the server has no call support even when the `.well-known` file looks correct. ### 14.2 Check lk-jwt-service routing ```bash # Should return HTTP 405 (Method Not Allowed). That means the request reached lk-jwt-service. curl -i https://rtc.chat.example.com/sfu/get # Should return 200 curl -i https://rtc.chat.example.com/healthz ``` A 404, 502, or an HTML page means the NPMPlus Advanced config (section 10.2) is not routing these paths correctly. ### 14.3 End-to-end test Tuwunel's docs recommend the [`testmatrix`](https://codeberg.org/spaetz/testmatrix) tool: ```bash pip install testmatrix # or: pipx install testmatrix testmatrix -u @you:chat.example.com -t YOUR_ACCESS_TOKEN chat.example.com ``` Use `pipx` (or a virtual environment) on distributions that refuse system-wide `pip install`. Get your access token in Element Web under **Settings > Help & About** (bottom of the page). Treat it like a password. If all tests pass, the tool prints credentials you can paste into the [LiveKit Connection Tester](https://livekit.com/webrtc/connection-test) to confirm media can flow. ### 14.4 Check STUN, TURN, and LiveKit ports from outside Run these from a machine **outside** your network, such as a phone hotspot or a VPS. STUN (install your distribution's coturn package for the tool): ```bash turnutils_stunclient -p 3478 turn.chat.example.com ``` The IP it prints should be the testing machine's public IP. On macOS, the Homebrew `stuntman` formula provides `stunclient --protocol udp turn.chat.example.com 3478`. TURN, end to end. Ask Tuwunel for temporary TURN credentials: ```bash curl -s -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ https://chat.example.com/_matrix/client/v3/voip/turnServer ``` Open the WebRTC project's [Trickle ICE page](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/), add `turn:turn.chat.example.com:3478?transport=udp` with the `username` and `password` from that response, and click **Gather candidates**. A candidate of type `relay` means coturn authenticated you and allocated a relay port. LiveKit's media ports: ```bash nc -vz 203.0.113.10 7881 # TCP media port nc -vzu 203.0.113.10 50100 # UDP: "succeeded" is not conclusive for UDP, but a refusal is ``` ### 14.5 Inspect a live call in the browser During a call, open `about:webrtc` (Firefox) or `chrome://webrtc-internals` (Chrome/Chromium). Look for: * **ICE connection state** reaching `connected` or `completed`. * The **remote candidate** showing your public IP (`203.0.113.10`), or your LAN IP for a device on your own network. A `172.x.x.x` Docker address means LiveKit's `ips.includes` filter is missing (section 8). ### 14.6 Common failures | Symptom | Likely cause | Fix | | --- | --- | --- | | Element Call reports that no call backend is configured (for example `MISSING_MATRIX_RTC_FOCUS`) | `livekit_url` missing, or the `[global.well_known]` header missing above it. | Fix section 6, then restart Tuwunel (`docker restart tuwunel`). | | lk-jwt logs `Failed to look up user info` | lk-jwt-service cannot reach `https://chat.example.com` from inside your network. | Check `extra_hosts` in the compose file (section 9) and that NPMPlus answers on `10.0.0.81:443`. | | `curl .../sfu/get` returns 404 or an HTML page | The NPMPlus Advanced config is missing or was not saved. | Section 10.2. | | Calls work from outside your network but fail on your LAN | Your router does not support NAT loopback, so LAN devices cannot reach your public IP. | Keep `advertise_internal_ip: true` (section 8) for the media path, and add local DNS overrides so `chat.example.com` and `rtc.chat.example.com` resolve to `10.0.0.81` on your LAN (router DNS, AdGuard Home, Pi-hole, or `/etc/hosts`). | | Calls connect slowly or not at all | LiveKit is advertising unreachable addresses, such as Docker bridge IPs or an outdated public IP. | Check `ips.includes` and `node_ip`, and the log line described at the end of section 8. | | Legacy 1:1 calls fail, Element Call works | coturn problem. | Check `external-ip`, the port forwards, and `docker logs coturn`. | | `turns:` fails while `turn:` works | Wrong certificate folder in `turnserver.conf`, or a client that rejects Let's Encrypt certificates for TURN. | Check `docker logs coturn` for certificate errors, and see the Let's Encrypt note in section 7. | ### 14.7 Known limitation Tuwunel's own compatibility table lists MSC4140 (delayed events) as not implemented. Element Call uses delayed events to automatically remove a participant from a call when their client disconnects without hanging up, for example when the app crashes or the network drops. Expect occasional "ghost" participants to linger in a call's member list until their membership expires. Normal hang-ups are not affected. lk-jwt-service's delegated leave feature (and the LiveKit webhook its documentation describes) exists to clean this up, but it also depends on MSC4140 in the homeserver, so there is nothing to configure for it with Tuwunel today. --- ## 15. Maintenance ### 15.1 Reload coturn after certificate renewal (Calls) NPMPlus renews certificates automatically, but coturn keeps using the old one until told to reload. coturn re-reads its TLS certificates on `SIGUSR2`, without a restart. Add a weekly job to root's crontab (`sudo crontab -e`): ```cron 0 4 * * 1 docker kill --signal=SIGUSR2 coturn >/dev/null 2>&1 ``` Certificates are renewed well before they expire, so weekly is often enough. ### 15.2 Reload Tuwunel's config After editing `tuwunel.toml`, reload it without a restart: ```bash docker kill --signal=SIGUSR1 tuwunel ``` Sending `!admin server reload-config` in the admin room does the same. Options marked `reloadable: yes` in Tuwunel's example config take effect immediately; that covers registration and tokens, the TURN settings, `livekit_url`, and the backup settings. A reload that changes `server_name` or `ip_source` is rejected as a whole. Listener settings (`address`, `port`) and anything not marked reloadable, such as `allow_federation` and `[global.well_known] client`, need a restart (`docker restart tuwunel`). If the new config is invalid, Tuwunel keeps running the old one and logs `Failed to reload config`. ### 15.3 Updating In Portainer, open the stack, click **Editor**, then **Update the stack** with **Re-pull image and redeploy** enabled. Skim the [Tuwunel release notes](https://github.com/matrix-construct/tuwunel/releases) first. The first start after an update may run a database migration. While it runs, `docker logs -f tuwunel` prints a `Database migration in progress` line every 15 seconds, and the container reports `starting` instead of `healthy`. Do not stop or restart it during that time; the `stop_grace_period: 30m` setting exists for this reason. `latest` moves whenever you redeploy with a fresh pull. If you would rather decide when versions change, replace `latest` with a specific version tag listed on each image's registry page, and update the tag deliberately. ### 15.4 Backups Tuwunel's online backups copy the database while the server keeps running. They do **not** include uploaded media. A complete backup set is: * the database backups in `/data/compose/tuwunel/backups` * uploaded media in `/data/compose/tuwunel/data/media` * your config files: `tuwunel.toml`, plus `turnserver.conf` and `livekit.yaml` if you run calls **Back up by hand** from the admin room: ```text !admin server backup-database !admin server list-backups !admin server verify-backup ``` `verify-backup` checks the newest backup when you do not give an ID. **Schedule backups.** Because `admin_signal_execute` in section 6 runs a backup whenever Tuwunel receives `SIGUSR2`, cron can trigger one. Add to root's crontab (`sudo crontab -e`): ```cron # Daily database backup at 03:00 0 3 * * * docker kill --signal=SIGUSR2 tuwunel >/dev/null 2>&1 ``` The result is written to Tuwunel's log. `database_backups_to_keep` controls how many are kept. **Copy backups off the host.** A backup on the same disk does not survive that disk failing. Copy the three items above to another machine with whatever tool you already use (rsync, restic, borg). A minimal rsync example for root's crontab, run after the backup and assuming root has SSH key access to `backup-host`: ```cron 30 3 * * * rsync -a /data/compose/tuwunel/backups /data/compose/tuwunel/data/media /data/compose/tuwunel/tuwunel.toml backup-host:/srv/backups/tuwunel/ >/dev/null 2>&1 ``` ### 15.5 Restoring from a backup 1. Stop Tuwunel: ```bash docker stop tuwunel ``` 2. Keep a copy of the current state so you can undo the restore. This includes media, so check free space first: ```bash sudo cp -a /data/compose/tuwunel/data /data/compose/tuwunel/data.before-restore ``` 3. Run a one-time container with the same volumes and the `--restore-backup` flag. It restores the newest backup into the database folder, then shuts itself down: ```bash docker run --rm \ -v /data/compose/tuwunel/data:/var/lib/tuwunel \ -v /data/compose/tuwunel/backups:/var/lib/tuwunel-backups \ -v /data/compose/tuwunel/tuwunel.toml:/etc/tuwunel.toml:ro \ -e TUWUNEL_CONFIG=/etc/tuwunel.toml \ jevolk/tuwunel:latest --restore-backup --maintenance --execute "server shutdown" ``` Use `--restore-backup=3` instead to pick a specific ID from `list-backups`. 4. If media was lost too, copy it back into `/data/compose/tuwunel/data/media` from your off-host copy. 5. Start Tuwunel again with `docker start tuwunel` and repeat the checks in section 11. More detail, including manual recovery if the binary cannot run, is in Tuwunel's [backup documentation](https://github.com/matrix-construct/tuwunel/blob/main/docs/backups.md). --- ## 16. Podman version Two ways to run this stack with Podman: * **`podman compose`**: runs the compose file from section 9 almost unchanged. Quickest if you already know Compose. * **Quadlets (recommended)**: native systemd units. Podman has no daemon to restart containers after a reboot; systemd does it instead, which is what Quadlets set up. This section uses **rootful** Quadlets (files in `/etc/containers/systemd/`, managed with `sudo systemctl`). Rootful keeps parity with the Docker setup: host networking for coturn and LiveKit, fixed IPs on a shared network, and read access to NPMPlus's root-owned certificate files. Rootless Podman can run Tuwunel alone well (Tuwunel's repository includes a rootless Quadlet), but a rootless coturn cannot read root-owned private keys without extra permission work. Requires Podman 4.7 or later (`podman --version`). The files below pass a few options through `PodmanArgs=` so they also work on Podman 4.x; the comments name the native keys you can use on Podman 5.3 or later instead. ### 16.1 Files and folders Reuse sections 5.1, 6, 7, and 8 exactly: same folders, same `tuwunel.toml`, `turnserver.conf`, and `livekit.yaml`. > **SELinux (Fedora, RHEL, openSUSE Tumbleweed, and others):** The `:Z` suffix on volumes below relabels those files so the container can use them. For coturn, the certificate folder is shared with NPMPlus, so relabeling it could lock NPMPlus out; the coturn Quadlet disables SELinux separation for that container instead. The tradeoff is that SELinux does not confine coturn. On systems without SELinux, both settings are ignored. ### 16.2 Network `/etc/containers/systemd/proxy.network`: ```ini [Network] NetworkName=proxy Subnet=172.20.0.0/16 # Uncomment both lines for IPv6: #IPv6=true #Subnet=fd00:20::/64 ``` NPMPlus can reach the fixed IPs on this network if it runs on the host network or is attached to `proxy` under Podman. Mixing a Docker-managed NPMPlus with these Podman containers on one host is not covered here; in that case publish the ports instead (see the comments in each file) and point NPMPlus at `10.0.0.81:PORT`. ### 16.3 Tuwunel `/etc/containers/systemd/tuwunel.container`: ```ini [Unit] Description=Tuwunel Matrix homeserver Wants=network-online.target After=network-online.target [Container] ContainerName=tuwunel Image=docker.io/jevolk/tuwunel:latest Network=proxy.network:ip=172.20.0.9 # If NPMPlus cannot reach the proxy network, use this instead of the Network= line # (and remove ip_source from tuwunel.toml): #PublishPort=6167:6167 Volume=/data/compose/tuwunel/data:/var/lib/tuwunel:Z Volume=/data/compose/tuwunel/backups:/var/lib/tuwunel-backups:Z Volume=/data/compose/tuwunel/tuwunel.toml:/etc/tuwunel.toml:ro,Z Environment=TUWUNEL_CONFIG=/etc/tuwunel.toml # Allow up to 30 minutes for database migrations on shutdown. PodmanArgs=--stop-timeout=1800 # Podman ignores the health check embedded in the image, so declare it here. HealthCmd=["/usr/bin/tuwunel", "--health-check"] HealthInterval=30s HealthTimeout=15s HealthStartPeriod=1800s HealthRetries=3 [Service] Restart=on-failure TimeoutStartSec=900 TimeoutStopSec=1830 [Install] WantedBy=multi-user.target ``` | Line | Docker equivalent / purpose | | --- | --- | | `Network=proxy.network:ip=...` | `networks: proxy: ipv4_address:`. Add `,ip6=fd00:20::9` for IPv6. | | `Volume=...backups...` | The `backups` volume, for `database_backup_path`. | | `PodmanArgs=--stop-timeout=1800` + `TimeoutStopSec=1830` | `stop_grace_period: 30m`. Both are needed: one tells Podman to wait, the other stops systemd from killing it sooner. This matches Tuwunel's official Quadlet. | | `HealthCmd=...` | Docker reads the health check from the image; Podman does not, per Tuwunel's docs. Podman 5.8.2 fixed a bug where a `HealthCmd` containing double quotes, like this one, could produce a non-working health check ([podman#28409](https://github.com/containers/podman/issues/28409)). If `sudo podman healthcheck run tuwunel` fails while the server answers normally, update Podman or delete the `Health*` lines. | | `Restart=on-failure` | `restart: unless-stopped`. | | `TimeoutStartSec=900` | Allows time to pull the image on first start. | ### 16.4 coturn (Calls) `/etc/containers/systemd/coturn.container`: ```ini [Unit] Description=coturn TURN server Wants=network-online.target After=network-online.target [Container] ContainerName=coturn Image=docker.io/coturn/coturn:latest Network=host User=0 # Leave the shared certificate folder unlabeled and run coturn without SELinux # separation (on Podman 5.3+ you can write SecurityLabelDisable=true instead). PodmanArgs=--security-opt=label=disable Volume=/data/compose/tuwunel/turnserver.conf:/etc/coturn/turnserver.conf:ro Volume=/opt/npmplus/tls/certbot:/certs:ro # Use only turnserver.conf. Delete this line for a dynamic public IP (section 7). Exec=-c /etc/coturn/turnserver.conf [Service] Restart=on-failure TimeoutStartSec=900 [Install] WantedBy=multi-user.target ``` ### 16.5 LiveKit (Calls) `/etc/containers/systemd/livekit.container`: ```ini [Unit] Description=LiveKit SFU for MatrixRTC Wants=network-online.target After=network-online.target [Container] ContainerName=livekit Image=docker.io/livekit/livekit-server:latest Network=host Volume=/data/compose/tuwunel/livekit.yaml:/etc/livekit.yaml:ro,Z Exec=--config /etc/livekit.yaml [Service] Restart=on-failure TimeoutStartSec=900 [Install] WantedBy=multi-user.target ``` ### 16.6 lk-jwt-service (Calls) `/etc/containers/systemd/lk-jwt.container`: ```ini [Unit] Description=lk-jwt-service for MatrixRTC Wants=network-online.target After=network-online.target [Container] ContainerName=lk-jwt Image=ghcr.io/element-hq/lk-jwt-service:latest Network=proxy.network:ip=172.20.0.10 # If NPMPlus cannot reach the proxy network, use this instead of the Network= line: #PublishPort=8081:8081 Environment=LIVEKIT_JWT_BIND=:8081 Environment=LIVEKIT_URL=wss://rtc.chat.example.com Environment=LIVEKIT_KEY=LIVEKIT_KEY Environment=LIVEKIT_SECRET=LIVEKIT_SECRET Environment=LIVEKIT_FULL_ACCESS_HOMESERVERS=chat.example.com # Podman equivalent of extra_hosts (on Podman 5.3+ you can write # AddHost=chat.example.com:10.0.0.81 instead). PodmanArgs=--add-host=chat.example.com:10.0.0.81 [Service] Restart=on-failure TimeoutStartSec=900 [Install] WantedBy=multi-user.target ``` If you publish port 8081, change the `proxy_pass` line in section 10.2 to `http://10.0.0.81:8081`. The files above contain secrets. Restrict them: ```bash sudo chmod 600 /etc/containers/systemd/*.container ``` ### 16.7 Start everything ```bash # Generate systemd units from the Quadlet files sudo systemctl daemon-reload # Start (the [Install] section also starts them at every boot) sudo systemctl start tuwunel coturn livekit lk-jwt # Check status, health, and logs sudo systemctl status tuwunel sudo podman healthcheck run tuwunel && echo healthy sudo journalctl -u tuwunel -f ``` The `proxy` network is created automatically, because the containers that use it depend on the generated `proxy-network.service`. Quadlet units are generated, so `systemctl enable` does not apply to them; the `[Install]` section handles starting at boot. If a unit does not appear after `daemon-reload`, look for errors with: ```bash /usr/lib/systemd/system-generators/podman-system-generator --dryrun ``` An unknown key makes the generator skip the whole file, which is the usual cause on older Podman versions. ### 16.8 Command translations Every `docker` command in this guide has a direct Podman equivalent: | Docker | Podman | | --- | --- | | `docker network create --subnet 172.20.0.0/16 proxy` | Handled by `proxy.network` (16.2) | | `docker logs -f tuwunel` | `sudo podman logs -f tuwunel` or `sudo journalctl -u tuwunel -f` | | `docker ps` health status | `sudo podman healthcheck run tuwunel` | | `docker kill --signal=SIGUSR1 tuwunel` | `sudo podman kill --signal=SIGUSR1 tuwunel` | | `docker kill --signal=SIGUSR2 tuwunel` (backup cron) | `podman kill --signal=SIGUSR2 tuwunel` (in root's crontab) | | `docker kill --signal=SIGUSR2 coturn` (certificate cron) | `podman kill --signal=SIGUSR2 coturn` (in root's crontab) | | `docker stop tuwunel` / `docker start tuwunel` | `sudo systemctl stop tuwunel` / `sudo systemctl start tuwunel` | | `docker restart tuwunel` | `sudo systemctl restart tuwunel` | | `docker run --rm ... --restore-backup` (15.5) | `sudo podman run --rm ...` with the same flags, adding `:Z` to each volume | | Portainer "Update the stack" | `sudo podman pull ` then `sudo systemctl restart ` |