8.0 KiB
Stoat — Fedora 44 self-hosted chat deployment
Ansible automation that hardens a Fedora 44 server, installs 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 # podman, compose, SELinux container policy
│ └── 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 | engine + podman-compose + podman-docker; runs as root (no unprivileged service user, no linger) |
| SELinux | keeps mode enforcing; installs container-selinux (container policy) + policycoreutils-python-utils / setools-console (semanage, audit2why); registers the deployment tree as container_file_t so rootful containers can use the bind mounts |
| 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) |
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 as root:
# Stack status
systemctl status podman-compose-stoat
podman ps
# Logs (per service: api / events / web / livekit / database …)
podman logs -f api
# Restart the whole stack
systemctl restart podman-compose-stoat
# Update Stoat to new images (run in /opt/stoat/self-hosted)
podman-compose pull
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. Containers run as root under the container_t domain, so the role registers /opt/stoat/self-hosted as container_file_t (via semanage fcontext + restorecon) — that's what lets the rootful containers read/write the bind-mounted data/ volumes and config files without per-volume :Z flags, and the registration is persistent (survives restorecon).
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
# re-apply file contexts if you add bind mounts outside the
# deployment tree (the role only labels /opt/stoat/self-hosted):
sudo semanage fcontext -a -t container_file_t '/opt/stoat/moredata(/.*)?'
sudo restorecon -RF /opt/stoat/moredata
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-composeas root. 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--overwrite(after backing up secrets).- Security posture: fail2ban, firewalld default-deny, security-only auto-updates, SELinux enforcing with
container_file_tlabels. SSH configuration is left exactly as you set it. (Aligned with the upstream guide's recommendations.) - Data persistence: everything persists in
/opt/stoat/self-hosted/data/(mongodb, rabbitmq, minio, caddy) — keep it out of the git clone/reset. - System hardening + Stoat are one role run; to re-apply only config changes, re-running
site.ymlis idempotent.