Setup DNS - SekiroKenjii/Quizzivy GitHub Wiki

DNS for quizzivy.com

Not needed for Phase 0 or Phase 1. Local development runs entirely on localhost, and the Google OAuth client already has the localhost redirect registered and verified. This blocks the first real deploy, nothing before it.

Tracked as O-14.


Current state

Checked 2026-08-28:

Nameservers ns1โ€“ns4.zonedns.vn โ€” not Cloudflare yet
A / CNAME records none, on the apex or any subdomain
Google OAuth โœ… https://app.quizzivy.com/auth/google/callback is registered

The Google half is confirmed mechanically, not by memory: posting a deliberately invalid authorization code to Google's token endpoint with that redirect URI returns invalid_grant (credentials and redirect accepted) rather than redirect_uri_mismatch. make verify-google does the same check for whatever GOOGLE_REDIRECT_URI is set to.


Step 0 โ€” move the domain to Cloudflare

Do this first and independently of everything else: nameserver changes take up to 24 hours to propagate, so starting it early costs nothing and removes it from the critical path.

  1. Cloudflare dashboard โ†’ Add a site โ†’ quizzivy.com โ†’ Free plan.

  2. Cloudflare scans for existing records. There are none, so the list will be empty โ€” that is expected, not an error.

  3. Cloudflare shows two nameservers, e.g. xxx.ns.cloudflare.com.

  4. At the current registrar (the one behind zonedns.vn), replace all four nsN.zonedns.vn entries with Cloudflare's two.

  5. Wait for Cloudflare to report Active. Verify from here with:

    dig +short NS quizzivy.com @1.1.1.1

Nothing breaks while this propagates, because nothing is served from the domain yet.

Why Cloudflare rather than staying at the current registrar: R2 is already on this account, so the media bucket and DNS end up in one place; and apex CNAME flattening removes the usual "you cannot CNAME the apex" problem if the SPA ever moves to quizzivy.com itself.


The layout

Host Serves
app.quizzivy.com the SPA
api.quizzivy.com the Go API
quizzivy.com nothing yet โ€” kept free for a landing page

Both hosts must stay under quizzivy.com. That is not cosmetic: it is what makes them same-site, which is what makes ยง5.2's httpOnly; Secure; SameSite=Lax; Path=/auth refresh cookie work on a cross-origin request (docs/plan/00-overview.md ยง4.1).

Split them across two registrable domains and Lax cookies stop being sent entirely โ€” sessions die after 15 minutes with no error anywhere. That is R-07, and it is the kind of failure that survives code review because nothing looks wrong.


Step 1 โ€” the records

Name Type Target Who creates it
app CNAME <project>.pages.dev Cloudflare, automatically
api A + AAAA the IPs fly certs add prints you, by hand

app โ€” Cloudflare Pages

You do not create this record. That is the part worth being explicit about: Pages owns both the DNS record and the certificate. The flow is

  1. Create the Pages project.
  2. In the project โ†’ Custom domains โ†’ add app.quizzivy.com.
  3. Cloudflare creates a CNAME to <project>.pages.dev and issues the cert.

There is no value for you to type into a Target field.

If you create the CNAME by hand instead, the site returns 522 and it is not obvious why. Cloudflare proxies the request to Pages, Pages does not recognise the Host header as one of its domains, and the connection is refused โ€” which the edge reports as an origin timeout. Confirmed 2026-08-28:

curl -o /dev/null -w '%{http_code}\n' https://app.quizzivy.com/
# 522

# the giveaway: Pages itself rejects the hostname
curl -H 'Host: app.quizzivy.com' https://quizzivy-web.pages.dev/
# 403 Forbidden -- cloudflare

The fix is to add the domain in the Pages project. If a hand-made record is already there, delete it first โ€” Pages will not claim a name that already exists.

Two ways to create the project:

  • Git integration โ€” connect the GitHub repo, build command pnpm build, output directory dist, root directory web. Requires the repo to be pushed, and gives preview deploys per branch.
  • Direct upload โ€” pnpm build && npx wrangler pages deploy dist --project-name quizzivy-web from web/. No GitHub needed, good for a first smoke test.

api โ€” Fly.io, and the certificate

Done on 2026-08-28. fly certs add api.quizzivy.com created the certificate and printed the records it wants โ€” A and AAAA to the app's dedicated IPs, not a CNAME to quizzivy-api.fly.dev. Use whatever fly certs add prints rather than the values here; they are per-app.

A     api  ->  66.241.124.115
AAAA  api  ->  2a09:8280:1::17d:c39f:0

Order matters. Add them DNS only (grey cloud) first: with the record already proxied, Cloudflare answers the ACME challenge instead of Fly and issuance hangs. Then:

fly certs check api.quizzivy.com     # wait for "Certificate issued"

Only once it is issued, switch the records to proxied (orange) and set the zone's SSL/TLS mode to Full (strict) โ€” Fly now presents a real certificate, so strict verification passes.

Leaving them grey is a defensible alternative: one less hop, and Fly terminates TLS itself. It changes one setting, below.

The setting that depends on the proxy decision

CLIENT_IP_HEADER must name the header set by whatever sits directly in front of the app:

api record CLIENT_IP_HEADER
proxied (orange) CF-Connecting-IP
DNS only (grey) Fly-Client-IP

The record was left grey, and fly.toml sets Fly-Client-IP (since 2026-10-09). Until then it set CF-Connecting-IP, which on a grey record any client can send, so every rate limit was bypassable. Change both together if the record is ever made proxied.

The same setting decides whether the Signed-in devices list shows a location (T-R4.9). Only with CF-Connecting-IP, and only on a request that carries it, the API also reads CF-IPCity and CF-IPCountry to label where a device last signed in or refreshed, as "City, CC". Cloudflare sends the country by default and the city only while the "Add visitor location headers" Managed Transform is on (Rules โ†’ Settings โ†’ Managed Transforms; check the name in the dashboard). Without it the label is the country alone. With the record grey, the API records no location.

Never set this to X-Forwarded-For. Proxies append to that header, so a client can send its own value and have the real address appended after it โ€” letting it pick a fresh rate-limit bucket per request and defeat ยง6.5 entirely. config.Load refuses to start if CLIENT_IP_HEADER is X-Forwarded-For, and internal/ratelimit/clientip_test.go covers the forging case end to end.

Cloudflare proxy limits worth knowing

The body limit leaves room for the media caps; time limits can still bind a large upload. Checked against Cloudflare's documentation on 2026-10-05:

  • Request body: the Free plan's default is 100 MB, above ยง11.1's 50 MiB audio and 10 MiB image limits plus multipart overhead. A zone can lower it in Network โ†’ Maximum Upload Size. See Cloudflare's upload limits.
  • Proxy Read Timeout: 125 seconds waiting for the origin's response. Proxy Write Timeout: 30 seconds writing the request to the origin. These are separate limits; see Cloudflare's connection limits.

The API keeps a 110-second streamed-body read deadline and a 120-second http.Server.WriteTimeout. The latter bounds writing the response; it does not cancel the request context or guarantee that processing stops at 120 seconds. A 50 MiB payload needs at least about 3.81 Mbit/s to arrive within 110 seconds, before multipart overhead. An upload near that deadline leaves roughly ten seconds of nominal response headroom for probing, object storage, and the database, so it needs a faster sustained connection in practice. The file-size cap does not guarantee success on every connection. Diagnose edge 524 responses separately from API read or write deadline failures.


Secrets on Fly

Set these with fly secrets set, never in fly.toml โ€” that file is committed.

DATABASE_URL           postgres://quizzivy_app:...@<neon-host>/quizzivy?sslmode=require
MIGRATE_DATABASE_URL   postgres://quizzivy_migrate:...@<neon-host>/quizzivy?sslmode=require
JWT_SIGNING_KEY        openssl rand -base64 48
JOIN_CODE_KEY          openssl rand -base64 32, with an offline copy in the owner's password manager
GOOGLE_CLIENT_SECRET   from the OAuth client
S3_ENDPOINT            https://<R2_ACCOUNT_ID>.r2.cloudflarestorage.com
S3_BUCKET              quizzivy-media
S3_ACCESS_KEY_ID       the R2 access key id
S3_SECRET_ACCESS_KEY   the R2 secret

Object storage uses the S3_* names, not R2_*. This list said R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY until 2026-09-01, and those three are read by make verify-r2 and by nothing else โ€” .env.example says so directly: "The server reads the S3_* names above and does not derive anything from R2_ACCOUNT_ID."

Setting only the R2_* names does not fail. MediaEnabled() sees zero of four set, treats object storage as simply not configured, and the API serves everything else โ€” so the deployment comes up healthy, passes /healthz, and audio upload is dead. On a listening-test product that is the feature, and the only sign is one line at startup: media storage disabled (no bucket configured). Check for it after a deploy.

Leave S3_FORCE_PATH_STYLE unset in production. R2 addresses buckets by subdomain and needs it false, which is the default; only MinIO locally needs it true.

GOOGLE_CLIENT_ID is NOT a secret and is not set here. It lives in fly.toml's [env], because it is a public identifier that already ships inside a JS bundle any visitor can read. Setting it as a secret as well gives one value two homes, and they drift.

Two roles, two URLs, deliberately (ยง13.5). MIGRATE_DATABASE_URL is used only by the release command; the running API connects as quizzivy_app, which cannot run DDL. sslmode=require because Neon is over the public internet.

Deployment shape

Dockerfile builds two static binaries into a distroless image (36 MB): the API and a small migration runner. The runner exists instead of shipping the goose CLI, which links drivers for MySQL, SQLite, Turso, Vertica and YDB โ€” a dozen dependencies this project does not use, in a binary that runs against a public deployment. It uses the same goose library at the same version, with only the Postgres driver attached.

fly.toml runs migrate up as a release_command, so the schema is applied before a new version takes traffic, and rolls back the deploy if it fails.

min_machines_running = 1 and auto_stop_machines = false: this app has a server-authoritative timer and a 1.5-second autosave loop, so a cold start when a class all begins a test at once is both slow and visible. At one shared-cpu-1x machine, never sleeping is a couple of dollars a month.

What is deployed (2026-08-28)

API https://quizzivy-api.fly.dev โ€” 2 machines in sin, healthy
SPA https://quizzivy-web.pages.dev
Database Neon, ap-southeast-1, PostgreSQL 18.6, migrations at version 3

Verified end to end: /healthz returns database:ok; an unbuilt operation returns the Vietnamese error envelope with a requestId matching the X-Request-Id header; CORS allows https://app.quizzivy.com and refuses others; deep links like /join/K7M3-P9QR serve the SPA shell rather than a CDN 404.

Two manual steps remain, both needing dashboard access this project's tokens do not have (zone:read only, and Pages custom domains have no wrangler command):

  1. api โ€” add the A and AAAA records above as DNS only, wait for fly certs check api.quizzivy.com to report issued, then switch to proxied.
  2. app โ€” Pages project quizzivy-web โ†’ Custom domains โ†’ add app.quizzivy.com. Cloudflare creates the record itself.

A note on machine count

Fly created two machines on first deploy, for zero-downtime rolling deploys. fly.toml only asks for a minimum of one. Two costs roughly twice as much and, at ~50 students, one would serve the load comfortably โ€” fly scale count 1 -a quizzivy-api if the redundancy is not worth it. The argument for keeping two is that this is a test-taking app: a machine dying mid-exam with no second one is a bad afternoon.

Verify after the first deploy

curl -s https://api.quizzivy.com/healthz            # {"database":"ok","status":"ok"}
GOOGLE_REDIRECT_URI=https://app.quizzivy.com/auth/google/callback make verify-google
make verify-r2

Generated from docs/setup/dns.md. Edit that file in the repository; changes made on this page are overwritten.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ