Files
stoat/README.md
2026-09-13 13:27:02 -06:00

6.7 KiB
Raw Blame History

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

  1. Set the real domain in group_vars/all/vars.yml:

    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:

    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:

    ansible-playbook -i inventory.ini site.yml -k
    

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)
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
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

  1. First account = instance owner. Open https://<your-domain> 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:
    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):

# 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.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.