All checks were successful
PR checks / checks (pull_request) Successful in 9m56s
D55 bound the published port to 127.0.0.1, on the reasoning that TLS terminates
at a proxy on the same host and nothing else has business reaching the container.
That is right for the host this ends up on and wrong for every step before it: a
loopback binding cannot be opened from a browser on another machine, which is the
first thing an operator wants to do — look at the site on the VM's own address,
before DNS exists, before the proxy exists, from a desktop or a phone.
The port line is now "${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321",
so http://<vm-ip>:4321 answers out of the box, the way a normal bridge publish
behaves. Which addresses it answers on is a variable rather than an edit:
SITE_BIND_ADDR narrows it to one interface, or back to loopback, without touching
a file that `docker compose pull` replaces. That also retires the "change the port
line yourself" instruction DEPLOY.md had to give a proxy running in another
container or on another machine.
What is given up, said plainly in DEPLOY.md §3.1: on a host with a public address
the default answers on port 4321 from the internet, plain HTTP beside the proxy's
443, with no proxy in the path to set X-Forwarded-For — so signups arriving that
way share one rate-limit bucket. There is no login and no secret behind it, so it
is untidy rather than dangerous, and both remedies are named (firewall the port,
or narrow the binding).
Verified by running it, not only by reading it: `docker compose config` accepts
both bindings and resolves host_ip 0.0.0.0 and 127.0.0.1 respectively; the stack
came up healthy, `docker compose port site 4321` reported 0.0.0.0:4321, and the
site answered 200 on both 127.0.0.1 and the machine's LAN address, still carrying
its own per-page CSP.
Recorded as D59, amending D55. Count of record fifty-nine.
Co-Authored-By: Claude <noreply@anthropic.com>
410 lines
19 KiB
Markdown
410 lines
19 KiB
Markdown
# 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://<vm-ip>: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://<host>: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 `<style>` and `<script>`
|
|
elements, so their hashes no longer match what the header declares. Turn it off.
|
|
- **Email address obfuscation** — injects a script *and* rewrites the contact address into an
|
|
obfuscated span. Turn it off; the address on this site is published deliberately (D13).
|
|
|
|
Everything else — caching, Brotli, HTTP/3, Always Use HTTPS — is fine. If you would rather not think
|
|
about it, DNS-only (the grey cloud) works and the site loses nothing, since it uses no third-party
|
|
resource at all.
|
|
|
|
## 5. Branding, without a rebuild
|
|
|
|
`brand/` is the override; the image's `brand-default/` is the stock. **Every file resolves against
|
|
the mount first and the defaults second, per file**, so a directory holding only `theme.css`
|
|
recolours the site and leaves every logo alone. `PLAN.md` §7 is the full account.
|
|
|
|
```
|
|
brand/
|
|
brand.json Site name, tagline, contact address, Discord invite, Gitea org,
|
|
demo URL, Play opt-in URL
|
|
logo.png ONE raster. Every size the site asks for — header at three pixel
|
|
ratios, install icons, apple-touch, favicons, a real .ico — is
|
|
derived from it on demand
|
|
theme.css Redefines the custom properties in tokens.css. It wins by cascade
|
|
layer, so it does not need to be loaded last
|
|
```
|
|
|
|
```bash
|
|
# edit or drop in a file, then:
|
|
docker compose restart site
|
|
```
|
|
|
|
The restart is what rewrites the brand text into the prerendered HTML and re-indexes search — a
|
|
mounted site name has to reach forty-nine pages that were rendered before the file existed.
|
|
|
|
**One field needs no restart:** `betaOptInUrl`. `/beta` renders per request and reads it live, so the
|
|
day the Play closed test opens, pasting the URL into `brand/brand.json` puts a working link on the
|
|
confirmation screen on the very next request.
|
|
|
|
To see which source answered a given asset:
|
|
|
|
```bash
|
|
curl -sI http://127.0.0.1:4321/brand/logo.png | grep -i x-brand-source
|
|
```
|
|
|
|
`mount`, `default`, or `derived:mount` / `derived:default` when the size was generated on demand
|
|
from whichever `logo.png` is in force.
|
|
|
|
## 6. The closed-beta tester list
|
|
|
|
There is no admin page, by design (`PLAN.md` §8) — the site has no authenticated surface at all. The
|
|
list is managed from a shell against the mount.
|
|
|
|
```bash
|
|
cd /opt/runicgateway.com
|
|
|
|
docker compose exec site node scripts/beta.mjs stats
|
|
docker compose exec site node scripts/beta.mjs export # marks the rows exported
|
|
docker compose exec site node scripts/beta.mjs export --all # everything, again
|
|
docker compose exec site node scripts/beta.mjs remove someone@example.com
|
|
```
|
|
|
|
`export` writes two files into `data/exports/` — a CSV record, and a `.txt` of one address per line,
|
|
which is the format Google Play's tester list accepts. They are on the bind mount, so they are on the
|
|
host at `./data/exports/` and can be copied off with `scp` like any other file.
|
|
|
|
`remove` is a deletion request, and it **overwrites** the address, the IP hash and the user agent
|
|
rather than flagging the row. That is what `/privacy` promises; the row survives only as an anonymous
|
|
record that a signup happened.
|
|
|
|
## 7. Updating, and the automatic deploy
|
|
|
|
Merging to `main` builds the image, pushes it, and **deploys it** (D54). No manual step, no release
|
|
tag. What protects production is that every one of the eleven checks and both test suites have
|
|
already run on the pull request, and the deploy job is `needs: build`, so a failed build never
|
|
reaches the host.
|
|
|
|
That requires a Gitea Actions runner **on this host**, labelled `rgcom`, running jobs directly on
|
|
the host rather than inside a container — it needs the host's Docker daemon and
|
|
`/opt/runicgateway.com`.
|
|
|
|
```bash
|
|
# On the host, once. Get the registration token from
|
|
# Gitea → the repository → Settings → Actions → Runners → Create new runner
|
|
act_runner register \
|
|
--no-interactive \
|
|
--instance https://gitea.whitlocktech.com \
|
|
--token <REGISTRATION_TOKEN> \
|
|
--name runicgateway-com-host \
|
|
--labels rgcom:host
|
|
```
|
|
|
|
`rgcom:host` — the `:host` suffix is what makes jobs run on the machine rather than in a job
|
|
container. Without it the job starts in a container with no Docker socket and no
|
|
`/opt/runicgateway.com`, and fails on the `cd`.
|
|
|
|
Make sure the user the runner runs as can talk to Docker (`docker ps` succeeds) and can read and
|
|
write `/opt/runicgateway.com`.
|
|
|
|
**Until that runner exists, the deploy job just queues**, and nothing is harmed: the image has
|
|
already been built and pushed by the time it would run, so the manual update below works throughout,
|
|
and the queued job goes as soon as the runner registers.
|
|
|
|
**One way the automatic deploy can fail before it starts.** The registry is behind Cloudflare, which
|
|
refuses a request body over 100 MB, and `docker push` uploads each image layer as a single request —
|
|
so a layer that grows past that is rejected at the edge with `413 Payload Too Large`, publishing
|
|
nothing. `needs: build` then keeps the deploy from running at all, which means the container you are
|
|
already serving is left alone; the site is simply not updated. The workflow checks layer sizes before
|
|
it pushes and fails with a message naming the layer, so this should announce itself rather than
|
|
arriving as a `413`. Either way it is fixed in the `Dockerfile` (see the `COPY` block that splits
|
|
`node_modules`) and nothing needs doing on the host.
|
|
|
|
**To update by hand instead** — always available, and what you do if the runner is down:
|
|
|
|
```bash
|
|
cd /opt/runicgateway.com
|
|
docker compose pull
|
|
docker compose up -d
|
|
docker compose ps
|
|
```
|
|
|
|
The compose file has no `build:` at all, so a production host can only ever pull.
|
|
|
|
## 8. Rolling back
|
|
|
|
Every merge publishes two tags: `latest` and `sha-<7>` of the commit. To pin:
|
|
|
|
```bash
|
|
cd /opt/runicgateway.com
|
|
sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=sha-1806406/' .env
|
|
docker compose pull && docker compose up -d
|
|
```
|
|
|
|
Set it back to `latest` to resume following `main`. Note that while it is pinned, the automatic
|
|
deploy still runs and still pulls — but Compose recreates the container on the *pinned* tag, so the
|
|
site stays where you put it. That is the intended behaviour: a pin is a decision, and a merge should
|
|
not quietly undo it.
|
|
|
|
The tags are listed under **Packages** on the organisation's Gitea page.
|
|
|
|
## 9. Backups
|
|
|
|
One file matters: `data/beta.sqlite`. The rest of the site is in the image and in git.
|
|
|
|
It is a live SQLite database in WAL mode, so **do not just `cp` it** — a copy taken mid-write can
|
|
miss committed rows sitting in the `-wal` file. Either use SQLite's own backup, which is safe against
|
|
a running writer:
|
|
|
|
```bash
|
|
cd /opt/runicgateway.com
|
|
docker compose exec site node -e "const db=require('better-sqlite3')(process.env.DATA_DIR+'/beta.sqlite');db.exec(\"VACUUM INTO '/app/data/beta-backup.sqlite'\");db.close()"
|
|
mv data/beta-backup.sqlite /somewhere/safe/beta-$(date +%F).sqlite
|
|
```
|
|
|
|
or stop the container first and copy all three files (`beta.sqlite`, `-wal`, `-shm`) together.
|
|
|
|
`brand/` is worth keeping too, if you have customised it — it is the one part of a running
|
|
deployment that exists nowhere else.
|
|
|
|
## 10. When something is wrong
|
|
|
|
```bash
|
|
cd /opt/runicgateway.com
|
|
docker compose logs --tail 200 site
|
|
docker compose ps
|
|
```
|
|
|
|
| Symptom | Cause worth checking first |
|
|
|---|---|
|
|
| Container restarts, or never becomes healthy | `data/` not writable by uid 1000 — `sudo chown -R 1000:1000 data` |
|
|
| `/beta` says the signup is unavailable | Same. The rest of the site is unaffected, which is by design |
|
|
| Every visitor hits the rate limit | The proxy is not setting `X-Forwarded-For` — [§3.2](#32-set-x-forwarded-for) |
|
|
| Rate limits reset on every restart | `BETA_IP_SALT` is unset in `.env` — [§2.3](#23-fill-in-the-two-secrets) |
|
|
| Pages render unstyled; console says `Refused to apply inline style` | A second CSP from the proxy or from Cloudflare Rocket Loader / minification — [§3.3](#33-do-not-add-security-headers-at-the-proxy), [§4](#4-dns-and-tls) |
|
|
| A new logo or site name has not appeared | The mount needs a `docker compose restart site`, not just a file edit — [§5](#5-branding-without-a-rebuild) |
|
|
| Search finds the old site name | Same restart; the boot rewrite re-indexes |
|
|
| The site is stock despite files in `brand/` | Check the mount actually landed: `docker compose exec site ls /app/brand` |
|
|
| A merge did not deploy, and the build job is red | If it failed on the layer check or on a `413`, a layer grew past Cloudflare's 100 MB request limit — [§7](#7-updating-and-the-automatic-deploy). The running container is untouched; the fix is in the Dockerfile, not on the host |
|
|
|
|
Everything the container writes goes to stdout, so `docker compose logs` is the whole log. The
|
|
reverse proxy's access log is the only traffic data that exists — there are no analytics anywhere on
|
|
the site (D9).
|