feat(delivery): phase 12 — the container, and the defect only a proxy could find
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
PLAN.md §13 phase 12, the last one. Four decisions of record, D54–D57, taking the count to fifty-seven; recorded in §6, "How phase 12 delivered it". A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts, .env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the community-health files this was the only repository of the ten to lack, and DEPLOY.md. D54 — a merge deploys, amending D6. build-image.yml pushes runicgateway-site:latest and :sha-<7>, then rolls the container over on the `rgcom` runner out of /opt/runicgateway.com, and waits for the container's own healthcheck rather than for `up -d` to return. D55 — the site runs on its own host behind a generic reverse proxy, so DEPLOY.md states the four requirements rather than one worked example, and the container binds 127.0.0.1 so the safe configuration is the default. D56 — @astrojs/node derives the request protocol from req.socket.encrypted and never reads x-forwarded-proto, so behind a TLS-terminating proxy the browser sends Origin: https://… while the container computes http://… and Astro's CSRF check compares them for equality. Every beta signup, from every visitor, was answered 403. serve.mjs now normalises both forwarded headers, unconditionally — the image should deploy and work. Two assertions in test/headers.test.mjs hold both halves. D57 — DEPLOY.md rather than a README section; SECURITY.md and CODE_OF_CONDUCT.md are pointers to the org's copies rather than copies, because a copy would hard-code the contact address D13 confines to brand.json. Verified: npm run verify green (eleven checks, 36 unit tests, 7 served tests, astro check 0 errors). The image was built and run with both mounts — a mounted brand reached 51 files and all 50 search pages, /brand/* fell back per file, a proxy-shaped signup reached the store, and the export CLI wrote both Play files to the host mount. docker compose config caught a YAML trap in the healthcheck: a block sequence reads the `: ` in `r.ok ? 0 : 1` as a mapping. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
380
DEPLOY.md
Normal file
380
DEPLOY.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# 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** | ~600 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 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 **`127.0.0.1:4321`** by default and speaks plain HTTP. 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://127.0.0.1:4321`. 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.
|
||||
|
||||
**If your proxy is itself in a container, or on another machine,** it cannot reach the host's
|
||||
loopback. Either change the port line in `docker-compose.yml` to publish on all interfaces —
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "${SITE_HOST_PORT:-4321}:4321"
|
||||
```
|
||||
|
||||
— and firewall the port so only the proxy reaches it, or put the proxy on a shared Docker network
|
||||
and address the service as `site:4321`, publishing no host port at all.
|
||||
|
||||
### 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`.
|
||||
|
||||
**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` |
|
||||
|
||||
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).
|
||||
Reference in New Issue
Block a user