← Back to Wiki
Self-Hosting / Chat & Voice

Self-Host Outpost: A Real Discord Alternative

Outpost is a self-hosted, single-community Discord alternative. Text channels with roles and permissions. Real voice and video on LiveKit's WebRTC stack, not a toy, with camera and screen share. File uploads and custom emoji. Friends and direct messages, optionally end-to-end encrypted. Reporting, blocking and a moderator queue. Two-factor login, and single sign-on against your own OIDC provider. Invite-based joins. Desktop, Android and iOS clients. All on your own infrastructure, MIT licensed. This is the one-click Docker deployment, plus the one gotcha that silently breaks voice chat if you skip it.

Share on X

What you're standing up

Four containers. The Outpost app itself, API and web client in one image. Postgres. LiveKit for voice and video. MinIO for file storage, meaning avatars and attachments. The install script generates every secret and wires them together. Nothing to hand-configure for a basic instance.

Requirements

Quick install

git clone https://github.com/blindrun/outpost-chat.git
cd outpost-chat/deploy
./install.sh

It asks for your server's public hostname or IP, a GitHub owner to pull the pre-built image from ghcr.io, and whether to set up HTTPS. Say yes unless this is purely LAN testing. It generates every secret and brings everything up with docker compose up -d.

A fresh instance prints a one-time claim code to its own log at startup. install.sh waits for it and prints it directly, or grab it yourself with docker compose logs app. The client shows a "Claim This Server" prompt until someone enters that code, and that is what creates the owner account. Nobody can register before that, so there is no race to become the first real user on a freshly exposed instance.

TLS is not optional. Voice requires it

BE WARNED: browsers only allow microphone access, getUserMedia, on a secure HTTPS page. That has a non-obvious knock-on effect. Uploaded files and voice signaling both have to be reachable from the same HTTPS origin as the app. Point them at a bare http://host:8080 or ws://host:7880 and it breaks silently in two different ways. Browsers auto-upgrade insecure <img> requests to HTTPS and fail with nothing listening there, giving you broken avatar and icon images with no visible error. And they refuse to attempt an insecure WebSocket from a secure page at all, so voice never connects. That one shows as DOMException: The operation is insecure in the console, easy to mistake for a firewall or port problem.

If you said yes to the HTTPS prompt, install.sh already generated a ready-to-use Caddyfile in the deploy/ directory. It routes /rtc/* to LiveKit's signaling and everything else to the app, and it points LIVEKIT_URL and MINIO_PUBLIC_URL in .env at the matching wss:// and https:// addresses. To put it into effect:

# Install Caddy first if you don't have it: https://caddyserver.com/docs/install
sudo cp Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy

Make sure your domain's DNS already points at this server before you reload. Caddy fetches a real Let's Encrypt certificate automatically on first request, with no manual cert setup. A different reverse proxy works fine too. nginx, Traefik, whatever. Replicate the same two routes under one HTTPS site. /rtc/* to LiveKit, everything else to the app. MinIO needs no route of its own. The app serves uploads itself.

Testing on a LAN with no domain? Skip this. Voice will not work until you add TLS later. To add it to an existing install, delete .env and re-run install.sh.

Manual install, if you'd rather not run the script

cp .env.example .env
# edit .env: fill in JWT_SECRET, POSTGRES_PASSWORD, MINIO_ROOT_PASSWORD,
# LIVEKIT_API_KEY, LIVEKIT_API_SECRET (random strings -- `openssl rand -hex 32`
# works well), and set LIVEKIT_URL / MINIO_PUBLIC_URL to your real public
# host (see the TLS section above for which scheme/URL shape to use).

sed -e "s|__LIVEKIT_API_KEY__|<the key you just picked>|" \
    -e "s|__LIVEKIT_API_SECRET__|<the secret you just picked>|" \
    livekit.yaml.template > livekit.yaml

docker compose up -d

Building the image yourself instead of pulling it

Useful if you do not want a dependency on the pre-built ghcr.io image, or you are testing an unreleased change:

docker build -t outpost-chat:local -f ../Dockerfile ..
APP_IMAGE=outpost-chat:local docker compose up -d

Updating

docker compose pull
docker compose up -d

Database migrations run automatically on container start. No separate migration step.

Optional: bot protection on registration (Cloudflare Turnstile)

If you open registration to the public rather than keeping it invite-only, put a real CAPTCHA in front of the sign-up form. Cloudflare Turnstile is free and usually invisible to real visitors. No puzzle-solving for most people, since Cloudflare's own risk signals decide who needs an extra challenge. It is fully opt-in. Leave both variables unset and registration works exactly as before, with no widget shown.

  1. Cloudflare dashboard → TurnstileAdd widget manually. Give it any name, add your instance's hostname, leave the mode on Managed, the recommended default.
  2. Copy the Site Key and Secret Key it generates into .env:
TURNSTILE_SITE_KEY=your-site-key
TURNSTILE_SECRET_KEY=your-secret-key
docker compose up -d

That is it. No migration, no restarting the whole stack. The site key is meant to be public, since it is embedded in every page load that shows the widget. The secret key never leaves your server. Verification happens server-side against Cloudflare's real API before an account is created, not trusted from the browser.

The widget only shows on the Register screen. Not Log In, and not the first owner-bootstrap account either. That one is already protected a different way. It needs the claim code your server printed to its own console at first startup, which a bot has no path to ever seeing.

Two-factor login (authenticator app + security keys)

Fully opt-in, per user, from User Settings → Security. Nothing to configure on the server side. Two independent methods, either or both:

BE WARNED: security keys need the same real HTTPS setup as voice, per the TLS section above. WebAuthn is a browser API available only on a secure origin, the same restriction as getUserMedia. The authenticator-app method has no such requirement and works fine on a plain-HTTP LAN deployment. Security keys just will not be offered as an option until the instance is served over real HTTPS.

Once either method is enabled on an account, logging in becomes a two-step flow automatically. Password first, then the code or key. There is no separate toggle. It is driven entirely by whether that account has a method configured.

Optional: single sign-on against your own identity provider

Anything speaking standard OpenID Connect works. Authentik, Authelia, Keycloak, Google. Unlike 2FA, this is deployment config rather than a per-user setting, because a client secret belongs to the deployment and not to a settings row. Set it in .env:

OIDC_ISSUER=https://auth.example.com/application/o/outpost/
OIDC_CLIENT_ID=<from your provider>
OIDC_CLIENT_SECRET=<from your provider>
OIDC_REDIRECT_URI=https://chat.example.com/auth/oidc/callback
OIDC_DISPLAY_NAME=Company SSO     # button label, defaults to "SSO"
OIDC_SCOPES=openid profile email  # this is the default
OIDC_ALLOW_SIGNUP=false           # set false to restrict to existing accounts

Leave OIDC_ISSUER unset and the whole feature stays off, with no button shown. Existing accounts link by email, and only when your provider marks that email verified. After the first sign-in the identity is the issuer and subject pair, not the address, so a later email change does not hand someone else's account away. The very first account still cannot be created this way. That one goes through the claim code.

The desktop app signs in through your real browser, not a window inside the app, and comes back via an outpost:// handler. That is the RFC 8252 recommendation, and it means you are probably already signed in to your provider when the app asks.

Backups

Everything that matters lives in two named Docker volumes. outpost-pgdata holds Postgres, so accounts, messages, channels and roles. outpost-minio holds uploaded avatars and attachments. Back up both, the same as any other stateful container. If you are not already covering named volumes in your backup routine, see Get Your Docker App Configs Actually Backed Up.

How uploads are served, and the limitation that remains. The MinIO bucket is private. Its own port never has to be reachable from outside the host. Avatars, attachments and custom emoji are served by the app itself, at GET /outpost-uploads/*, and every request has to carry a live session token for an account that is not banned. The token rides in a query parameter rather than an Authorization header, because an <img> or <video> tag cannot send a header. That is the same tradeoff the gateway WebSocket already makes for the same reason, and it means upload URLs can leak through browser history, referrers and proxy logs the way any tokenized URL can. There is still no per-file access control either. Any logged-in member who has an object key can fetch that object. Fine for avatars and casual attachments. Do not rely on it for anything sensitive.