Setup DNS - SekiroKenjii/Quizzivy GitHub Wiki
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.
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.
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.
-
Cloudflare dashboard โ Add a site โ
quizzivy.comโ Free plan. -
Cloudflare scans for existing records. There are none, so the list will be empty โ that is expected, not an error.
-
Cloudflare shows two nameservers, e.g.
xxx.ns.cloudflare.com. -
At the current registrar (the one behind
zonedns.vn), replace all fournsN.zonedns.vnentries with Cloudflare's two. -
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.
| 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.
| 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 |
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
- Create the Pages project.
- In the project โ Custom domains โ add
app.quizzivy.com. - Cloudflare creates a CNAME to
<project>.pages.devand 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 -- cloudflareThe 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 directorydist, root directoryweb. Requires the repo to be pushed, and gives preview deploys per branch. -
Direct upload โ
pnpm build && npx wrangler pages deploy dist --project-name quizzivy-webfromweb/. No GitHub needed, good for a first smoke test.
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.
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.
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.
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.
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.
| 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):
-
apiโ add the A and AAAA records above as DNS only, wait forfly certs check api.quizzivy.comto report issued, then switch to proxied. -
appโ Pages projectquizzivy-webโ Custom domains โ addapp.quizzivy.com. Cloudflare creates the record itself.
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.
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-r2Generated from docs/setup/dns.md. Edit that file in the repository; changes made on this page are overwritten.