Skip to content
Law and data9 min read

Russian data localisation on a static site: where the boundary actually goes

The statute is about databases, not about where HTML is served from. Hence: prerendered pages with no endpoint that accepts input, the receiver and the database in Russia, a fixed order of operations inside the receiver, and why a failed step must not answer {ok:true}.

Most of what is written about Russian data-localisation law and websites reduces to one claim, and the claim is wrong: "the site has to be hosted in Russia". The statute does not say that. 152-ФЗ, ст. 18 ч. 5 requires the operator to ensure that recording, systematisation, accumulation, storage, updating and retrieval of personal data of Russian citizens is carried out using databases located in Russia. It is a rule about databases and the operations performed on them, not about where a browser fetches HTML from.

The distinction is load-bearing. It means the boundary between "may live abroad" and "must live in Russia" does not run along a domain or a project. It runs through one specific request: the one in which personal data first exists. Everything before that boundary is static, and it does not care which CDN serves it. Everything after it is a Russian server, with no exceptions.

A caveat before the engineering: this is an engineer's reading of the rule, not legal advice. Architecture settles the technical half. It does not settle the regulator filing, the hosting contract, or the wording of the privacy notice.

What a marketing page actually does

Nothing that meets the definition of processing. It emits bytes. No form means no data subject and no operation. Which gives the first architectural consequence, and it is worth stating precisely. The right phrasing is not "zero serverless functions" but zero endpoints that accept a request body. Every page is prerendered at build time; the frontend contains no route handler and no server action. The site itself builds as output: "standalone" — a Node process behind Caddy that serves finished HTML, sets headers and redirects, and cannot take data in.

The difference between the two phrasings is not pedantry. "There are no functions" is a property of a config file, and it breaks the first day something has to be generated on demand. "There is exactly one endpoint that accepts input, and it is in Russia" is a property visible in the CSP and in CORS, it survives refactoring, and it can be checked from outside. A prohibition is worth exactly as much as it is verifiable: an endpoint that can accept data eventually will, usually via whoever adds a form a year from now and does not read this comment first.

The second consequence concerns the proxy. proxy.ts performs no geo-IP redirect, never touches a request body, and logs nothing; its single job is to remember the language a visitor chose in the switcher. The geo redirect was not removed to save on compute: inferring a language from an address means deriving an attribute of a person from their IP for a purpose they never agreed to. Language here is an explicit action, and the default is Russian.

Why the form cannot post to its own domain

The natural implementation is a route handler on the same origin that accepts JSON and forwards it on. That is precisely the thing not to build. The request body materialises in a process on foreign soil; the platform sees it, and the platform's logs and traces follow their own rules in their own geography. "The data only passed through" is not a defence — a recording happened.

So the form posts cross-origin, directly to the receiver in Russia. The boundary is visible in exactly two files and nowhere else: NEXT_PUBLIC_API_URL in .env.example and connect-src in the CSP in next.config.ts. If a third destination ever appears, the browser blocks it — not a reviewer.

The cost is real and worth stating: cross-origin means CORS with a preflight, and it means you cannot set an httpOnly cookie on the site's own domain from the receiver. In exchange there is no point in the frontend — wherever it happens to run — at which personal data could be present. That is a good trade. CORS is debugged once; "there may be leads sitting in a platform log somewhere" is never debugged at all.

One case deserves its own paragraph: the frontend and the receiver on the same machine in Russia, which is how this site runs. The temptation is obvious — both sides are already in the country, so why draw the boundary at all when the form could post to its own origin. Draw it anyway. Jurisdiction is a property of where something is deployed, not of how it is built: a site moves to a foreign CDN in an evening, and the route handler moves with it, having by then been accepting personal data for a year. Drawn in advance, the boundary costs one preflight request. Drawn in hindsight, it costs a year of log review and a question with no good answer.

What "primary write to the Russian database" means in practice

It means an order of operations inside the receiver:

1. validate the payload   → 400, nothing stored
2. verify the captcha     → 403, nothing stored
3. INSERT + COMMIT in RF  → 500, the visitor is told it did not send
4. notify the operator    → 200 either way, the failure goes to the log

What matters is that step 4 follows step 3 and not the reverse. A notification — an email, a message, anything — is a transfer. It needs its own basis and its own line in the privacy notice. Notify first and you have created a copy outside the primary database that may have no primary record in Russia at all. The ordering is not an optimisation; it is the requirement restated as control flow: the Russian write first, everything else derived from it.

The same logic extends past ordering to the input schema. If the receiver will accept a field that the privacy notice does not describe, it collects data it documents itself as not collecting. The input shape has to be a closed list, not "the fields I know about, plus whatever arrives".

Why the captcha may not be optional

The argument is not about spam. An open POST endpoint accumulates junk, and sooner or later that junk contains real addresses belonging to real people — scrapers harvest them and paste them wherever a form will take them. Legally those are personal data that you now store and must be able to erase, for a person who has never contacted you and has never heard of you. In that framing a captcha is not form protection. It is minimisation of what you accept in the first place.

Then, which captcha. reCAPTCHA sends the visitor's IP to Google, reproducing the exact defect that got Google Analytics removed from this project. That leaves Yandex SmartCaptcha: the client key is public and sits in .env.example, the server key never leaves the VPS. The default behaviour is fail-closed — if the key is unset the form still renders and the receiver rejects the submission. The friendlier-looking inverse ("no key configured, let it through") is an open endpoint in production.

Why {ok:true} after a failure is a lie

Forms often answer with success on the grounds that the visitor does not care. Here the visitor does care: they pressed a button underneath a sentence about consenting to processing. If no record was written, the honest answer is that it did not send.

But there are two different failures and they must not share a branch. The INSERT failed: no data exists, answer 5xx, let the form offer a retry. The INSERT committed and the notification did not: from the visitor's point of view the job is done, the record exists, and asking them to press again produces a second one. Answer 200 and put the failure in the operator's log. One branch for both cases buys you either a silently lost enquiry or a duplicate in the database — and that is not a choice to make by accident.

Erasability as an architectural test

The right to erasure stress-tests an architecture better than any checklist. A lead in Postgres is one statement away from gone. The same lead, fanned out to three inboxes, a chat channel and somebody's spreadsheet export, is never gone, because you no longer know where it is. Hence a rule that reads like asceticism and is actually a requirement: keep the number of sinks small and enumerate every one of them. Each new notification channel subtracts from your ability to discharge an obligation you already have.

Analytics

There is no Google Analytics in this project and there will not be: the tag ships a visitor identifier out of the country. Yandex Metrica is permissible, but only after explicit consent — the counter loads after the click, not before it. A banner that installs the script and then asks does not obtain consent, it simulates it. The CSP shows the shape of this directly: mc.yandex.ru is present in script-src, and the script itself only enters the document once the visitor has decided.

What architecture does not solve

It does not replace the regulator filing, it does not replace a contract with a Russian host, and it does not write the privacy notice. What it does is reduce the question "where does personal data live" to a single line in .env.example that you can point at. That is the case where drawing the boundary correctly is cheaper than reconstructing afterwards who saw what.