With Docker, settings go in a .env file next to docker-compose.yml. Start from the template:
cp .env.example .env
Apply changes with docker compose up -d. Nothing is required: every setting has a working default.
Where a setting can come from, later sources override earlier ones:
state/secrets.env: generated on first start. HoldsBOT_TOKENandBOOTSTRAP_CODE..dev.vars: only if you mount one into the container (older setups did this)..env/ container environment variables.
Running without Docker (npm run serve)? Put the same settings in .dev.vars instead. See .dev.vars.example.
Docker
| Variable | Default | What it does |
|---|---|---|
HOFFLE_PORT |
8730 |
Port on your machine that Hoffle is published on. |
Security
| Variable | Default | What it does |
|---|---|---|
BOOTSTRAP_CODE |
generated | Code required to create the first (owner) account. After that account exists, it no longer does anything. |
BOT_TOKEN |
generated | Global secret for system bots, the music publisher and internal services. Treat it like a password. |
HUDDLE_CSP |
unset | 1 sends the bundled Content-Security-Policy. See Content-Security-Policy. |
HUDDLE_CSP_POLICY |
unset | Send this policy verbatim instead of the bundled one. Overrides HUDDLE_CSP. |
Response headers
Every response carries these, with no configuration:
| Header | Value | Why |
|---|---|---|
X-Content-Type-Options |
nosniff |
Stops a mislabelled upload from being executed as HTML. |
X-Frame-Options |
SAMEORIGIN |
Stops Hoffle being framed by another site. |
Referrer-Policy |
strict-origin-when-cross-origin |
Keeps invite tokens out of the Referer sent to third-party hosts via link previews. |
Permissions-Policy |
camera/microphone/display-capture for self only; geolocation, payment, USB denied |
Voice and screen share need those three; nothing here needs the rest. |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Only sent when the request arrived over HTTPS. |
Content-Security-Policy
Left off by default. A policy that does not match your setup breaks voice, uploads or themes silently — the browser blocks the request and the app just stops working — so it is opt-in rather than on by default.
HUDDLE_CSP=1
Then check, in this order, before leaving it on: join a voice room, upload an
image and a PDF, apply a custom theme, send a voice message, open a link preview,
and start the music bot. If any of those break, either leave the CSP off or write
your own with HUDDLE_CSP_POLICY.
The bundled policy allows blob: (the dice renderer, the noise-suppression
worklet and the session recorder all run from blob URLs), 'wasm-unsafe-eval'
(MediaPipe selfie segmentation), 'unsafe-inline' for styles (themes inject CSS),
and ws:/wss: for the realtime socket and LiveKit. Tightening it means
checking those features still work.
Rate limiting
Built in, no configuration. Fixed windows, counted in the database so they hold across restarts and worker isolates:
| Action | Budget |
|---|---|
| Login | 10 attempts per 5 minutes, per IP and per username |
| Signup | 5 attempts per 10 minutes, per IP |
Per-username login limiting means one attacker cannot lock you out by hammering
your name, and a botnet cannot avoid the IP limit by rotating for a single
account. Both buckets must have room for a login to proceed. Exceeding a budget
returns 429 with a Retry-After header.
Behind a reverse proxy, the client address comes from CF-Connecting-IP, then
the first hop of X-Forwarded-For, then X-Real-IP. If none is present every
request shares one bucket — it fails closed (over-limiting) rather than open, so
make sure your proxy sets one of those headers.
Web address
| Variable | Default | What it does |
|---|---|---|
BASE_PATH |
/hangout |
Path the app is served under. Requests to / and other paths outside it are routed into it, so you rarely need to change this. |
LANDING_DOMAINS |
hoffle.online,www.hoffle.online |
Comma-separated domains where / shows the public landing page instead of the app. Set it to your marketing domain if you have one. |
Voice and video
| Variable | Default | What it does |
|---|---|---|
HUDDLE_ICE_SERVERS |
Cloudflare public STUN | JSON array of RTCIceServer objects. Add your TURN server here, then verify it with npm run check:turn -- --ice-servers "$HUDDLE_ICE_SERVERS". See Voice. |
LIVEKIT_URL |
unset | wss:// address of a LiveKit server. When set with the two keys below, voice goes through LiveKit instead of peer-to-peer. |
LIVEKIT_API_KEY |
unset | Key name from livekit.yaml. |
LIVEKIT_API_SECRET |
unset | Secret from livekit.yaml. |
Push notifications
Lets Hoffle notify people about DMs and mentions while the tab is closed. Generate a key pair once:
docker compose exec hoffle npx web-push generate-vapid-keys
| Variable | What it does |
|---|---|
VAPID_PUBLIC_KEY |
Public key from the command above. |
VAPID_PRIVATE_KEY |
Private key from the command above. |
VAPID_SUBJECT |
A contact address, such as mailto:[email protected]. |
Do not change the keys later, or existing subscriptions stop working.
Native Android app notifications (Firebase)
The Android app in mobile/ gets notifications through Firebase Cloud Messaging, since browser push can't run inside the app.
| Variable | What it does |
|---|---|
FCM_SERVICE_ACCOUNT |
The Firebase service-account key (Project settings → Service accounts → Generate new private key), as one line of JSON. |
The matching google-services.json goes into the app build; see mobile/README.md. iOS isn't covered: a sideloaded app can't receive Apple push notifications.
Phone notifications without vendor keys (ntfy / UnifiedPush)
Each user can paste an ntfy topic URL under Settings → Appearance → Phone notifications. Hoffle then POSTs mentions, DMs, event reminders and incoming calls (high priority) to that topic, and the ntfy app delivers them in the background on Android and iOS. Nothing here needs VAPID, APNs or FCM credentials. Run your own ntfy server if you'd rather not use ntfy.sh.
| Variable | What it does |
|---|---|
HUDDLE_PUSH_ALLOW_PRIVATE |
Set to 1 to allow http:// and LAN/private addresses (e.g. an ntfy container on the same compose network). Off by default, since the server makes these requests. |
Integrations
| Variable | What it does |
|---|---|
KLIPY_API_KEY |
Enables GIF search in the message box (klipy.com). Without it, pasting or uploading GIFs still works. |
GEMINI_API_KEY |
Enables /ask (free key). Answers use Flash-Lite, search DuckDuckGo when a question needs current info, and allow 3 questions a minute and 60 an hour per user. |
GEMINI_MODEL |
Models /ask tries in order, comma separated. Default gemini-3.1-flash-lite,gemini-3.5-flash-lite. |
ANTHROPIC_API_KEY |
Answers /ask with Claude instead (paid, platform.claude.com). If GEMINI_API_KEY is set too, Gemini answers whenever Claude fails or runs out of credit. |
ANTHROPIC_MODEL |
Claude model for /ask. Default claude-haiku-5-5. |
AI_DAILY_LIMIT |
/ask answers per day for the whole server, default 200, which keeps a free key under its quota. |
LASTFM_API_KEY, LASTFM_SECRET |
Last.fm "now playing" integration. |
MUSICWATCH_PASSWORD |
Password for the external music dashboard. |
MUSICWATCH_BASE_URL, MUSICWATCH_PUBLIC_URL |
Address of an external music dashboard, if you run one. |
Internal service addresses
Docker Compose sets these for you. You only need them when running services by hand.
| Variable | Compose value | What it does |
|---|---|---|
MUSIC_HELPER_BASE_URL |
http://music-bot:8731 |
yt-dlp resolver used by the music bot. |
DND_BASE_URL |
http://dnd-bot:8732 |
D&D 5e compendium service. |
DND_PUBLIC_URL |
https://dnd.deeppixel.online |
Public address of the D&D companion, for links shown to users. |
Advanced
| Variable | What it does |
|---|---|
FEATURE_RECORD_SESSIONS |
1 turns on the consent-gated D&D session recorder. See dnd-session-recorder.md. |
RECORDER_SERVICE_URL, RECORDER_SERVICE_TOKEN |
Address and token of the recorder service. |
GOOGLE_SITE_VERIFICATION |
Google Search Console verification token for the landing page. |
Adding a new setting (for developers)
Wrangler does not pass the container's environment through to the worker. scripts/entrypoint.sh copies an explicit list of variables (PASSTHROUGH_KEYS) into the env file the worker reads. If you add a binding that should be configurable from .env, add its name to that list.