# Stoat — Fedora 44 self-hosted chat deployment Ansible automation that **hardens a Fedora 44 server**, installs **rootless Podman**, and deploys a self-hosted **[Stoat](https://stoat.chat)** instance (open-source community chat, formerly Revolt) using the official [`stoatchat/self-hosted`](https://github.com/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 1. **Set the real domain** in `group_vars/all/vars.yml`: ```yaml stoat_domain: chat.yourdomain.com # ← A/AAAA record must point at the server ``` Let'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.chat` domain for your own instance. 2. **Set the SSH user** in `inventory.ini`: ```ini stoat-demo ansible_host=stoat-demo ansible_user=YOUR_SSH_USER ``` 3. **SSH is not touched by this playbook** — the server keeps its SSH configuration exactly as you have it. Fail2Ban still protects the login (`3` failed attempts → `1 h` ban, tunable). Ansible connects with your SSH password via `-k`: ```bash 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 ` (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: ```bash ansible-playbook -i inventory.ini site.yml -k -e "stoat_domain=chat.example.org fail2ban_maxretry=5" ``` --- ## After deployment 1. **First account = instance owner.** Open `https://` and register. 2. **Make it invite-only** (recommended for a private instance): in `/opt/stoat/self-hosted/Revolt.toml` set `invite_only = true`, then restart the API service. 3. **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: ```bash scp stoat-demo:/opt/stoat/self-hosted/secrets.env ~/backups/ ``` 4. **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): ```bash # 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: ```bash 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): ```bash # 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.sh` is guarded** by `creates:` — it never overwrites existing `secrets.env`/`Revolt.toml` unintentionally. To regenerate config deliberately, run it manually with `--overwrite` as the `stoat` user (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.yml` is idempotent.