bb79b73fdd192081c9f77875bb67f9d095e374bf
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
├── sshd_hardening.conf.j2 # sshd drop-in
└── 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 password login is intentionally enabled (
sshd_password_authentication: yes).- Root login stays disabled — use your SSH user +
sudo. - Fail2Ban is the brute-force defense:
3failed attempts →1 hban (tunable). - If you ever want key-only auth again, flip the var to
no. - Ansible connects with a password via
-k:
ansible-playbook -i inventory.ini site.yml -k - Root login stays disabled — use your SSH user +
What the playbook does
| Stage | Actions |
|---|---|
| System | dnf update all packages; install git, curl, openssl |
| Firewall | firewalld default-deny; allow ssh, http, https, 7881/tcp, 50000-50100/udp (LiveKit voice) |
| SSH | drop-in 00-hardening.conf: PermitRootLogin no, PasswordAuthentication yes; config validated with sshd -t before restart |
| 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 |
| 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 |
fail2ban_port / fail2ban_maxretry / fail2ban_bantime |
22 / 3 / 3600 |
sshd jail tuning |
sshd_permit_root_login |
no |
root SSH access |
sshd_password_authentication |
yes |
password login (fail2ban guards it) |
sshd_mfa_required |
false |
only enable if a 2FA PAM module exists, else you lock yourself out |
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
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, non-root SSH user, fail2ban, firewalld default-deny, security-only auto-updates. (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.