Files
runicgateway.com/DEPLOY.md
Claude 92f00ab20a
All checks were successful
PR checks / checks (pull_request) Successful in 9m47s
fix(image): ship node_modules in three layers, and count them before pushing
The merge that landed phase 12 built its image and could not publish it.
`docker push` answered 413 Payload Too Large on one blob and stopped, so the
registry stayed empty and `needs: build` meant the deploy never ran — the site
was merged and undeployed, and the log said only that a digest was too large.

The limit is Cloudflare's, not Gitea's: the instance is proxied, and Cloudflare
refuses a request body over 100 MB below Enterprise. A push uploads each layer
as one monolithic PUT, so the ceiling is per layer and the rejection happens at
the edge, where Gitea never sees it and no Gitea setting can lift it.

One layer was over, by nine megabytes: COPY node_modules at 108.8 MB compressed,
in a 188.6 MB image whose next largest layer is the 47.6 MB Node base. @pagefind
and @img (sharp's libvips) account for it and both are needed at run time — the
boot rewrite re-indexes the site and re-derives the brand images — so what could
move is where they land, not whether they ship.

The build stage moves them aside after `npm prune` and the runtime stage copies
them as their own layers: 46.8 + 50.5 + 11.5 MB, and the image is exactly the
same total size, the same bytes divided differently. Moving rather than copying
twice keeps the three disjoint, so a dependency added later needs no maintenance
here.

A split is a margin and not a guarantee, so the workflow now counts layers before
it pushes: docker save, re-compress anything over 8 MB the way the push would,
fail at 90 MB — not 100, the blob is not the only thing in the request — naming
the layer and what would have happened. Tested in both directions; on the broken
image it reports 108 MB, which is what the registry recorded.

Verified by running the built container, not only by measuring it: healthy in
~25s, the mounted brand rewritten across 51 files, 50 pages re-indexed by
pagefind, and the favicon served as `x-brand-source: derived:mount` — which is
sharp resolving from its new layer. npm run verify is green, all eleven checks
and both suites.

Recorded as D58; DEPLOY.md gains the symptom and what it means for the host
(nothing — the running container is untouched).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:10:34 -05:00

18 KiB

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 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
  2. First deploy
  3. Putting a proxy in front of it
  4. DNS and TLS
  5. Branding, without a rebuild
  6. The closed-beta tester list
  7. Updating, and the automatic deploy
  8. Rolling back
  9. Backups
  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) expects /opt/runicgateway.com; if you put it somewhere else, change the cd in .gitea/workflows/build-image.yml.

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:

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

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:

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.

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:

docker login gitea.whitlocktech.com
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

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 —

    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
# 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:

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.

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.

# 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:

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:

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:

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

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
Rate limits reset on every restart BETA_IP_SALT is unset in .env§2.3
Pages render unstyled; console says Refused to apply inline style A second CSP from the proxy or from Cloudflare Rocket Loader / minification — §3.3, §4
A new logo or site name has not appeared The mount needs a docker compose restart site, not just a file edit — §5
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. 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).