Stoat — Fedora 44 self-hosted chat deployment
Ansible automation that hardens a Fedora 44 server, installs rootless Podman, and deploys a self-hosted Stoat instance (open-source community chat, formerly Revolt) using the official stoatchat/self-hosted stack.
Target host: stoat-demo
Project layout
├── site.yml # main playbook — applies the role
├── inventory.ini # server inventory (stoat-demo)
├── group_vars/all/vars.yml # ★ all deployment variables live here
└── roles/stoat/
├── tasks/
│ ├── main.yml # imports, in order
│ ├── system.yml # dnf full update + base packages
│ ├── harden.yml # firewalld, sshd, fail2ban, dnf5-automatic, logrotate
│ ├── podman.yml # rootless podman, compose, service user, linger
│ └── stoat.yml # clone repo → generate config → systemd unit → start
├── handlers/main.yml
└── templates/
├── jail.local.j2 # fail2ban config
└── podman-compose-stoat.service.j2 # systemd unit for the stack
Before you run
-
Set the real domain in
group_vars/all/vars.yml:stoat_domain: chat.yourdomain.com # ← A/AAAA record must point at the serverLet's Encrypt certificates are issued for this name, and every URL Stoat advertises (web client, API, WebSocket, LiveKit) uses it. Do not use the public
stoat.chatdomain for your own instance. -
Set the SSH user in
inventory.ini:stoat-demo ansible_host=stoat-demo ansible_user=YOUR_SSH_USER -
SSH is not touched by this playbook — the server keeps its SSH configuration exactly as you have it. Fail2Ban still protects the login (
3failed attempts →1 hban, tunable). Ansible connects with your SSH password via-k:ansible-playbook -i inventory.ini site.yml
What the playbook does
| Stage | Actions |
|---|---|
| System | dnf update all packages; install git, curl, openssl |
| Firewall | installs + starts firewalld (native/minimal Fedora may not ship it); default-deny; allow ssh, http, https, 7881/tcp, 50000-50100/udp (LiveKit voice) |
| Fail2Ban | sshd jail (3 retries → 1 h ban, incremental for repeat offenders) |
| Auto-updates | dnf5-plugin-automatic, security-only, apply_updates = yes, started timer |
| Logs | logrotate rule for fail2ban logs (daily, 7 rotations, compressed) |
| Podman | rootless engine + podman-compose + podman-docker; net.ipv4.ip_unprivileged_port_start=80 so Caddy can bind 80/443; dedicated stoat service user with lingering enabled |
| SELinux | keeps mode enforcing; installs container-selinux (container policy) + policycoreutils-python-utils / setools-console (semanage, audit2why) |
| Stoat | clone stoatchat/self-hosted to /opt/stoat/self-hosted; run generate_config.sh <domain> (answers: built-in Caddy, video enabled); start stack under podman-compose-stoat.service |
Network / ports
| Port | Purpose |
|---|---|
| 22/tcp | SSH |
| 80/tcp, 443/tcp | Caddy reverse proxy (automatic Let's Encrypt TLS) |
| 7881/tcp | LiveKit WebRTC signaling |
| 50000–50100/udp | LiveKit voice/video media (cannot be proxied) |
Variables reference (group_vars/all/vars.yml)
| Variable | Default | Meaning |
|---|---|---|
stoat_domain |
chat.example.com |
Required — public domain for this instance |
stoat_install_dir |
/opt/stoat/self-hosted |
where the stack lives on the server |
stoat_version |
main |
git ref of self-hosted repo |
stoat_reverse_proxy |
false |
true = move Caddy to :8880, bring your own front proxy/TLS |
stoat_video |
true |
enable camera/screen sharing (LiveKit) |
stoat_service_user |
stoat |
unprivileged user running rootless podman |
podman_unprivileged_port_start |
80 |
lowest port rootless containers may bind |
selinux_mode |
enforcing |
SELinux mode the role enforces (config + setenforce) |
fail2ban_port / fail2ban_maxretry / fail2ban_bantime |
22 / 3 / 3600 |
sshd jail tuning |
firewall_services / firewall_ports |
ssh,http,https / 7881, UDP range | firewalld allow rules |
Override any variable per-run, without editing files:
ansible-playbook -i inventory.ini site.yml -k -e "stoat_domain=chat.example.org fail2ban_maxretry=5"
After deployment
- First account = instance owner. Open
https://<your-domain>and register. - Make it invite-only (recommended for a private instance): in
/opt/stoat/self-hosted/Revolt.tomlsetinvite_only = true, then restart the API service. - Back up
secrets.env— it holds the VAPID push keys, file-encryption key, and LiveKit credentials. Losing it means losing access to all uploaded files and breaking existing sessions. Copy it somewhere safe:scp stoat-demo:/opt/stoat/self-hosted/secrets.env ~/backups/ - Caddy terminates TLS itself — do not put Cloudflare proxy (orange cloud) in front of the domain; ACME needs direct access to the server (grey cloud / direct DNS only).
Day-to-day operations
All commands run on the server, mostly as the stoat user (sudo -u stoat when needed):
# Stack status
systemctl status podman-compose-stoat
podman ps
# Logs (per service: api / events / web / livekit / database …)
sudo -u stoat podman logs -f api
# Restart the whole stack
sudo systemctl restart podman-compose-stoat
# Update Stoat to new images (run in /opt/stoat/self-hosted as stoat)
sudo -u stoat podman-compose pull
sudo -u stoat podman-compose up -d
# Fail2Ban status
sudo fail2ban-client status sshd
# Open firewall UI changes
sudo firewall-cmd --list-all
SELinux notes
Fedora 44 ships SELinux enforcing; the role pins that mode. The stack runs in rootless podman, where containers run in the user's own SELinux domain and can read/write the stoat user's files — bind mounts under /opt/stoat/self-hosted/data generally need no relabeling or :Z flags.
If a service misbehaves, check for denials before anything else:
sudo ausearch -m avc -ts recent | grep -i stoat # recent denials
# or find the exact rule suggestion:
sudo grep "denied" /var/log/audit/audit.log | tail -20 | audit2why
Common knobs if a denial shows up (only set what the denial actually requires):
# containers connecting to non-default ports on the host
sudo setsebool -P container_connect_any 1
# container needs to manage cgroups (systemd inside a container)
sudo setsebool -P container_manage_cgroup 1
# give a specific bind-mounted volume its container context (last resort,
# only if audit2why points at file labeling):
sudo chcon -R -t container_file_t /opt/stoat/self-hosted/data
Caveats & notes
- First start pulls ~2 GB of images and initializes MongoDB — allow a few minutes; the systemd unit tolerates up to 15 min (
TimeoutStartSec=900). - Podman, not Docker: the upstream stack is docker-compose based; this deployment runs it with
podman-compose(rootless). It supports the compose features this stack needs (depends_on: condition: service_healthy, bind mounts, port ranges). generate_config.shis guarded bycreates:— it never overwrites existingsecrets.env/Revolt.tomlunintentionally. To regenerate config deliberately, run it manually with--overwriteas thestoatuser (after backing up secrets).- Security posture: rootless containers, fail2ban, firewalld default-deny, security-only auto-updates. SSH configuration is left exactly as you set it. (Aligned with the upstream guide's recommendations.)
- Rootless bind mounts: data persists in
/opt/stoat/self-hosted/data/(mongodb, rabbitmq, minio, caddy). - System hardening + Stoat are one role run; to re-apply only config changes, re-running
site.ymlis idempotent.