Skip to main content
Cybersecurity & Hardening

Escaping Discord: How to Launch a Secure Self-Hosted Stoat Server

Discord's age-verification stack leaked 70,000 IDs. Here is how to migrate your community to a properly hardened, self-hosted Stoat server on Docker.

Published 11 min read

The trust break with Discord did not happen in a single day. Two specific incidents lined up to ruin it.

In October 2025, the third-party customer-support vendor 5CA, which Discord had been routing age-verification appeals through, got popped by an attacker group calling itself “Scattered Lapsus$ Hunters.” 1.6 terabytes left the building. Roughly 70,000 of those records were government ID photos: real driver’s licenses, real passports, real full names, all uploaded by users who wanted to appeal a ban or unlock a region-restricted feature.

Then in February 2026, researchers pulled apart Persona, the age-verification vendor Discord had been routing UK users to. Persona’s entire government-dashboard codebase, 53 MB and 2,456 files, was sitting unauthenticated on a FedRAMP-authorized US government endpoint. Inside that codebase: 269 distinct verification checks. Facial recognition against watchlists. Adverse media in 14 categories. Politically-exposed-person monitoring. Crypto-activity sweeps. Suspicious Activity Report filings into FinCEN (US Treasury) and FINTRAC (Canada). All advertised to users as a simple “are you old enough” age gate.

Discord delayed the global rollout to H2 2026 and dropped Persona. The structural direction, mandatory ID upload through an opaque third-party identity-checking pipeline, has not changed.

The question for community runners then became simple: where do we go.

Searches for self-hosted Discord alternatives spiked sharply after the February disclosure, and one project absorbed most of the new traffic: Stoat, the rebranded continuation of Revolt. Same codebase, same maintainers, same Discord-shaped UX. If you are ready to make the jump, the rest of this post covers what a hardened self-hosted Stoat deployment actually looks like, what hardware to put it on, and the migration playbook that gets your members to follow you instead of scattering.

Why the self-hosting trap matters before you touch the install

Here is where a lot of community runners hand the same problem back to themselves in a different shape.

Most “deploy Stoat with Docker” guides on YouTube and Medium are written for the demo bar, not the production bar. They run every container as root inside the container. They bind MongoDB and Redis straight to 0.0.0.0. They paste the default Revolt.toml and never generate fresh message-secret keys. They commit the resulting .env file with secrets in plaintext into a git repo. They turn off auth on the admin console “to make the first deploy easier.” Then they tell you it works.

It does work, right up until a Shodan crawler finds your exposed MongoDB on port 27017, the unauthenticated Redis on 6379, and an attacker has root inside three of your containers in under an hour.

Leaving Discord because they could not protect 70,000 IDs, and then standing up a server that hemorrhages your community’s data to an automated botnet scan, is the worst possible outcome of this migration. The whole point of self-hosting is that the buck stops with you. Which means the hardening has to be in place from day one, not “added later when we have time.”

What a hardened self-hosted Stoat deployment looks like

The Docker Compose I’ll be referring to throughout the rest of this post lives at github.com/wnstify/docker/tree/main/stoat. It is the same stack I deploy for managed clients, with secret generation, network segmentation, and per-service resource limits already wired up.

The shape:

  • 15 long-running services plus 2 one-shot init containers (one bootstraps Garage S3, the other primes MongoDB cleanup collections after the API migration finishes).
  • 5 Docker networks, four of them flagged internal: true, meaning no outbound internet egress at all.
  • One bridge network (stoat-frontend) reaches the public, and only Caddy and LiveKit are attached to it.
  • No port published to 0.0.0.0 except the LiveKit UDP voice/video range (50000–50100/udp) and a TCP fallback (7881/tcp), both inherent to how WebRTC works. The HTTP target is bound to 127.0.0.1:8880 and only your upstream reverse proxy reaches it.

The network access matrix:

stoat-frontend (bridge)            Caddy + LiveKit only
stoat-app      (internal: true)    Caddy <-> application services
stoat-data     (internal: true)    MongoDB, Redis, Garage <-> backend services
stoat-rabbit   (internal: true)    RabbitMQ <-> message consumers
stoat-voice    (internal: true)    LiveKit <-> Redis

internal: true networks have zero outbound internet. If a container on stoat-data is compromised, it cannot exfiltrate to a command-and-control server. It cannot reach Pastebin or Telegram. It cannot apk add curl. The blast radius collapses to the services on the same internal network, which is what segmentation is for. This is the same per-tier compartmentalization pattern I unpack in my defense in depth web application architecture walkthrough, applied to a chat platform.

In front of the box, an upstream reverse proxy (Pangolin, Caddy on the host, nginx, or whatever you prefer) terminates TLS and proxies to 127.0.0.1:8880. The Caddy inside the deployment handles only path-based routing between Stoat’s many sub-services.

What actually makes the Stoat deployment hard to break

The full control matrix lives in the repo’s SECURITY.md. The high-impact controls:

  • cap_drop: ALL on every container. Docker grants 14 Linux capabilities by default. The deployment drops all of them globally and adds back exactly one (NET_BIND_SERVICE for Caddy, so it can bind port 80). Everything else operates with zero capabilities. Most CMS-style exploit chains assume the default capability set; remove it and large chunks of the chain fall apart.
  • no-new-privileges: true on every container. Setuid binaries inside the container cannot escalate. Whatever gets exploited, the process inside the container cannot promote itself further.
  • ipc: private on every container. Explicit IPC namespace isolation, with no shared memory leaking sideways between containers on the same host. Modern Docker defaults to private, but declaring it explicitly defends against any host configured with the legacy --default-ipc-mode=shareable.
  • read_only: true on 11 of the 17 services. Containers that do not need a writable root filesystem do not have one. An attacker who lands code execution inside one of those containers cannot drop a webshell to /usr/lib, cannot replace a binary in /usr/local/bin, cannot persist across a restart. Everything has to fit into the explicit tmpfs mounts, which are wiped on every restart.
  • Non-root execution on most services. Stoat’s Rust services run as UID 65532 (nonroot) baked into the upstream images. Infrastructure (MongoDB, Redis, RabbitMQ, Garage, LiveKit) runs as PUID:PGID auto-detected from the host user who ran generate-config.sh. Three services are forced to root by upstream image constraints (web, caddy, garage-init); each is locked down with cap_drop: ALL to minimize what root inside the container actually means.
  • Per-service resource limits. Memory, CPU, and PID limits on every container. The largest cap is MongoDB at 2 GB / 1.5 vCPU / 200 PIDs. A buggy Stoat service or a DoS attempt against the API cannot exhaust the host. The container hits its limit first.
  • Authenticated everything. MongoDB has root credentials. Redis has a password (--requirepass). RabbitMQ has a default user. Garage S3 has access-key and secret-key. Garage admin has a bearer token. None of those credentials are typed by hand. generate-config.sh runs openssl rand for each one and writes the results to .env and secrets.env with chmod 600. Even root on the host cannot read them without intent.
  • No secrets in Revolt.toml. The Stoat config file holds only non-sensitive values: hostnames, feature flags, limits. Credentials live in secrets.env and override Revolt.toml at runtime via the REVOLT__ environment prefix. The config file is safe to commit to a repo; the env files are not.

If you are wiring host-level fundamentals at the same time (SSH lockdown, firewall, kernel sysctls, fail2ban tuning), my Linux server security fundamentals guide covers what to layer underneath this Compose stack.

Hardware sizing for self-hosted Stoat

Voice and video drive the spec. Text chat is essentially free on any modern VPS; LiveKit is what eats CPU when ten people are in a voice channel at once.

Use caseSpecHetzner Cloud equivalentMonthly
Demo / 1-user testing2 vCPU, 4 GB RAM, 40 GB NVMeCX23€3.99
Small community (<50 active)4 vCPU, 8 GB RAM, 80 GB NVMeCX33€6.49
Active 50–200 users, regular voice8 vCPU, 16 GB RAM, 160+ GB NVMeCX43 or CPX42€11.99–€25.49
Persistent voice load, no noisy neighbors4–8 dedicated vCPU, 16–32 GB RAMCCX23 / CCX33€31.49–€62.49

Prices are Germany/Finland Hetzner, May 2026, ex-VAT, with 20 TB egress included. The vCPU/RAM column applies to any provider; only the Hetzner SKU column is provider-specific.

Two specifics to plan for:

  • Disk grows with uploads. Garage stores every file your members send. CPX tiers ship double the disk vs equivalent CX, or attach a Hetzner Volume (around €0.044/GB/month NVMe) for elastic storage.
  • Skip Hetzner Arm (CAX) unless you have verified. The Stoat Rust services may be published amd64-only at any given release. Run docker manifest inspect ghcr.io/stoatchat/api:v0.12.1 (or your target version) before you commit to an Arm host.

Stoat vs Matrix: pick by community shape, not ideology

Both projects are real open source. They serve different community needs.

Choose Stoat if your community currently lives on Discord and you want the lowest possible migration friction. The server-categories-channels-roles layout is almost identical. Members claim their preferred username, drag in a few custom emojis, and they are at home in an afternoon. Voice and video work out of the box via the bundled LiveKit.

Choose Matrix if you need cross-server federation (multiple independent homeservers talking to each other), end-to-end encryption as a compliance requirement, or bridges into Slack, Teams, IRC, or other chat networks. Element on top of Synapse is the standard path. Set-up complexity is higher; the federation and E2EE story is much stronger.

Stoat is not end-to-end encrypted for messages today. The upstream project has stated E2EE is on the roadmap, but it has not shipped. If your community handles content where you (as the server operator) being able to read messages is a compliance problem, Matrix is the right call regardless of friction cost.

The migration playbook: 4 to 8 weeks of overlap, not a hard cutover

The deploy is the 10 percent. The remaining 90 percent is convincing a community of humans who have a daily dopamine loop with Discord to break that loop and rebuild it somewhere else.

Forcing a hard cutover (“we delete the Discord server on Friday”) shatters communities. I have watched two of them dissolve completely doing it that way. The pattern that works:

  1. Run a concurrent soft launch. Keep both platforms alive for at least four weeks, ideally six to eight. Members claim preferred usernames on Stoat early. They explore the UI without commitment. The new server feels like an experiment, not an eviction.
  2. Recruit your top 5 percent first. Your most active members are the social anchor for the rest of the community. Invite them privately before the wider open, let them kick the tires, upload custom emojis, test roles. When the wider invite lands, those members are already familiar enough to act as guides.
  3. Be transparent about the gaps. Stoat is not Discord. Some Discord-specific bot integrations do not exist yet. A handful of niche features (specific stage-channel formats, certain music-bot behaviors) are still missing. Acknowledge it openly. Trying to oversell parity is what makes early adopters bounce.
  4. Anchor the move in a real reason, not in ideology. “We are moving for software freedom” lands flat with most users. “Discord leaked 70,000 government IDs through a third-party support vendor and a separate downstream vendor was quietly running facial-recognition checks against watchlists — we built a private home where your data stays with us” lands. Real facts drive real action.

Over those weeks, conversation gravity shifts. The most engaged users start preferring the new home. The old Discord server quiets down on its own. By week six or eight, you can archive Discord (or leave it as a read-only history) and the community has already picked up its center elsewhere.

Closing the loop: a Stoat deployment you can actually trust

The video at the top of this post walks through the deploy live. The hardened repository at github.com/wnstify/docker/tree/main/stoat is yours to use under the same AGPL-3.0 license the underlying Stoat project ships under.

If you would rather not run the box yourself (monitoring it, patching it, watching the logs, restoring from a Garage S3 snapshot at 2 AM when a member uploads a 4 GB file and fills the disk), that is exactly what my Cloud Infrastructure Audit & Hardening engagement is for. Read-only access, two-week turnaround, a Blueprint Report you can read on a phone with critical fixes separated from nice-to-haves.

Either way, your community gets a home where you decide what happens to its data. After the year Discord just had, that is the only acceptable starting point.

Watch on YouTube

Video walkthrough

Prefer the screen-recording version of this guide? Watch it on YouTube. The card opens in a new tab so the player only loads when you ask for it.

Frequently Asked Questions

Want this handled, not just understood?

Reading the playbook is one thing. Running it on production at 2am is another. If you'd rather have me run it for you, the door is open.

Apply for Access