Install the site
The site is a Docker deployment: a MariaDB container, the prebuilt application image, and two files you write. Nothing is compiled on your host, and there is no repository to clone — everything you need is on this page.
-
Make a directory for the deployment.
Everything below is relative to it, and the bind mounts want to exist before the containers do — Docker creates a missing mount source as
root, and the container user then cannot write it.Terminal window mkdir -p runic-gateway/logs runic-gateway/brand runic-gateway/modulescd runic-gateway -
Write
docker-compose.yml.docker-compose.yml services:db:image: mariadb:11restart: unless-stoppedenvironment:MARIADB_DATABASE: ${DB_NAME}MARIADB_USER: ${DB_USER}MARIADB_PASSWORD: ${DB_PASSWORD}MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}volumes:- dbdata:/var/lib/mysqlhealthcheck:test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]interval: 10stimeout: 5sretries: 10app:image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}restart: unless-stoppedenv_file: .envenvironment:DB_HOST: dbUPLOAD_DIR: /app/uploadsLOG_DIR: /app/logsMODULES_DIR: /app/modulesdepends_on:db:condition: service_healthyvolumes:- uploads:/app/uploads- ./logs:/app/logs- ./brand:/app/brand:ro- ./modules:/app/modulesports:- "3000:3000"volumes:dbdata:uploads:This file only ever pulls. There is no
build:anywhere in it, which is deliberate: a production host should not be able to build an image by accident. -
Write
.envbeside it.Every highlighted line must be changed before this is a real deployment. The secrets want to be long random strings —
openssl rand -base64 36three times is enough..env IMAGE_TAG=latestNODE_ENV=productionPORT=3000INTERNAL_PORT=3001DB_HOST=dbDB_PORT=3306DB_NAME=runic_gatewayDB_USER=runicDB_PASSWORD=change-me-db-passwordDB_ROOT_PASSWORD=change-me-root-passwordJWT_SECRET=change-me-to-a-long-random-stringSECRET_ENC_KEY=change-me-to-another-long-random-stringCOOKIE_SECURE=autoTRUST_PROXY=1ADMIN_USERNAME=adminADMIN_PASSWORD=change-me-before-first-bootBOT_INTERNAL_KEY=change-me-to-a-third-long-random-string -
Pull and start.
Terminal window docker compose pulldocker compose up -dThe database comes up first; the app waits for its health check, creates its schema, seeds defaults, creates your first admin, and starts listening.
-
Check that it is up.
Terminal window curl -s http://localhost:3000/api/health{"status":"ok"}If that answers, the site is running. Go to First run.
What you just deployed
Section titled “What you just deployed”localhost:3000 ──► app (the website: API + the built React client, one process) │ └──► db (MariaDB, no host port — only the app can reach it)Four host directories and two volumes hold everything that survives a container:
| Path | What is in it |
|---|---|
./logs/ |
app.log, readable from the host without docker exec |
./modules/ |
Installed modules. A bind mount, so placing one by hand is a supported install |
./brand/ |
Your logo, hero and favicon, if you replace the defaults (Branding and theming) |
dbdata volume |
The database |
uploads volume |
Everything uploaded through the site |
Two variables worth reading twice
Section titled “Two variables worth reading twice”SECRET_ENC_KEY encrypts secrets at rest — OAuth client secrets, the Discord bot
token, the shard’s auth token. In production the server refuses to start without it.
Changing it later does not re-encrypt anything: what was stored under the old key can no
longer be read, and every stored secret has to be entered again.
BOT_INTERNAL_KEY authenticates the internal channel between the site and the Discord
bot. The server also refuses to start in production if it is blank, left at a placeholder,
or shorter than 16 characters — even when, as here, you are not running the bot yet.
What this quickstart leaves out
Section titled “What this quickstart leaves out”The project’s shipped Compose file has two more services. Neither is needed to boot, and each is introduced where it is configured:
ntfy— Push notifications for the Android app. Nothing needs it to boot, and it wants a public URL a first install does not have yet.bot— The Discord bot. It is configured from the admin panel once the site is up, so it is introduced on the integrations page rather than here.
It also leaves out the branding, logging and session variables, which have working defaults and their own admin screens. The full file and the full environment reference are in the website repository.
Behind a reverse proxy
Section titled “Behind a reverse proxy”Not required to get started, and required before anyone else uses the site. Two settings here are what make it correct:
TRUST_PROXY=1tells the app to read the client’s address fromX-Forwarded-For. Rate limiting, login backoff and the bot-scoring IP bans are all only as accurate as that. Set it to the number of proxies in front of the app, or pin it to the proxy’s address; a blankettrueis rejected on purpose, because it would let anyone spoof their address by sending a header.COOKIE_SECURE=autoissues aSecuresession cookie when the request arrives over HTTPS and a plain one otherwise, so logging in works both through the proxy and directly on the LAN while you are setting up.
Point the proxy at port 3000. Do not forward INTERNAL_PORT (3001) — it is the
server-to-bot channel, it is deliberately not published by the Compose file, and it must
never be reachable from outside.