<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sandra Versluisje</title>
    <description>The latest articles on DEV Community by Sandra Versluisje (@sandraversluis).</description>
    <link>https://dev.to/sandraversluis</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3941103%2F84d90cba-96c2-42dc-ab7a-805cf247365e.png</url>
      <title>DEV Community: Sandra Versluisje</title>
      <link>https://dev.to/sandraversluis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sandraversluis"/>
    <language>en</language>
    <item>
      <title>Cloudflare R2 EU jurisdiction storage for UK GDPR: what we learned</title>
      <dc:creator>Sandra Versluisje</dc:creator>
      <pubDate>Tue, 19 May 2026 23:13:13 +0000</pubDate>
      <link>https://dev.to/sandraversluis/cloudflare-r2-eu-jurisdiction-storage-for-uk-gdpr-what-we-learned-2m1f</link>
      <guid>https://dev.to/sandraversluis/cloudflare-r2-eu-jurisdiction-storage-for-uk-gdpr-what-we-learned-2m1f</guid>
      <description>&lt;p&gt;Most UK SaaS founders default to S3 in &lt;code&gt;eu-west-2&lt;/code&gt; and call it GDPR compliance done. It is not.&lt;/p&gt;

&lt;p&gt;S3 in London is operated by AWS Inc, a US entity. The data sits in London. The processor sits in Seattle. Under UK GDPR Chapter V and the post-Schrems II ICO position, that is a transfer to a non-adequate third country unless your DPA includes the latest SCCs and a documented Transfer Impact Assessment. Plenty of UK SMBs get away with it. Plenty also fail compliance audits because of it.&lt;/p&gt;

&lt;p&gt;We built Beamprobe (a UK virtual data room) on Cloudflare R2 with the EU jurisdiction flag instead. After six months in production, here is what worked, what tripped us up, and what I would do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why R2 EU jurisdiction is different from "R2 in Europe"
&lt;/h2&gt;

&lt;p&gt;R2 has a jurisdiction setting at the bucket level. You can set it to EU. When you do, three things change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The bucket is pinned to EU member-state data centres. No transparent replication to North America.&lt;/li&gt;
&lt;li&gt;The bucket lives under Cloudflare's Frankfurt-based entity for billing and processor purposes.&lt;/li&gt;
&lt;li&gt;Cross-border transfer events (egress to non-EU regions) are logged and surfaced in the audit feed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For UK GDPR purposes, the second one is the load-bearing detail. The processor entity matters more than the rack location. Cloudflare's EU jurisdiction puts you on a DPA signed against a Frankfurt entity, with Chapter V transfers handled through their EU subprocessor list rather than a US parent.&lt;/p&gt;

&lt;p&gt;The setting is buried. You pick it when you create the bucket in the Cloudflare dashboard, or you pass &lt;code&gt;jurisdiction: 'eu'&lt;/code&gt; in the S3-compatible API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrangler example&lt;/span&gt;
wrangler r2 bucket create beamprobe-prod &lt;span class="nt"&gt;--jurisdiction&lt;/span&gt; eu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no way to migrate an existing bucket to a different jurisdiction. You create a new one, copy, swap. We learned that the hard way after launching with a default-jurisdiction bucket and realising during a customer audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Phoenix / ExAws setup
&lt;/h2&gt;

&lt;p&gt;We use ExAws against the R2 S3-compatible endpoint. Configuration sits in runtime.exs so the same release binary runs in dev with local-disk storage and in prod with R2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/runtime.exs&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;storage_adapter&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:s3&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;r2_account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"R2_ACCOUNT_ID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"R2_ACCOUNT_ID missing"&lt;/span&gt;
  &lt;span class="n"&gt;r2_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"R2_ACCESS_KEY_ID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"R2_ACCESS_KEY_ID missing"&lt;/span&gt;
  &lt;span class="n"&gt;r2_secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"R2_SECRET_ACCESS_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"R2_SECRET_ACCESS_KEY missing"&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="ss"&gt;:ex_aws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;access_key_id:&lt;/span&gt; &lt;span class="n"&gt;r2_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;secret_access_key:&lt;/span&gt; &lt;span class="n"&gt;r2_secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;region:&lt;/span&gt; &lt;span class="s2"&gt;"auto"&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="ss"&gt;:ex_aws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;scheme:&lt;/span&gt; &lt;span class="s2"&gt;"https://"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;host:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;r2_account&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.eu.r2.cloudflarestorage.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;region:&lt;/span&gt; &lt;span class="s2"&gt;"auto"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two non-obvious bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;region: "auto"&lt;/code&gt;. R2 ignores region but ExAws insists on having one. &lt;code&gt;"auto"&lt;/code&gt; is the canonical value.&lt;/li&gt;
&lt;li&gt;The host has &lt;code&gt;.eu.&lt;/code&gt; baked in for EU-jurisdiction buckets. Default-jurisdiction is &lt;code&gt;&amp;lt;account&amp;gt;.r2.cloudflarestorage.com&lt;/code&gt;. Wrong host on either side returns a confusing 404.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;R2 access keys are scoped per bucket, not per account. Create one set of credentials per environment. Rotate quarterly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encryption at rest: what R2 gives you and what it does not
&lt;/h2&gt;

&lt;p&gt;R2 encrypts at rest using AES-256 with Cloudflare-managed keys. That satisfies Article 32 on its own for most categories of data. For investor-grade documents with NDAs, we add envelope encryption on top:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a 256-bit data encryption key (DEK) per file.&lt;/li&gt;
&lt;li&gt;AES-256-CBC encrypt the file body with the DEK.&lt;/li&gt;
&lt;li&gt;S/MIME-encrypt the DEK under a per-user key, store separately.&lt;/li&gt;
&lt;li&gt;Upload the ciphertext to R2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: even a leaked R2 access key does not give the attacker plaintext. They need our user-key store too. That is two distinct breach paths needed for one disclosure, which is the bar UK GDPR Article 32 "appropriate technical measures" actually expects for special-category data.&lt;/p&gt;

&lt;p&gt;Cloudflare also offers SSE-C (server-side encryption with customer keys). We do not use it because the key never leaves our Phoenix backend in our model; envelope encryption gives us the same guarantee with one less moving part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Presigned URLs without leaking the bucket
&lt;/h2&gt;

&lt;p&gt;R2 presigned URLs work the same as S3 presigned URLs. Time-bound, signed with HMAC-SHA256, single-resource.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;Beamprobe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Storage&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt; &lt;span class="p"&gt;\\&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prefixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;ExAws&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;S3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in:&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to lock down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short TTL&lt;/strong&gt;. We use 600 seconds for viewer page rasters, 60 seconds for downloads. Long TTLs end up in browser history and crash dumps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-object prefix&lt;/strong&gt;. We path-prefix every object as &lt;code&gt;documents/&amp;lt;user_id&amp;gt;/&amp;lt;room_id&amp;gt;/&amp;lt;random&amp;gt;.pdf&lt;/code&gt;. The random suffix is 16 bytes of crypto, base64. Guessing one URL gets you one document, not the bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS off by default&lt;/strong&gt;. R2 buckets default to no CORS. Do not enable it unless a browser actually needs to fetch the object directly. We serve every public asset through a viewer endpoint that signs URLs server-side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lifecycle policies and the egress trap
&lt;/h2&gt;

&lt;p&gt;R2's headline feature is zero egress fees. There is a footnote.&lt;/p&gt;

&lt;p&gt;The "no egress" applies to clients of Cloudflare's network: workers, pages, your own zone. Egress to third-party origins via S3 API call is billed. Cross-region replication is billed. Lifecycle transitions to Glacier-equivalent classes (R2 calls them "Infrequent Access") are billed at the transition.&lt;/p&gt;

&lt;p&gt;If you write a worker or a server that pulls objects out of R2 to a non-Cloudflare endpoint, you pay normal egress. The zero-egress model assumes your serving plane lives inside Cloudflare.&lt;/p&gt;

&lt;p&gt;Our setup: Phoenix on Hetzner Germany. We DO pay egress when Phoenix fetches a document body to decrypt and serve. That is what we are willing to pay for control.&lt;/p&gt;

&lt;p&gt;The lesson: model your egress before you bet your unit economics on "R2 = free egress." For most B2B SaaS workloads this is fine. For media streaming or large-file delivery it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost comparison vs S3 eu-west-2
&lt;/h2&gt;

&lt;p&gt;Real numbers from a single Beamprobe production month (May 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;R2 EU&lt;/th&gt;
&lt;th&gt;S3 eu-west-2 equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage 540 GB&lt;/td&gt;
&lt;td&gt;$8.10&lt;/td&gt;
&lt;td&gt;$12.42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class A operations (writes) 380K&lt;/td&gt;
&lt;td&gt;$1.71&lt;/td&gt;
&lt;td&gt;$1.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class B operations (reads) 4.2M&lt;/td&gt;
&lt;td&gt;$1.51&lt;/td&gt;
&lt;td&gt;$1.68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress to Hetzner 180 GB&lt;/td&gt;
&lt;td&gt;(paid same as S3)&lt;/td&gt;
&lt;td&gt;$16.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monthly total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$32&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For our workload R2 is roughly a third the cost. The math flips if you are serving public CDN-style traffic where R2's free egress to Cloudflare zones wins big.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes we made
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Created bucket without EU jurisdiction&lt;/strong&gt;. Lost a week migrating to an EU-jurisdiction bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did not pin region in the access key&lt;/strong&gt;. Access keys are scoped per bucket, not account. We over-permissioned an early key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored presigned URLs in logs&lt;/strong&gt;. A 1-hour presigned URL is a 1-hour credential. We now redact &lt;code&gt;?X-Amz-Signature&lt;/code&gt; from log lines server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Used default CORS off but did not test&lt;/strong&gt;. Browsers fail silently. Wrap every CORS-bound flow in a Playwright test that hits the actual bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgot to set lifecycle rules for failed multipart uploads&lt;/strong&gt;. R2 charged us for orphaned parts for two months before we noticed. Set the lifecycle rule on day one:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wrangler r2 bucket lifecycle put beamprobe-prod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--abort-multipart-uploads-after-days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick EU jurisdiction at bucket creation, no exceptions.&lt;/li&gt;
&lt;li&gt;Document the processor relationship in the DPA from day one. Cloudflare publishes a UK GDPR-aware DPA at cloudflare.com/legal/data-processing-agreement. Sign it.&lt;/li&gt;
&lt;li&gt;Bake the envelope-encryption pattern into the storage abstraction, not into individual upload handlers. Less risk of forgetting a code path.&lt;/li&gt;
&lt;li&gt;Use Cloudflare Workers as a thin signing layer for presigned URLs if your serving plane is also on Cloudflare. We did not because Phoenix-on-Hetzner is our model. If you are on Workers anyway, do it.&lt;/li&gt;
&lt;li&gt;Run a quarterly access-key rotation drill. Cloudflare makes rotation easy; the human process of updating env vars across dev/staging/prod is the slow bit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When R2 is not the right call
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need single-region S3 endpoint matching for a customer who requires &lt;code&gt;eu-west-2&lt;/code&gt; in their DPA. R2 cannot give you AWS-region semantics.&lt;/li&gt;
&lt;li&gt;You depend on AWS IAM features (resource policies, KMS integration, CloudTrail audit). R2's IAM is simpler. Some compliance auditors prefer the AWS depth.&lt;/li&gt;
&lt;li&gt;You need cross-region replication for disaster recovery. R2 does not yet ship that for EU-jurisdiction buckets at the time of writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything else, R2 EU is faster to set up, cheaper for our workload, and easier to defend in a UK GDPR audit because the processor entity is in Frankfurt rather than Seattle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;ICO Data Sharing Code of Practice (2024 update)&lt;/li&gt;
&lt;li&gt;UK GDPR Chapter V (international transfers)&lt;/li&gt;
&lt;li&gt;Cloudflare R2 jurisdiction documentation&lt;/li&gt;
&lt;li&gt;Schrems II judgement, CJEU C-311/18&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;I write about UK data residency, document security and Elixir/Phoenix at &lt;a href="https://beamprobe.com" rel="noopener noreferrer"&gt;beamprobe.com&lt;/a&gt;. Beamprobe is a UK virtual data room for fundraising teams and M&amp;amp;A advisors. Bootstrapped, built in the UK, used worldwide.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>r2</category>
      <category>gdpr</category>
      <category>elixir</category>
    </item>
  </channel>
</rss>
