Merge pull request 'docs(ops): the free tier cannot be applied to this hostname (#285)' (#315) from feature/285-cloudflare-free-tier into main
Linting / lint (push) Successful in 3m5s
SonarQube Analysis / sonarqube (push) Failing after 13m27s

Reviewed-on: #315
This commit was merged in pull request #315.
This commit is contained in:
2026-09-06 10:02:26 -05:00
+108
View File
@@ -0,0 +1,108 @@
# Cloudflare's free tier: what it would and would not do for us
Findings for #285. Everything here was checked against Cloudflare's own documentation on 2026-09-05 and against this repository's code; nothing was measured against a live Cloudflare account, because — see immediately below — one cannot be set up for this hostname.
**Status: not adoptable as things stand. The recommendation is to not adopt, and to reconsider only if the site moves to a domain we control.** The blocker is not a Cloudflare feature being paid. It is that the free plan cannot be applied to `bermudalamb.synology.me` at all.
## The blocker: we do not own the zone
Cloudflare's free plan supports exactly one onboarding path — **full setup**, which requires changing the authoritative nameservers for the *zone* at its registrar.
Our hostname is `redefined-designs.bermudalamb.synology.me`. The zone is `synology.me`, which belongs to Synology. We hold a DDNS label inside it and have no ability to delegate that zone's nameservers, so full setup is not available to us.
The two alternatives both sit behind paid plans:
| Setup | What it needs | Plan required |
| --- | --- | --- |
| Full setup | Nameserver delegation for the whole zone | Free |
| Partial (CNAME) setup | A CNAME at the existing DNS host | **Business or Enterprise** |
| Subdomain setup (NS delegation of a subdomain as its own zone) | `NS` records in the parent zone | **Enterprise** |
So the honest answer to "what does the free plan change about our origin IP" is: **nothing, because we cannot put this hostname on the free plan.** #285 anticipated exactly this shape of result and asked for it to be written down rather than treated as a reason to widen the spike.
**The prerequisite, if this is ever wanted, is a domain we control** — a registered domain of our own, with its nameservers pointed at Cloudflare and the DDNS hostname kept as the origin record behind it. That is a real change with its own consequences (every `PUBLIC_URL`, the authentik forward-auth configuration, the Gitea host, and every verification and upload link already sent), not a configuration toggle. It should be its own issue if it is wanted.
Everything below assumes that prerequisite is met, because the rest of the spike's questions are still worth having answered before anyone buys a domain expecting a particular outcome.
## Origin hiding, answered concretely
With DNS proxied, `dig` against the proxied record returns Cloudflare's addresses rather than the house IP. That part works. What still leaks it, specifically for us:
- **Any DNS-only record pointing at the same machine.** Only `A`, `AAAA` and `CNAME` records can be proxied. `gitea.bermudalamb.synology.me` resolves to the same house IP, and the Actions runner and the deploy workflow both need to reach it. Proxying Gitea as well, or accepting that it publishes the address, is a decision that has to be made deliberately — leaving it unproxied undoes most of the benefit.
- **`MX` and `TXT` records are always DNS-only.** They cannot be proxied at any plan level.
- **Outbound mail.** We send through `smtp-relay.brevo.com`, so Brevo is the sending host — but the NAS's own address can appear in the first `Received` header of a relayed message. Worth checking a real delivered message rather than assuming either way.
- **Certificate transparency logs.** Any certificate ever issued for the hostname is public and permanent. Historical CT entries for `*.bermudalamb.synology.me` cannot be retracted, so an address published there stays discoverable afterwards.
- **The QA stack.** If `qa-redefined-designs…` stays direct while production is proxied, it publishes the same address and the whole exercise is defeated. QA would have to go through Cloudflare too, or move off that address.
The realistic summary: proxying raises the effort required, it does not make the address secret. Treat it as *reducing casual discovery*, not as hiding.
## What it would break
Two concrete things, and the first is not obvious.
### 1. `trust proxy` is wrong the moment a second proxy exists
`backend/src/app.ts` sets `app.set('trust proxy', 1)` — trust exactly one hop. Today the chain is `client → NPM → app`, so `req.ip` is the real client address, which is what `backend/src/rateLimit.ts` depends on and what its comment at lines 6264 asserts.
Cloudflare **appends** to `X-Forwarded-For` rather than replacing it. Adding it makes the chain `client → Cloudflare → NPM → app`, so:
| | `X-Forwarded-For` at the app | `req.ip` with `trust proxy: 1` |
| --- | --- | --- |
| Today | `client` | `client` — correct |
| With Cloudflare | `client, cf-edge` | **`cf-edge` — wrong** |
Four limiters key on `req.ip` and would collapse every visitor into a handful of Cloudflare edge addresses:
- `passwordResetRequestLimiter` (via `keyByCallerAndEmail`)
- `clientErrorLimiter`
- `intakeViewLimiter`
- `intakeSubmitLimiter`
`verificationResendLimiter` is unaffected — it keys on the customer id.
This fails silently and in the dangerous direction: the limiters keep working, they just stop distinguishing callers, so one abusive client spends everybody's allowance. The fix is `trust proxy: 2`, or reading `CF-Connecting-IP` and validating the peer against Cloudflare's published ranges. (`True-Client-IP` is Enterprise-only and is otherwise identical to `CF-Connecting-IP`; there is no reason to reach for it.) **Whichever is chosen, the comment at `rateLimit.ts:62-64` has to change with it, because it currently states as fact something that would no longer be true.**
### 2. Bot Fight Mode cannot be scoped, on any plan
Bot Fight Mode is free and issues CPU-expensive challenges to traffic matching known bot patterns. Cloudflare's documentation is explicit that it **cannot be bypassed or skipped with WAF custom rules or Page Rules on any plan**, because it does not run on the Ruleset Engine — `Skip`, `Bypass` and `Allow` have no effect on it. Scoped exceptions require Super Bot Fight Mode, which is paid.
That is a problem for server-to-server traffic, which Cloudflare's own documentation warns may be challenged:
- **`POST /webhooks/paypal`** — a challenged webhook is a capture notification we never receive.
- The Gitea Actions runner, if the Gitea host is also proxied.
- Brevo, for anything inbound.
It is all-or-nothing per zone. Enabling it means accepting that risk to the webhook; the safe configuration on the free plan is to leave it **off**, which removes one of the two features that made the free tier interesting in the first place.
JavaScript Detections is switched on automatically alongside it and cannot be disabled, and may interact with Content-Security-Policy headers.
## What it would not help with
**Rate limiting adds nothing for us.** The free plan's allowance is one rule, with expressions limited to Path and Verified Bot, counting by IP only, and both the counting and mitigation periods capped at **10 seconds**. Our own limiters run 15-minute and 1-hour windows, key on email and customer id as well as address, and already cover the intake surface through #227's ceiling. A 10-second window is not a smaller version of that; it is a different tool, and not one this application needs.
**Phishing and brand impersonation are untouched.** As #285 says up front, takedown tooling is a paid upper-tier product. Nothing on the free plan stops someone standing up a copy of the storefront.
## What is genuinely fine
- **Uploads are a non-issue, confirmed rather than assumed.** The free plan's request body cap is 100 MB. `MAX_IMAGE_BYTES` is 8,000,000 bytes across at most 6 images per request, so we are an order of magnitude clear. One caveat: WAF Managed Rules only inspect the **first 1 MB** of a request body on the free plan, so a managed rule cannot meaningfully inspect an image upload. That affects what protection is worth expecting, not whether uploads work.
- **TLS is free and sufficient.** Universal SSL covers the edge certificate. Full (strict) is reachable at no cost provided nginx serves a certificate the edge will validate.
- **Authenticated Origin Pull is available on the free plan** — worth confirming, since it is the one feature that would let nginx refuse anything not arriving through Cloudflare, which is the part of the origin-hiding question that actually has teeth. It needs `ssl_client_certificate` pointing at Cloudflare's `origin-pull-ca.pem` plus `ssl_verify_client on;`, and requires Full SSL mode. Whether Nginx Proxy Manager exposes that through its Advanced config was **not verified** and would need checking before relying on it.
## Recommendation
**Do not adopt.** Not primarily because the useful pieces are paid, but because the free tier cannot be applied to this hostname at all.
If the origin address is judged worth hiding, the ordered prerequisites are:
1. Register a domain we control and move the site onto it — its own issue, with real migration consequences.
2. Only then evaluate Cloudflare, with `trust proxy` corrected **in the same change** as the DNS cutover, or the limiters silently degrade.
3. Leave Bot Fight Mode off, or move the PayPal webhook somewhere it cannot challenge.
4. Decide QA and Gitea deliberately rather than by default, since either left unproxied republishes the address.
Steps 24 are most of the work, and none of it is Cloudflare-specific configuration.
## Not established here
- Whether Nginx Proxy Manager's Advanced config can express `ssl_verify_client on;` in a way that survives its regeneration.
- Whether the NAS's own lookups of the hostname change behaviour once proxied — flagged in #285 and not testable without a live zone.
- What a real Brevo-relayed message's `Received` headers actually contain.