# EU hosting and its limits

The repository's architectural decision, rationale and consequences.

# EU hosting and its limits

**Status:** accepted · **Date:** 2026-11-06 · **Related:** [ADR-0009](0009-row-level-security.md), [ADR-0015](0015-household-data-lives-on-the-device.md)

**Enforcement:** `scripts/check-production-config.mjs` (Worker placement, R2 jurisdiction and the database region are part of the production config check).

The ARD asks for hosting in Germany with isolated workers (RNF08). The template runs on Cloudflare Workers, Neon and R2. We must say what that does and does not guarantee.

## Decision [#decision]

1. **Database: Neon in Frankfurt (`aws-eu-central-1`).** All persistent server rows live there.
2. **Workers: a placement hint toward the EU** (smart placement with a region hint close to the database).
3. **R2: a bucket with `jurisdiction=eu`**, used only for end-to-end encrypted family-sync blobs. Vault documents never go there ([ADR-0015](0015-household-data-lives-on-the-device.md)).

## What this does not guarantee [#what-this-does-not-guarantee]

Cloudflare's Data Localization Suite (Regional Services, Customer Metadata Boundary) is Enterprise-only. Without it:

- **Worker code may run outside the EU.** A placement hint is a latency hint, not a legal boundary. Requests are terminated at the nearest edge location, wherever that is.
- **Data at rest stays in the EU:** Postgres rows in Frankfurt, R2 objects in the EU jurisdiction.
- **Data in transit may cross borders** while a Worker handles it. For household traffic this is almost nothing, because the app sends no household data. What passes through is ciphertext (family sync), municipality user requests and opt-in aggregates.

## Anonymous opt-in: what the caps do not stop [#anonymous-opt-in-what-the-caps-do-not-stop]

The municipality opt-in has no account (ADR-0020), so the only handle on a caller is the address the edge vouches for. The API caps it at 20 calls per address per hour and 3 new opt-ins per source per day (T41). The source key is SHA-256 of the server secret, the UTC day and the address: a salt that rotates daily, so the per-source key holds no address and one day's key cannot be linked to the next. The hourly per-address key (`anon:<limit>:<ip>`) does hold the address until cleanup removes it after 24 h idle; see ADR-0022. Faking the 19 homes that would make a single real home visible behind the k ≥ 20 threshold then takes 7 sources in a day. An attacker with many addresses still can. Device attestation (App Attest / Play Integrity) is the next step if a municipality needs more; it is not decided here.

## Trade-off [#trade-off]

Enterprise buys a contractual boundary at a price that does not fit a pre-launch product. We accept the gap because the sensitive data is on the device or encrypted before it leaves, and we state it in the privacy notice and the municipality DPA instead of claiming "hosted in Germany". If a municipality requires processing inside the EU, the answer is an Enterprise upgrade or moving the API to an EU-only host; neither is decided here.

## Consequences [#consequences]

- Marketing and legal copy say "data stored in the EU", never "processed only in Germany".
- Subprocessors (Cloudflare, Neon, others) are listed as configuration, with `[TBD]` entries tracked in `docs/STATUS.md`.

