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.
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.
docker compose version should work.8080 for the app, configurable via APP_PORT.
7880 and 7881 TCP for LiveKit signaling. 50000-60000 UDP for LiveKit
media. The LiveKit ports are not configurable. LiveKit runs with Docker host networking rather than
published ports, because mapping 10,000+ individual UDP ports through Docker's iptables-based port mapping
is slow enough to hang docker compose up. Add 80 and 443 too if you
set up HTTPS, per below. You should be.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.
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.
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
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
docker compose pull
docker compose up -d
Database migrations run automatically on container start. No separate migration step.
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.
.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.
Fully opt-in, per user, from User Settings → Security. Nothing to configure on the server side. Two independent methods, either or both:
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.
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.
outpost:// handler. That is the RFC 8252 recommendation, and it means you are
probably already signed in to your provider when the app asks.
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.
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.