Follow-on from #285, which established that Cloudflare's free tier cannot be applied to bermudalamb.synology.me at all — the free plan supports only full setup, which needs nameserver delegation for the zone, and the zone is Synology's. Partial (CNAME) setup is Business/Enterprise. The findings are in docs/ops/cloudflare-free-tier.md.
So origin hiding is blocked behind owning a domain. This issue is that migration.
This is not a DNS change. The hostname is embedded in outstanding emails, session cookies and signed links, and the application's proxy assumptions are wrong the moment a second proxy exists. Most of the work below is not Cloudflare configuration.
Scope
Register a domain we control, put its zone on Cloudflare, proxy the storefront through it, and keep the NAS as the origin behind DDNS.
The registrar decision
The registrar does nothing for origin hiding on its own — only the Cloudflare proxy does. Buying a domain and using the registrar's own DNS publishes the house IP exactly as today. The registrar's whole job is to own a zone we can delegate to Cloudflare, cheaply.
Registrar
.com renewal
WHOIS privacy
5-year
Notes
Cloudflare Registrar
~$9.77
Free
~$49
At-cost. Must use Cloudflare nameservers
Porkbun
~$11
Free
~$55
NS delegatable anywhere
Namecheap
~$15
Free
~$75
NS delegatable anywhere
GoDaddy
$22.99 (yr 1 $0.99)
+$9.99/yr
~$122
Teaser pricing; privacy is a paid add-on
Proposed: Cloudflare Registrar. Cheapest, and its one restriction — no external nameservers — is the thing we actually want here. The trade-off is lock-in: leaving Cloudflare later means transferring the domain out, not repointing NS. Porkbun is the hedge if that flexibility is wanted, at about a dollar a year more.
Prices are from third-party comparison sites as of 2026-09-05, not each registrar's own pricing page. Confirm at purchase.
Two things that must ship in the same change as the DNS cutover
1. trust proxy is wrong the moment Cloudflare is in front
app.ts:34 sets app.set('trust proxy', 1). Today the chain is client → NPM → app and req.ip is the real client. Cloudflare appends to X-Forwarded-For rather than replacing it, so the chain becomes client → Cloudflare → NPM → app and req.ip resolves to a Cloudflare edge address.
Four limiters key on req.ip and would silently stop distinguishing callers while continuing to look healthy — one abusive client would spend everybody's allowance:
clientErrorLimiter (no keyGenerator, so it defaults to IP)
intakeViewLimiter
intakeSubmitLimiter
verificationResendLimiter is unaffected; it keys on customer id.
Fix is trust proxy: 2, or read CF-Connecting-IP and validate the peer against Cloudflare's published ranges. The comment at rateLimit.ts:62-64 asserts the current behaviour as fact and has to change in the same commit, or it becomes a lie that the next person will believe.
This is latent for any second proxy, not only Cloudflare.
2. The proxied flag is a silent-failure trap
The home IP is dynamic, so a DDNS updater must keep the Cloudflare A record current via their API. That API call carries a proxied boolean.
If the updater ever writes proxied: false, the origin IP goes public and nothing breaks. The site keeps serving, so there is no signal — the protection is simply gone. Same shape as the trust proxy problem: correct-looking behaviour, absent protection.
Requirements:
The updater must assert proxied: true on every write, never inherit it.
Something should periodically verify the record is still proxied and complain if it is not. An unmonitored boolean protecting the thing this whole migration exists for is not good enough.
One upside: with a proxied record, visitors always terminate at Cloudflare, so an origin IP change needs no client-side DNS propagation. Strictly better than plain DDNS today.
UPLOADS_BASE_URL is a separate origin and needs deciding alongside.
docker-compose.qa.yml carries the QA hostname in its documented env block.
NPM's authentik forward-auth gates ^/(admin|api/admin). That config is not in this repo and has to move with the hostname. Per decision 5 in project-context.md, do not widen or narrow that regex in the process.
Gitea (gitea.bermudalamb.synology.me) resolves to the same address and the Actions runner needs it. Left unproxied it republishes the IP; proxied, Bot Fight Mode may challenge the runner.
The cutover window
Outstanding links and cookies already in customers' hands, with the values checked:
Thing
Lifetime
Consequence of the old host going away
Intake action links (ACTION_TTL_MS)
30 days, hardcoded
Approve/reject links in admin mail stop working
Session cookie (SESSION_DAYS)
30 days
Cookies are host-scoped — every customer is logged out at cutover regardless
Email verification (verify_token_hours)
24 h default, admin-configurable
Pending verifications break
Password reset (password_reset_hours)
1 h default, admin-configurable
In-flight resets break
So the old hostname has to keep answering for at least 30 days after cutover, or outstanding intake action links die. The logout is unavoidable and worth deciding whether to announce.
Upload-link expiry was not established — adminUploadLinks.ts bounds submissions with max_submissions and no expiry was found. Confirm before cutover.
Decisions this issue needs to make
Domain name, and registrar (proposed: Cloudflare Registrar).
Whether QA goes through Cloudflare too. If it stays direct it publishes the same address and defeats the exercise, so "leave QA alone" is not a neutral default.
Whether Gitea is proxied, accepting the Bot Fight Mode risk to the Actions runner, or left exposed.
Bot Fight Mode on or off. #285 found it cannot be skipped by WAF or Page Rules on any plan — it does not run on the Ruleset Engine — and Cloudflare documents that it may challenge API traffic. A challenged POST /webhooks/paypal is a capture notification we never receive. Safe answer is off.
Whether to pursue Authenticated Origin Pull, which is on the free plan and is the only piece that actually stops direct-to-origin traffic. Whether NPM's Advanced config can express ssl_verify_client on; durably was not established in #285.
Out of scope
Moving hosting off the NAS. Considered and rejected on 2026-09-05: the driver is origin hiding and cost, and both are served by keeping the NAS as origin.
Anything paid beyond the domain registration itself.
Phishing and brand-impersonation protection, which #285 established is a paid upper-tier product.
Done when
A domain we control resolves to the storefront through Cloudflare, with the record proxied.
dig against the public record returns Cloudflare, not the house.
trust proxy is corrected and rateLimit.ts:62-64 says something true, shipped in the same change.
The DDNS updater writes proxied: true on every update, with something checking it stays that way.
The old hostname still answers, with an agreed date at least 30 days out for retiring it.
QA and Gitea have been decided deliberately and the decision is written down.
docs/ops/cloudflare-free-tier.md is updated to reflect what was actually adopted, rather than being left as a spike that recommended not adopting.
Follow-on from #285, which established that Cloudflare's free tier **cannot be applied to `bermudalamb.synology.me` at all** — the free plan supports only full setup, which needs nameserver delegation for the zone, and the zone is Synology's. Partial (CNAME) setup is Business/Enterprise. The findings are in `docs/ops/cloudflare-free-tier.md`.
So origin hiding is blocked behind owning a domain. This issue is that migration.
**This is not a DNS change.** The hostname is embedded in outstanding emails, session cookies and signed links, and the application's proxy assumptions are wrong the moment a second proxy exists. Most of the work below is not Cloudflare configuration.
## Scope
Register a domain we control, put its zone on Cloudflare, proxy the storefront through it, and keep the NAS as the origin behind DDNS.
## The registrar decision
The registrar does nothing for origin hiding on its own — only the Cloudflare proxy does. Buying a domain and using the registrar's own DNS publishes the house IP exactly as today. The registrar's whole job is to own a zone we can delegate to Cloudflare, cheaply.
| Registrar | .com renewal | WHOIS privacy | 5-year | Notes |
| --- | --- | --- | --- | --- |
| **Cloudflare Registrar** | ~$9.77 | Free | **~$49** | At-cost. **Must** use Cloudflare nameservers |
| Porkbun | ~$11 | Free | ~$55 | NS delegatable anywhere |
| Namecheap | ~$15 | Free | ~$75 | NS delegatable anywhere |
| GoDaddy | $22.99 (yr 1 $0.99) | **+$9.99/yr** | ~$122 | Teaser pricing; privacy is a paid add-on |
**Proposed: Cloudflare Registrar.** Cheapest, and its one restriction — no external nameservers — is the thing we actually want here. The trade-off is lock-in: leaving Cloudflare later means transferring the domain out, not repointing NS. **Porkbun** is the hedge if that flexibility is wanted, at about a dollar a year more.
Prices are from third-party comparison sites as of 2026-09-05, not each registrar's own pricing page. Confirm at purchase.
## Two things that must ship in the same change as the DNS cutover
### 1. `trust proxy` is wrong the moment Cloudflare is in front
`app.ts:34` sets `app.set('trust proxy', 1)`. Today the chain is `client → NPM → app` and `req.ip` is the real client. Cloudflare **appends** to `X-Forwarded-For` rather than replacing it, so the chain becomes `client → Cloudflare → NPM → app` and `req.ip` resolves to a Cloudflare edge address.
Four limiters key on `req.ip` and would silently stop distinguishing callers while continuing to look healthy — one abusive client would spend everybody's allowance:
- `passwordResetRequestLimiter` (via `keyByCallerAndEmail`)
- `clientErrorLimiter` (no `keyGenerator`, so it defaults to IP)
- `intakeViewLimiter`
- `intakeSubmitLimiter`
`verificationResendLimiter` is unaffected; it keys on customer id.
Fix is `trust proxy: 2`, or read `CF-Connecting-IP` and validate the peer against Cloudflare's published ranges. **The comment at `rateLimit.ts:62-64` asserts the current behaviour as fact and has to change in the same commit**, or it becomes a lie that the next person will believe.
This is latent for *any* second proxy, not only Cloudflare.
### 2. The `proxied` flag is a silent-failure trap
The home IP is dynamic, so a DDNS updater must keep the Cloudflare A record current via their API. That API call carries a `proxied` boolean.
**If the updater ever writes `proxied: false`, the origin IP goes public and nothing breaks.** The site keeps serving, so there is no signal — the protection is simply gone. Same shape as the `trust proxy` problem: correct-looking behaviour, absent protection.
Requirements:
- The updater must assert `proxied: true` on every write, never inherit it.
- Something should periodically verify the record is still proxied and complain if it is not. An unmonitored boolean protecting the thing this whole migration exists for is not good enough.
One upside: with a proxied record, visitors always terminate at Cloudflare, so an origin IP change needs no client-side DNS propagation. Strictly better than plain DDNS today.
## The migration surface
Verified in the code rather than guessed:
- **`docker-compose.prod.yml:191`** hardcodes `PUBLIC_URL=https://redefined-designs.bermudalamb.synology.me`.
- **Eight backend files build customer-facing URLs from `PUBLIC_URL`**: `envValidation.ts`, `favoriteAlerts.ts`, `intake/actionLinks.ts`, `intake/notifyDraft.ts`, `routes/adminUploadLinks.ts`, `routes/customers.ts`, `server.ts`, `utils.ts`.
- **`UPLOADS_BASE_URL`** is a separate origin and needs deciding alongside.
- **`docker-compose.qa.yml`** carries the QA hostname in its documented env block.
- **NPM's authentik forward-auth** gates `^/(admin|api/admin)`. That config is not in this repo and has to move with the hostname. Per decision 5 in `project-context.md`, do not widen or narrow that regex in the process.
- **Gitea** (`gitea.bermudalamb.synology.me`) resolves to the same address and the Actions runner needs it. Left unproxied it republishes the IP; proxied, Bot Fight Mode may challenge the runner.
## The cutover window
Outstanding links and cookies already in customers' hands, with the values checked:
| Thing | Lifetime | Consequence of the old host going away |
| --- | --- | --- |
| Intake action links (`ACTION_TTL_MS`) | **30 days**, hardcoded | Approve/reject links in admin mail stop working |
| Session cookie (`SESSION_DAYS`) | 30 days | Cookies are host-scoped — **every customer is logged out at cutover regardless** |
| Email verification (`verify_token_hours`) | 24 h default, admin-configurable | Pending verifications break |
| Password reset (`password_reset_hours`) | 1 h default, admin-configurable | In-flight resets break |
**So the old hostname has to keep answering for at least 30 days after cutover**, or outstanding intake action links die. The logout is unavoidable and worth deciding whether to announce.
Upload-link expiry was **not** established — `adminUploadLinks.ts` bounds submissions with `max_submissions` and no expiry was found. Confirm before cutover.
## Decisions this issue needs to make
- Domain name, and registrar (proposed: Cloudflare Registrar).
- Whether QA goes through Cloudflare too. **If it stays direct it publishes the same address and defeats the exercise**, so "leave QA alone" is not a neutral default.
- Whether Gitea is proxied, accepting the Bot Fight Mode risk to the Actions runner, or left exposed.
- Bot Fight Mode on or off. #285 found it **cannot be skipped by WAF or Page Rules on any plan** — it does not run on the Ruleset Engine — and Cloudflare documents that it may challenge API traffic. A challenged `POST /webhooks/paypal` is a capture notification we never receive. Safe answer is off.
- Whether to pursue Authenticated Origin Pull, which is on the free plan and is the only piece that actually stops direct-to-origin traffic. Whether NPM's Advanced config can express `ssl_verify_client on;` durably was not established in #285.
## Out of scope
- Moving hosting off the NAS. Considered and rejected on 2026-09-05: the driver is origin hiding and cost, and both are served by keeping the NAS as origin.
- Anything paid beyond the domain registration itself.
- Phishing and brand-impersonation protection, which #285 established is a paid upper-tier product.
## Done when
- A domain we control resolves to the storefront through Cloudflare, with the record proxied.
- `dig` against the public record returns Cloudflare, not the house.
- `trust proxy` is corrected and `rateLimit.ts:62-64` says something true, shipped in the same change.
- The DDNS updater writes `proxied: true` on every update, with something checking it stays that way.
- The old hostname still answers, with an agreed date at least 30 days out for retiring it.
- QA and Gitea have been decided deliberately and the decision is written down.
- `docs/ops/cloudflare-free-tier.md` is updated to reflect what was actually adopted, rather than being left as a spike that recommended not adopting.
Refs #285
This settles the blocker this issue was filed for. The registrar comparison above is superseded — the choice was made and the domains are bought, so the Cloudflare Registrar proposal in the issue body is now historical rather than a recommendation. name.com is a full registrar with nameserver control, which is all the Cloudflare free plan's full setup requires.
Domain Lock Plus blocks the nameserver change
This is the part that will bite during the cutover. Domain Safe includes Domain Lock Plus, which by design prevents changes to the domain's nameservers, alongside blocking external transfers and contact/WHOIS changes. It pairs with the account's two-step verification.
Delegating to Cloudflare is a nameserver change, so the cutover sequence has an extra step at the front and back:
Disable Domain Lock Plus on the primary domain (needs 2FA).
Point the nameservers at Cloudflare and let the zone go active.
Re-enable Domain Lock Plus.
Re-enabling afterwards costs nothing operationally: once the zone is delegated, ongoing DNS changes — including the DDNS updater rewriting the A record — happen at Cloudflare, not at name.com, so the lock never sits in the way again.
Two consequences of that same fact, worth knowing rather than discovering:
Domain Safe's DNS monitoring stops being useful after delegation. It watches records at name.com; once Cloudflare is authoritative there is nothing there to watch. It will not tell us if the proxied flag gets flipped — that still needs its own check, per the trap described in the issue body.
name.com's bundled free SSL certificate is redundant with Cloudflare Universal SSL. Nothing to do, just do not spend time wiring it up.
.org has no Domain Safe Pro
Three of the four carry it and .org does not. Deliberate or an oversight? It matters less than it looks — the lock's value is concentrated on whichever domain actually serves the site — but if .org is meant to be defensive-only it is also the one an attacker would find least protected, so it is worth a conscious answer rather than leaving it as an accident.
Decisions this unblocks, and now needs
Which domain is primary. Presumably redefined-designs.com. That value becomes PUBLIC_URL and propagates into every verification email, password-reset link, upload link and intake action link, so it is not a decision to revisit later — see the migration surface in the issue body.
What the other three do. Redirect to the primary, park them, or leave them unused. Only the primary needs to go on Cloudflare; adding all four as zones multiplies the configuration for no gain unless they are actually serving redirects.
Whether QA gets a hostname under the new domain (qa.redefined-designs.com, say) or stays on the Synology DDNS name. The issue body already flags that leaving QA on the old address republishes the origin IP and defeats the exercise — now there is a domain to move it to.
Everything else in the issue body still stands, in particular the two items that must ship in the same change as the DNS cutover: the trust proxy: 1 → 2 correction with rateLimit.ts:62-64, and asserting proxied: true on every DDNS write.
## Domains registered — the prerequisite is met
Registered at **name.com** on 2026-09-06:
| Domain | Domain Safe Pro |
| --- | --- |
| `redefined-designs.com` | Yes |
| `redefined-designs.net` | Yes |
| `redefined-designs.info` | Yes |
| `redefined-designs.org` | **No** |
This settles the blocker this issue was filed for. The registrar comparison above is superseded — the choice was made and the domains are bought, so the Cloudflare Registrar proposal in the issue body is now historical rather than a recommendation. name.com is a full registrar with nameserver control, which is all the Cloudflare free plan's full setup requires.
## Domain Lock Plus blocks the nameserver change
**This is the part that will bite during the cutover.** Domain Safe includes **Domain Lock Plus**, which by design prevents changes to the domain's nameservers, alongside blocking external transfers and contact/WHOIS changes. It pairs with the account's two-step verification.
Delegating to Cloudflare *is* a nameserver change, so the cutover sequence has an extra step at the front and back:
1. Disable Domain Lock Plus on the primary domain (needs 2FA).
2. Point the nameservers at Cloudflare and let the zone go active.
3. Re-enable Domain Lock Plus.
Re-enabling afterwards costs nothing operationally: once the zone is delegated, ongoing DNS changes — including the DDNS updater rewriting the A record — happen **at Cloudflare**, not at name.com, so the lock never sits in the way again.
Two consequences of that same fact, worth knowing rather than discovering:
- **Domain Safe's DNS monitoring stops being useful after delegation.** It watches records at name.com; once Cloudflare is authoritative there is nothing there to watch. It will not tell us if the `proxied` flag gets flipped — that still needs its own check, per the trap described in the issue body.
- **name.com's bundled free SSL certificate is redundant** with Cloudflare Universal SSL. Nothing to do, just do not spend time wiring it up.
## `.org` has no Domain Safe Pro
Three of the four carry it and `.org` does not. Deliberate or an oversight? It matters less than it looks — the lock's value is concentrated on whichever domain actually serves the site — but if `.org` is meant to be defensive-only it is also the one an attacker would find least protected, so it is worth a conscious answer rather than leaving it as an accident.
## Decisions this unblocks, and now needs
- **Which domain is primary.** Presumably `redefined-designs.com`. That value becomes `PUBLIC_URL` and propagates into every verification email, password-reset link, upload link and intake action link, so it is not a decision to revisit later — see the migration surface in the issue body.
- **What the other three do.** Redirect to the primary, park them, or leave them unused. Only the primary needs to go on Cloudflare; adding all four as zones multiplies the configuration for no gain unless they are actually serving redirects.
- **Whether QA gets a hostname under the new domain** (`qa.redefined-designs.com`, say) or stays on the Synology DDNS name. The issue body already flags that leaving QA on the old address republishes the origin IP and defeats the exercise — now there is a domain to move it to.
Everything else in the issue body still stands, in particular the two items that must ship in the same change as the DNS cutover: the `trust proxy: 1 → 2` correction with `rateLimit.ts:62-64`, and asserting `proxied: true` on every DDNS write.
The previous comment asked whether .org lacking Domain Safe Pro was deliberate or an oversight. It is neither — name.com does not appear to offer it for .org, so there was nothing to choose.
Worth being honest about the evidence: name.com's public documentation for Domain Safe does not publish a supported-TLD list at all, so this is an observation from the account rather than something confirmed against their docs. If it ever matters enough to rely on, their support is the only authoritative answer.
What that actually costs, and the mitigation
Domain Lock Plus restricts five things: external transfer to another registrar, internal transfer between name.com accounts, toggling WHOIS privacy, changes to contact/WHOIS records, and changes to nameservers.
Without it, .org falls back to the standard registrar lock (clientTransferProhibited), which is free, supported everywhere, and is the mechanism that actually blocks an unauthorised transfer. The gap is therefore narrower than the product tiers suggest — what is lost is the 2FA-gated second layer and the protection against nameserver changes, not transfer protection outright.
Two things to confirm rather than assume:
The standard registrar lock is on for redefined-designs.org. It is on by default at most registrars, and it is the part that matters.
Account-level 2FA is on. With Domain Lock Plus unavailable on this one, the account password becomes the weakest link for it specifically.
Why this is low-stakes here
.org will not serve the site — the primary carries PUBLIC_URL and everything derived from it. The realistic exposure is someone taking .org and standing up a lookalike storefront to phish customers.
That risk exists regardless of this lock, and #285 already established it is not addressable on Cloudflare's free tier — brand protection and phishing takedown are a paid upper-tier product. So the defensive registration is doing the useful work here by denying the name to someone else; the lock tier on it is a second-order concern.
No change to the cutover plan. The Domain Lock Plus step in the previous comment applies only to whichever domain becomes primary, and that one has it.
## Correction on `.org`
The previous comment asked whether `.org` lacking Domain Safe Pro was deliberate or an oversight. It is neither — **name.com does not appear to offer it for `.org`**, so there was nothing to choose.
Worth being honest about the evidence: name.com's public documentation for Domain Safe does not publish a supported-TLD list at all, so this is an observation from the account rather than something confirmed against their docs. If it ever matters enough to rely on, their support is the only authoritative answer.
## What that actually costs, and the mitigation
Domain Lock Plus restricts five things: external transfer to another registrar, internal transfer between name.com accounts, toggling WHOIS privacy, changes to contact/WHOIS records, and changes to nameservers.
Without it, `.org` falls back to the **standard registrar lock** (`clientTransferProhibited`), which is free, supported everywhere, and is the mechanism that actually blocks an unauthorised transfer. The gap is therefore narrower than the product tiers suggest — what is lost is the 2FA-gated second layer and the protection against nameserver changes, not transfer protection outright.
Two things to confirm rather than assume:
1. **The standard registrar lock is on for `redefined-designs.org`.** It is on by default at most registrars, and it is the part that matters.
2. **Account-level 2FA is on.** With Domain Lock Plus unavailable on this one, the account password becomes the weakest link for it specifically.
## Why this is low-stakes here
`.org` will not serve the site — the primary carries `PUBLIC_URL` and everything derived from it. The realistic exposure is someone taking `.org` and standing up a lookalike storefront to phish customers.
That risk exists regardless of this lock, and #285 already established it is **not** addressable on Cloudflare's free tier — brand protection and phishing takedown are a paid upper-tier product. So the defensive registration is doing the useful work here by denying the name to someone else; the lock tier on it is a second-order concern.
No change to the cutover plan. The Domain Lock Plus step in the previous comment applies only to whichever domain becomes primary, and that one has it.
Decided: the primary domain is redefined-designs.com
Settled on 2026-09-08. This is the one that goes on Cloudflare, carries PUBLIC_URL, and becomes the origin behind the proxied record.
That resolves the first of the three decisions listed above. The remaining two are still open:
What .net, .org and .info do — redirect to the primary, park, or leave unused. Only the primary needs a Cloudflare zone; adding all four multiplies configuration for no gain unless they are actually serving redirects.
Whether QA moves onto the new domain (qa.redefined-designs.com) or stays on the Synology DDNS name. Leaving it on the old address republishes the origin IP and defeats the exercise, so this is not a neutral default.
Worth restating because it constrains the cutover rather than being a detail: PUBLIC_URL propagates into every verification email, password-reset link, upload link and intake action link at the moment those are sent. Intake action links live 30 days, so the old hostname has to keep answering that long after the switch. Picking the primary is therefore the point of no return for links already in customers' hands — which is why it is recorded here rather than left in a chat.
## Decided: the primary domain is `redefined-designs.com`
Settled on 2026-09-08. This is the one that goes on Cloudflare, carries `PUBLIC_URL`, and becomes the origin behind the proxied record.
That resolves the first of the three decisions listed above. The remaining two are still open:
- **What `.net`, `.org` and `.info` do** — redirect to the primary, park, or leave unused. Only the primary needs a Cloudflare zone; adding all four multiplies configuration for no gain unless they are actually serving redirects.
- **Whether QA moves onto the new domain** (`qa.redefined-designs.com`) or stays on the Synology DDNS name. Leaving it on the old address republishes the origin IP and defeats the exercise, so this is not a neutral default.
Worth restating because it constrains the cutover rather than being a detail: `PUBLIC_URL` propagates into every verification email, password-reset link, upload link and intake action link at the moment those are sent. Intake action links live **30 days**, so the old hostname has to keep answering that long after the switch. Picking the primary is therefore the point of no return for links already in customers' hands — which is why it is recorded here rather than left in a chat.
This is much cheaper than the issue body says, and the reason is temporary
The body was written for a live shop. Production is not live and has no customers, which removes most of the cost it describes:
Written as
Actually, right now
Old hostname must answer 30 days after cutover, for outstanding intake action links
Nobody holds one. No window needed.
Every customer is logged out at switchover, host-scoped cookies, worth announcing
Nobody to log out. Nothing to announce.
In-flight verifications and password resets break
None in flight.
Upload-link expiry needs confirming before cutover
No links issued to anyone real.
The remaining work is PUBLIC_URL, the authentik forward-auth config, the Gitea host, and the DNS itself. That is the whole migration surface while nobody is depending on the old address.
This is the cheapest this will ever be, and it gets more expensive the moment the shop opens — every link sent from then on has to keep working.
The one thing that must not ship early
trust proxy: 1 → 2cannot be merged ahead of the DNS cutover. With only Nginx Proxy Manager in front, trusting two hops makes req.ip resolve to something that is not the client, which breaks the four limiters in the direction that looks healthy. It is only correct once Cloudflare is genuinely in front.
So it ships with the cutover, in the same change, exactly as the body says — not as preparation. The same applies to the rateLimit.ts:62-64 comment that asserts the current behaviour.
Ordered sequence
Steps 1–5 are not code and are yours; 6 is the code change and is mine when you reach it.
Add redefined-designs.com to Cloudflare as a zone (free plan, full setup).
Disable Domain Lock Plus at name.com for that domain — it blocks nameserver changes by design, and needs your 2FA.
Point the nameservers at Cloudflare, wait for the zone to go active, then re-enable Domain Lock Plus. It costs nothing afterwards: from then on DNS changes happen at Cloudflare, not name.com.
Create the A record proxied, pointing at the house IP.
Set up the DDNS updater against Cloudflare's API, asserting proxied: true on every write — never inheriting it. A write that quietly sets it false publishes the origin IP and nothing visibly breaks.
Then, in one change:PUBLIC_URL to the new host, trust proxy to 2 with the rateLimit.ts comment corrected, and the authentik forward-auth config moved.
Still open, and worth deciding before step 1
Does QA move too (qa.redefined-designs.com), or stay on the Synology name? Staying republishes the same origin IP and defeats the exercise, so this is not a neutral default.
Is Gitea proxied? It resolves to the same address, so leaving it exposed has the same effect. But proxying it puts the Actions runner behind Bot Fight Mode, which cannot be scoped on any plan.
Bot Fight Mode on or off. Safe answer is off: it cannot be skipped by WAF or Page Rules, and a challenged POST /webhooks/paypal is a capture notification we never receive.
What .net, .org and .info do — redirect, park, or nothing.
Not started
Nothing here is implemented. Steps 1–5 are outside the repository, and step 6 is deliberately not prepared in advance because merging it early is the failure mode described above.
## This is much cheaper than the issue body says, and the reason is temporary
The body was written for a live shop. **Production is not live and has no customers**, which removes most of the cost it describes:
| Written as | Actually, right now |
| --- | --- |
| Old hostname must answer **30 days** after cutover, for outstanding intake action links | Nobody holds one. No window needed. |
| **Every customer is logged out** at switchover, host-scoped cookies, worth announcing | Nobody to log out. Nothing to announce. |
| In-flight verifications and password resets break | None in flight. |
| Upload-link expiry needs confirming before cutover | No links issued to anyone real. |
**The remaining work is `PUBLIC_URL`, the authentik forward-auth config, the Gitea host, and the DNS itself.** That is the whole migration surface while nobody is depending on the old address.
This is the cheapest this will ever be, and it gets more expensive the moment the shop opens — every link sent from then on has to keep working.
## The one thing that must not ship early
`trust proxy: 1 → 2` **cannot be merged ahead of the DNS cutover.** With only Nginx Proxy Manager in front, trusting two hops makes `req.ip` resolve to something that is not the client, which breaks the four limiters in the direction that looks healthy. It is only correct once Cloudflare is genuinely in front.
So it ships *with* the cutover, in the same change, exactly as the body says — not as preparation. The same applies to the `rateLimit.ts:62-64` comment that asserts the current behaviour.
## Ordered sequence
Steps 1–5 are not code and are yours; 6 is the code change and is mine when you reach it.
1. **Add `redefined-designs.com` to Cloudflare** as a zone (free plan, full setup).
2. **Disable Domain Lock Plus** at name.com for that domain — it blocks nameserver changes by design, and needs your 2FA.
3. **Point the nameservers at Cloudflare**, wait for the zone to go active, then **re-enable Domain Lock Plus**. It costs nothing afterwards: from then on DNS changes happen at Cloudflare, not name.com.
4. **Create the A record proxied**, pointing at the house IP.
5. **Set up the DDNS updater** against Cloudflare's API, asserting `proxied: true` on *every* write — never inheriting it. A write that quietly sets it false publishes the origin IP and nothing visibly breaks.
6. **Then, in one change:** `PUBLIC_URL` to the new host, `trust proxy` to 2 with the `rateLimit.ts` comment corrected, and the authentik forward-auth config moved.
## Still open, and worth deciding before step 1
- **Does QA move too** (`qa.redefined-designs.com`), or stay on the Synology name? Staying republishes the same origin IP and defeats the exercise, so this is not a neutral default.
- **Is Gitea proxied?** It resolves to the same address, so leaving it exposed has the same effect. But proxying it puts the Actions runner behind Bot Fight Mode, which cannot be scoped on any plan.
- **Bot Fight Mode on or off.** Safe answer is off: it cannot be skipped by WAF or Page Rules, and a challenged `POST /webhooks/paypal` is a capture notification we never receive.
- **What `.net`, `.org` and `.info` do** — redirect, park, or nothing.
## Not started
Nothing here is implemented. Steps 1–5 are outside the repository, and step 6 is deliberately not prepared in advance because merging it early is the failure mode described above.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-on from #285, which established that Cloudflare's free tier cannot be applied to
bermudalamb.synology.meat all — the free plan supports only full setup, which needs nameserver delegation for the zone, and the zone is Synology's. Partial (CNAME) setup is Business/Enterprise. The findings are indocs/ops/cloudflare-free-tier.md.So origin hiding is blocked behind owning a domain. This issue is that migration.
This is not a DNS change. The hostname is embedded in outstanding emails, session cookies and signed links, and the application's proxy assumptions are wrong the moment a second proxy exists. Most of the work below is not Cloudflare configuration.
Scope
Register a domain we control, put its zone on Cloudflare, proxy the storefront through it, and keep the NAS as the origin behind DDNS.
The registrar decision
The registrar does nothing for origin hiding on its own — only the Cloudflare proxy does. Buying a domain and using the registrar's own DNS publishes the house IP exactly as today. The registrar's whole job is to own a zone we can delegate to Cloudflare, cheaply.
Proposed: Cloudflare Registrar. Cheapest, and its one restriction — no external nameservers — is the thing we actually want here. The trade-off is lock-in: leaving Cloudflare later means transferring the domain out, not repointing NS. Porkbun is the hedge if that flexibility is wanted, at about a dollar a year more.
Prices are from third-party comparison sites as of 2026-09-05, not each registrar's own pricing page. Confirm at purchase.
Two things that must ship in the same change as the DNS cutover
1.
trust proxyis wrong the moment Cloudflare is in frontapp.ts:34setsapp.set('trust proxy', 1). Today the chain isclient → NPM → appandreq.ipis the real client. Cloudflare appends toX-Forwarded-Forrather than replacing it, so the chain becomesclient → Cloudflare → NPM → appandreq.ipresolves to a Cloudflare edge address.Four limiters key on
req.ipand would silently stop distinguishing callers while continuing to look healthy — one abusive client would spend everybody's allowance:passwordResetRequestLimiter(viakeyByCallerAndEmail)clientErrorLimiter(nokeyGenerator, so it defaults to IP)intakeViewLimiterintakeSubmitLimiterverificationResendLimiteris unaffected; it keys on customer id.Fix is
trust proxy: 2, or readCF-Connecting-IPand validate the peer against Cloudflare's published ranges. The comment atrateLimit.ts:62-64asserts the current behaviour as fact and has to change in the same commit, or it becomes a lie that the next person will believe.This is latent for any second proxy, not only Cloudflare.
2. The
proxiedflag is a silent-failure trapThe home IP is dynamic, so a DDNS updater must keep the Cloudflare A record current via their API. That API call carries a
proxiedboolean.If the updater ever writes
proxied: false, the origin IP goes public and nothing breaks. The site keeps serving, so there is no signal — the protection is simply gone. Same shape as thetrust proxyproblem: correct-looking behaviour, absent protection.Requirements:
proxied: trueon every write, never inherit it.One upside: with a proxied record, visitors always terminate at Cloudflare, so an origin IP change needs no client-side DNS propagation. Strictly better than plain DDNS today.
The migration surface
Verified in the code rather than guessed:
docker-compose.prod.yml:191hardcodesPUBLIC_URL=https://redefined-designs.bermudalamb.synology.me.PUBLIC_URL:envValidation.ts,favoriteAlerts.ts,intake/actionLinks.ts,intake/notifyDraft.ts,routes/adminUploadLinks.ts,routes/customers.ts,server.ts,utils.ts.UPLOADS_BASE_URLis a separate origin and needs deciding alongside.docker-compose.qa.ymlcarries the QA hostname in its documented env block.^/(admin|api/admin). That config is not in this repo and has to move with the hostname. Per decision 5 inproject-context.md, do not widen or narrow that regex in the process.gitea.bermudalamb.synology.me) resolves to the same address and the Actions runner needs it. Left unproxied it republishes the IP; proxied, Bot Fight Mode may challenge the runner.The cutover window
Outstanding links and cookies already in customers' hands, with the values checked:
ACTION_TTL_MS)SESSION_DAYS)verify_token_hours)password_reset_hours)So the old hostname has to keep answering for at least 30 days after cutover, or outstanding intake action links die. The logout is unavoidable and worth deciding whether to announce.
Upload-link expiry was not established —
adminUploadLinks.tsbounds submissions withmax_submissionsand no expiry was found. Confirm before cutover.Decisions this issue needs to make
POST /webhooks/paypalis a capture notification we never receive. Safe answer is off.ssl_verify_client on;durably was not established in #285.Out of scope
Done when
digagainst the public record returns Cloudflare, not the house.trust proxyis corrected andrateLimit.ts:62-64says something true, shipped in the same change.proxied: trueon every update, with something checking it stays that way.docs/ops/cloudflare-free-tier.mdis updated to reflect what was actually adopted, rather than being left as a spike that recommended not adopting.Refs #285
Domains registered — the prerequisite is met
Registered at name.com on 2026-09-06:
redefined-designs.comredefined-designs.netredefined-designs.inforedefined-designs.orgThis settles the blocker this issue was filed for. The registrar comparison above is superseded — the choice was made and the domains are bought, so the Cloudflare Registrar proposal in the issue body is now historical rather than a recommendation. name.com is a full registrar with nameserver control, which is all the Cloudflare free plan's full setup requires.
Domain Lock Plus blocks the nameserver change
This is the part that will bite during the cutover. Domain Safe includes Domain Lock Plus, which by design prevents changes to the domain's nameservers, alongside blocking external transfers and contact/WHOIS changes. It pairs with the account's two-step verification.
Delegating to Cloudflare is a nameserver change, so the cutover sequence has an extra step at the front and back:
Re-enabling afterwards costs nothing operationally: once the zone is delegated, ongoing DNS changes — including the DDNS updater rewriting the A record — happen at Cloudflare, not at name.com, so the lock never sits in the way again.
Two consequences of that same fact, worth knowing rather than discovering:
proxiedflag gets flipped — that still needs its own check, per the trap described in the issue body..orghas no Domain Safe ProThree of the four carry it and
.orgdoes not. Deliberate or an oversight? It matters less than it looks — the lock's value is concentrated on whichever domain actually serves the site — but if.orgis meant to be defensive-only it is also the one an attacker would find least protected, so it is worth a conscious answer rather than leaving it as an accident.Decisions this unblocks, and now needs
redefined-designs.com. That value becomesPUBLIC_URLand propagates into every verification email, password-reset link, upload link and intake action link, so it is not a decision to revisit later — see the migration surface in the issue body.qa.redefined-designs.com, say) or stays on the Synology DDNS name. The issue body already flags that leaving QA on the old address republishes the origin IP and defeats the exercise — now there is a domain to move it to.Everything else in the issue body still stands, in particular the two items that must ship in the same change as the DNS cutover: the
trust proxy: 1 → 2correction withrateLimit.ts:62-64, and assertingproxied: trueon every DDNS write.Correction on
.orgThe previous comment asked whether
.orglacking Domain Safe Pro was deliberate or an oversight. It is neither — name.com does not appear to offer it for.org, so there was nothing to choose.Worth being honest about the evidence: name.com's public documentation for Domain Safe does not publish a supported-TLD list at all, so this is an observation from the account rather than something confirmed against their docs. If it ever matters enough to rely on, their support is the only authoritative answer.
What that actually costs, and the mitigation
Domain Lock Plus restricts five things: external transfer to another registrar, internal transfer between name.com accounts, toggling WHOIS privacy, changes to contact/WHOIS records, and changes to nameservers.
Without it,
.orgfalls back to the standard registrar lock (clientTransferProhibited), which is free, supported everywhere, and is the mechanism that actually blocks an unauthorised transfer. The gap is therefore narrower than the product tiers suggest — what is lost is the 2FA-gated second layer and the protection against nameserver changes, not transfer protection outright.Two things to confirm rather than assume:
redefined-designs.org. It is on by default at most registrars, and it is the part that matters.Why this is low-stakes here
.orgwill not serve the site — the primary carriesPUBLIC_URLand everything derived from it. The realistic exposure is someone taking.organd standing up a lookalike storefront to phish customers.That risk exists regardless of this lock, and #285 already established it is not addressable on Cloudflare's free tier — brand protection and phishing takedown are a paid upper-tier product. So the defensive registration is doing the useful work here by denying the name to someone else; the lock tier on it is a second-order concern.
No change to the cutover plan. The Domain Lock Plus step in the previous comment applies only to whichever domain becomes primary, and that one has it.
Decided: the primary domain is
redefined-designs.comSettled on 2026-09-08. This is the one that goes on Cloudflare, carries
PUBLIC_URL, and becomes the origin behind the proxied record.That resolves the first of the three decisions listed above. The remaining two are still open:
.net,.organd.infodo — redirect to the primary, park, or leave unused. Only the primary needs a Cloudflare zone; adding all four multiplies configuration for no gain unless they are actually serving redirects.qa.redefined-designs.com) or stays on the Synology DDNS name. Leaving it on the old address republishes the origin IP and defeats the exercise, so this is not a neutral default.Worth restating because it constrains the cutover rather than being a detail:
PUBLIC_URLpropagates into every verification email, password-reset link, upload link and intake action link at the moment those are sent. Intake action links live 30 days, so the old hostname has to keep answering that long after the switch. Picking the primary is therefore the point of no return for links already in customers' hands — which is why it is recorded here rather than left in a chat.This is much cheaper than the issue body says, and the reason is temporary
The body was written for a live shop. Production is not live and has no customers, which removes most of the cost it describes:
The remaining work is
PUBLIC_URL, the authentik forward-auth config, the Gitea host, and the DNS itself. That is the whole migration surface while nobody is depending on the old address.This is the cheapest this will ever be, and it gets more expensive the moment the shop opens — every link sent from then on has to keep working.
The one thing that must not ship early
trust proxy: 1 → 2cannot be merged ahead of the DNS cutover. With only Nginx Proxy Manager in front, trusting two hops makesreq.ipresolve to something that is not the client, which breaks the four limiters in the direction that looks healthy. It is only correct once Cloudflare is genuinely in front.So it ships with the cutover, in the same change, exactly as the body says — not as preparation. The same applies to the
rateLimit.ts:62-64comment that asserts the current behaviour.Ordered sequence
Steps 1–5 are not code and are yours; 6 is the code change and is mine when you reach it.
redefined-designs.comto Cloudflare as a zone (free plan, full setup).proxied: trueon every write — never inheriting it. A write that quietly sets it false publishes the origin IP and nothing visibly breaks.PUBLIC_URLto the new host,trust proxyto 2 with therateLimit.tscomment corrected, and the authentik forward-auth config moved.Still open, and worth deciding before step 1
qa.redefined-designs.com), or stay on the Synology name? Staying republishes the same origin IP and defeats the exercise, so this is not a neutral default.POST /webhooks/paypalis a capture notification we never receive..net,.organd.infodo — redirect, park, or nothing.Not started
Nothing here is implemented. Steps 1–5 are outside the repository, and step 6 is deliberately not prepared in advance because merging it early is the failure mode described above.