Skip to content

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.

  1. 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/modules
    cd runic-gateway
  2. Write docker-compose.yml.

    docker-compose.yml
    services:
    db:
    image: mariadb:11
    restart: unless-stopped
    environment:
    MARIADB_DATABASE: ${DB_NAME}
    MARIADB_USER: ${DB_USER}
    MARIADB_PASSWORD: ${DB_PASSWORD}
    MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
    - dbdata:/var/lib/mysql
    healthcheck:
    test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
    interval: 10s
    timeout: 5s
    retries: 10
    app:
    image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    environment:
    DB_HOST: db
    UPLOAD_DIR: /app/uploads
    LOG_DIR: /app/logs
    MODULES_DIR: /app/modules
    depends_on:
    db:
    condition: service_healthy
    volumes:
    - uploads:/app/uploads
    - ./logs:/app/logs
    - ./brand:/app/brand:ro
    - ./modules:/app/modules
    ports:
    - "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.

  3. Write .env beside it.

    Every highlighted line must be changed before this is a real deployment. The secrets want to be long random strings — openssl rand -base64 36 three times is enough.

    .env
    IMAGE_TAG=latest
    NODE_ENV=production
    PORT=3000
    INTERNAL_PORT=3001
    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
    JWT_SECRET=change-me-to-a-long-random-string
    SECRET_ENC_KEY=change-me-to-another-long-random-string
    COOKIE_SECURE=auto
    TRUST_PROXY=1
    ADMIN_USERNAME=admin
    ADMIN_PASSWORD=change-me-before-first-boot
    BOT_INTERNAL_KEY=change-me-to-a-third-long-random-string
  4. Pull and start.

    Terminal window
    docker compose pull
    docker compose up -d

    The database comes up first; the app waits for its health check, creates its schema, seeds defaults, creates your first admin, and starts listening.

  5. 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.

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

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.

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.

Not required to get started, and required before anyone else uses the site. Two settings here are what make it correct:

  • TRUST_PROXY=1 tells the app to read the client’s address from X-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 blanket true is rejected on purpose, because it would let anyone spoof their address by sending a header.
  • COOKIE_SECURE=auto issues a Secure session 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.