Files
website/.env.example
wtclaude 4151f7d44e
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 9m29s
fix(ntfy): publish ntfy host port so the external reverse proxy can reach it
The ntfy service was configured with no published host port, on the
assumption that the public reverse proxy shares the compose network and
can dial ntfy:80 directly. It does not — Pangolin runs outside the
compose network and reaches every service through a published host port
(exactly why `app` publishes 3000). With no published port there was
nothing for the notification subdomain to forward to, so push delivery
could never work in production.

Publish container :80 on a host port (NTFY_HOST_PORT, default 2586,
binds 0.0.0.0 like `app`) and correct the now-inaccurate comments in
docker-compose.yml and ntfy/server.yml. Document NTFY_HOST_PORT in
.env.example. No code change — deploy config only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:32 -05:00

143 lines
7.5 KiB
Plaintext

# ─── Runic Gateway — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env.
# To run this as an existing branded instance (e.g. UOMysticmoon), see
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
# Container image tag pulled by docker-compose (app + bot). Published by the
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
# Leave as `latest` for routine deploys; pin to a specific build for a
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
# Deploy: `docker compose pull && docker compose up -d`.
IMAGE_TAG=latest
# App
NODE_ENV=production
PORT=3000
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
INTERNAL_PORT=3001
UPLOAD_DIR=/app/uploads
# Logging — written to BOTH the console and a log file.
LOG_LEVEL=info # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity (keep a full record on disk)
LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
# without a rebuild. Text + colors reach the SPA through the settings API at
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
# admin-editable "site title" and "contact email" settings, if set, override
# BRAND_NAME / BRAND_CONTACT_EMAIL.
BRAND_NAME=Runic Gateway
BRAND_SHORT_NAME=Runic Gateway
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=
BRAND_URL=
# Accent color — drives the web theme's --accent and the Discord embed color.
BRAND_ACCENT_COLOR=#7f99bd
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
BRAND_LOGO=
BRAND_HERO=
BRAND_FAVICON=
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
DB_HOST=db
DB_PORT=3306
DB_NAME=runic_gateway
DB_USER=runic
DB_PASSWORD=change-me-db-password
DB_ROOT_PASSWORD=change-me-root-password
# Auth
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
COOKIE_SECURE=auto
# Changing this on a live instance invalidates existing sessions (users re-login).
COOKIE_NAME=rg_token
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
# Integer hop count or "false" also accepted; a blanket "true" is rejected
# (coerced to 1) to prevent X-Forwarded-For spoofing.
TRUST_PROXY=1
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
DEBUG_TRUST_PROXY=0
# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
# TOTP_ISSUER=Runic Gateway
TOTP_CHALLENGE_TTL=5m
# First admin bootstrap — created only if no users exist yet.
# Set, run once, then you can blank these out.
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. Until it's connected, the contact form falls back to
# a mailto: link (recipient = the `contact_email` site setting).
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
# routes, so a mismatch silently breaks every server<->bot call with 401s.
# It also guards the server's /internal/bot-config route, which returns the
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
# long random string. The Discord bot TOKEN itself is not an env var — it's
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
#
# Defense in depth: even with a strong key, configure Pangolin/your reverse
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
BOT_INTERNAL_URL=http://bot:4100
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
# website ingests its live event feed and proxies its read queries/commands
# (shard status, online players, player-vendor sales, IDOC houses, character
# sheets, account linking, town-crier). In production the sidecar + shard run on
# a DIFFERENT host from the website, so both URLs are configurable. The
# shared-secret auth token is NOT an env var — it is entered in the admin panel
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
# token). These URLs are just defaults; the admin can override them at runtime.
UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
UOLINK_PROTOCOL=1
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
# for the Android app; docs/android/PLAN.md §11).
# NTFY_BASE_URL Public URL devices reach the relay at (behind the
# reverse proxy). Used BOTH to configure the ntfy service
# AND as the backend's SSRF allow-set — a device may only
# register an endpoint whose origin matches this.
# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins
# (defaults to NTFY_BASE_URL's origin). Set only if devices
# register endpoints on a different host than NTFY_BASE_URL.
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
# set one only to require auth on backend→ntfy publishes.
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
# 2586). The public reverse proxy forwards the notification
# subdomain to host:NTFY_HOST_PORT — required because the
# proxy lives outside the compose network and cannot reach
# ntfy any other way. Change only on a host-port conflict.
NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN=
# NTFY_HOST_PORT=2586