148 lines
6.7 KiB
Markdown
148 lines
6.7 KiB
Markdown
# 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 -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:
|
||
|
||
```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://<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:
|
||
```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
|
||
```
|
||
|
||
---
|
||
|
||
## 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. |