# Deploying runicgateway.com The operator's guide. `PLAN.md` is the design of record and explains *why* the site is shaped this way; this file is what you follow on the host. **What ships:** one container image, published to the Gitea registry, and the [`docker-compose.yml`](docker-compose.yml) in this repository. There is no installer and no `curl | bash`. The image is built and pushed by `.gitea/workflows/build-image.yml` on every merge to `main`, tagged `latest` and `sha-<7>`. ``` gitea.whitlocktech.com/runicgateway/runicgateway-site:latest ``` **What you provide:** a host with Docker, a reverse proxy that terminates TLS, and a DNS record. --- ## Contents 1. [What the site actually needs](#1-what-the-site-actually-needs) 2. [First deploy](#2-first-deploy) 3. [Putting a proxy in front of it](#3-putting-a-proxy-in-front-of-it) 4. [DNS and TLS](#4-dns-and-tls) 5. [Branding, without a rebuild](#5-branding-without-a-rebuild) 6. [The closed-beta tester list](#6-the-closed-beta-tester-list) 7. [Updating, and the automatic deploy](#7-updating-and-the-automatic-deploy) 8. [Rolling back](#8-rolling-back) 9. [Backups](#9-backups) 10. [When something is wrong](#10-when-something-is-wrong) --- ## 1. What the site actually needs Very little, and that is deliberate (`PLAN.md` §6). | | | |---|---| | **Runtime** | Docker, with Compose v2 (`docker compose`, not `docker-compose`) | | **CPU / RAM** | One core and 512 MB is comfortable. Every page but two is prerendered HTML | | **Disk** | ~750 MB for the image, plus a SQLite file that will not reach a megabyte | | **Network out** | Only to pull the image. The running site makes no outbound request of any kind | | **Network in** | One HTTP port, reached by your reverse proxy | | **Database** | None. No MariaDB, no Redis, no second service | | **Mail** | None. The site sends no email at all (D7) — there is nothing to configure | It does **not** need the platform: no website, no sidecar, no shard. The site describes Runic Gateway; it does not talk to it. ## 2. First deploy ### 2.1 Create the directory The compose file, the `.env` and both bind mounts live together. The automatic deploy ([§7](#7-updating-and-the-automatic-deploy)) expects **`/opt/runicgateway.com`**; if you put it somewhere else, change the `cd` in `.gitea/workflows/build-image.yml`. ```bash sudo mkdir -p /opt/runicgateway.com sudo chown "$USER" /opt/runicgateway.com cd /opt/runicgateway.com ``` Fetch the two files from this repository — the compose file, and the environment template: ```bash curl -fsSLO https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/raw/branch/main/docker-compose.yml curl -fsSLO https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/raw/branch/main/.env.example mv .env.example .env ``` ### 2.2 Create the two mounts ```bash mkdir -p brand data ``` **`data/` must be writable by uid 1000**, which is what the container runs as. If you created it as another user: ```bash sudo chown -R 1000:1000 data ``` Two failure modes this avoids, both of which look like a broken site rather than a permission problem: - **A missing directory.** Docker creates a bind-mount source that does not exist, as `root:root`. The container then cannot open the store, and `/beta` renders with the form replaced by "the signup is temporarily unavailable" — correct behaviour, and a confusing thing to debug. - **`brand/` deleted later.** Same mechanism. An empty `brand/` is fine and produces exactly the stock site; a *missing* one gets recreated as root, and since the container only ever reads it, nothing breaks until the day you want to change the logo. ### 2.3 Fill in the two secrets Open `.env`. Everything has a working default except `BETA_IP_SALT` and `BETA_FORM_KEY`, which default to a random value **per process** — safe, but forgotten on every restart, which means every rate-limit window resets and every open form goes stale. ```bash printf 'BETA_IP_SALT=%s\n' "$(openssl rand -hex 32)" >> .env printf 'BETA_FORM_KEY=%s\n' "$(openssl rand -hex 32)" >> .env ``` (Then delete the two empty declarations the template shipped with, so the file has one of each.) Do not rotate the salt casually: it is what makes the stored `ip_hash` values meaningful, so changing it orphans the rate-limit history of everyone already counted. The raw IP address is never stored — `/privacy` says so, and the salt is the mechanism that makes it true. ### 2.4 Log in to the registry and start The image is published to the organisation's Gitea registry. If the package is not public-read, log in once with a token that has `read:package`: ```bash docker login gitea.whitlocktech.com ``` ```bash docker compose pull docker compose up -d docker compose ps ``` `ps` should show `site` as `running (healthy)` within about a minute. Health is a real HTTP request rather than a process check, and the delay is expected: `npm start` runs the brand rewrite before the server starts. ### 2.5 Confirm it from the host ```bash curl -sI http://127.0.0.1:4321/ | head -n 1 curl -sI http://127.0.0.1:4321/ | grep -i content-security-policy | cut -c1-120 ``` The port is published on every interface by default ([§3.1](#31-forward-to-the-published-port)), so the same two commands work from any other machine on the network with the host's address in place of `127.0.0.1` — which is the quickest way to look at the site in a real browser before DNS or the proxy exists. The second command matters more than the first. The site sends its **own** Content-Security-Policy, per page, built from the hashes of that page's inline scripts and styles. If it is missing, do not add one at the proxy — see below. ## 3. Putting a proxy in front of it The container publishes on **port 4321 of every interface** by default and speaks plain HTTP, so it answers both on `http://127.0.0.1:4321` and on the host's own address — `http://:4321`. Any reverse proxy will do; the site has no opinion about which. What it does have is four requirements, and the third is the one that is easy to get wrong and quiet when you do. ### 3.1 Forward to the published port Whatever your proxy calls it: forward `runicgateway.com` (and `www.` if you want it) to `http://:4321` — loopback if the proxy runs on this same machine, the host's address if it runs in another container or on another machine. There are no WebSockets, no long-polling, no streaming responses and no upload larger than a form field, so no timeout or buffering setting needs changing. A proxy on a shared Docker network can address the service as `site:4321` instead and skip the host port entirely. **Narrowing the binding.** `SITE_BIND_ADDR` in `.env` decides which addresses the port answers on, and nothing else in the site changes with it: ```bash SITE_BIND_ADDR=0.0.0.0 # every interface — the default SITE_BIND_ADDR=192.168.1.10 # one interface: the LAN, but not a public NIC SITE_BIND_ADDR=127.0.0.1 # loopback only: a proxy on THIS host and nothing else ``` **On a host with a public address, the default means port 4321 answers from the internet directly**, beside whatever the proxy serves on 443 — plain HTTP, no TLS, and no proxy in the path to set `X-Forwarded-For` ([§3.2](#32-set-x-forwarded-for)), so signups arriving that way share one rate-limit bucket. There is no login and nothing to steal, so this is untidy rather than dangerous — but on a public host, firewall the port or narrow the binding. ### 3.2 Set `X-Forwarded-For` **This one is load-bearing.** The beta signup rate-limits per client, and it reads the first entry of `X-Forwarded-For`, falling back to the connection's peer address. Behind a proxy that does not set the header, that peer address is *the proxy* — so every visitor on earth shares one bucket, and the third signup of any hour closes the form for everybody. It fails toward refusing signups rather than toward accepting abuse, which is the right direction, but it is still a broken page. Most proxies set the header by default; confirm yours does. `X-Forwarded-Proto` and `Host` are worth passing through as well, in common with any site behind a proxy. ### 3.3 Do not add security headers at the proxy The container already sends `Content-Security-Policy`, `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` and a `Permissions-Policy` (`PLAN.md` D48). That is deliberate: the image should be correct on its own, and a proxy somebody else configures is a promise this repository cannot check. If your proxy adds its own, you get **two** of each. Browsers resolve a duplicate CSP by enforcing the intersection — that is, the *strictest* combination of both — and since this site's policy is a list of per-page hashes, a second generic policy from a proxy will forbid the page's own stylesheet and inline scripts. The site renders unstyled, the documentation theme switcher stops working, and the only symptom is a console message. So: strip a global CSP for this host if your proxy adds one. The one header worth adding at the proxy is HSTS, which the container cannot sensibly set because it does not know whether it is behind TLS. ### 3.4 Give it a real hostname Two absolute URLs are generated at build time — the sitemap and the OpenGraph `og:url` — so the site expects to be served at its own name rather than under a path. Serving it at `example.com/site/` will work visually and produce wrong metadata. ## 4. DNS and TLS The domain is registered through **Cloudflare**, with DNS on Cloudflare (`PLAN.md` §14, N1). 1. In the Cloudflare dashboard, add an `A` record for `runicgateway.com` pointing at the host's public IP (and `AAAA` if it has a v6 address). Add `www` as a `CNAME` to the apex if you want it. 2. Let your proxy obtain the certificate — Let's Encrypt over HTTP-01 works once the record resolves. **If you leave Cloudflare's proxy on (the orange cloud)**, three of its features rewrite HTML and will break the hash-based CSP. Check them before assuming the site is at fault: - **Rocket Loader** — injects a script into every page. Not covered by any hash. Turn it off. - **Auto Minify / HTML minification** — changes the bytes of inline `