How to Use Custom Domains on Cloudflare

Proxy the JavaScript SDK, source configuration, and events through your own domain with a Cloudflare Worker.

By default, the JavaScript SDK v3 talks to three RudderStack hosts. It loads from cdn.rudderlabs.com, fetches the source configuration from api.rudderstack.com, and sends events to your data plane URL. This guide moves all three behind a domain you control on Cloudflare.

Use Custom Domains covers this setup for AWS CloudFront, with one distribution per host. On Cloudflare, one Worker serves all three hosts from one hostname by routing on the path. SDK paths start with /v3/ or a version number such as /3.23.0/, and event paths start with /v1/, so they don’t collide.

Choose an approach

  • Option A: Proxy through your domain: A Cloudflare Worker sits in front of the three RudderStack hosts, and the site loads everything from your own hostname. The SDK files still come from RudderStack, so new SDK releases reach you without any file management. RudderStack recommends this route, mainly because it reduces the impact of ad blockers and makes cookies first-party.
  • Option B: Self-host the files: You download the SDK, plugins, and device mode integrations and store them yourself, for example in an R2 bucket on a custom domain. RudderStack recommends this only for a zero-gap architecture, where no data goes out to any external service. You also take on upgrades. Source configuration and events still need the proxy from option A, because those are API calls rather than static files.
If the goal is ad blocker resistance and first-party cookies, option A is enough. Choose option B only if you have a hard requirement to keep requests off cdn.rudderlabs.com.

Note the following:

  • Ad blockers match RudderStack’s host names, not the SDK’s file names or paths.
  • AdGuard’s Tracking Protection filter and the EasyPrivacy list, which uBlock Origin and Brave use by default, block cdn.rudderlabs.com when another site loads it, and block event requests such as /v1/track on rudderstack.com hosts. Those lists don’t match rsa.min.js, /v1/page, or /rsaRequest on other hosts.
  • In tests with AdGuard in Safari, the page that used the default hosts loaded nothing, and the same page proxied through its own hostname loaded the SDK and delivered events. That result holds only when all three hosts go through the proxy.
  • Proxying the SDK files alone still loses tracked events.
  • The lists already name some individual data plane hosts and could add a proxy hostname the same way, so the proxy reduces blocking rather than preventing it.

Prepare the setup

  • Confirm the domain uses Cloudflare DNS, and that you can create Workers. For option B, confirm you can create R2 buckets.
  • Choose a hostname for the proxy, for example rs.example.com. If you plan to use server-side cookies, put it on the same registrable domain as the website.
  • In the RudderStack dashboard, copy the source write key and the data plane URL shown at the top of the Connections page.
  • Note the SDK version. For option B, pin an exact version from the npm versions page.
  • Test on a staging site or hostname before production.
  • If the website sets a Content Security Policy, keep it available. You update it in Step 5: Update the Content Security Policy.
  • Confirm which sites and sources use this setup, and whether they share a registrable domain with the proxy hostname.
  • Confirm whether you install the SDK with the CDN snippet or with npm.
  • Confirm whether you need server-side cookies.
  • Confirm which device mode destinations are connected. You check their integration files in Verify the setup.

Proxy through your domain

Send all of the following through one hostname:

RequestOriginRequirements
SDK filescdn.rudderlabs.comCore SDK, plugins, and device mode integrations. Proxy the whole host, not individual files. The SDK rewrites /v3 in its paths to its current version, so both /v3/modern/plugins/... and /3.x.y/modern/plugins/... have to resolve.
Source configurationapi.rudderstack.comThe SDK sends a GET request to /sourceConfig and sends the write key in the Authorization header. Authorization and Origin must reach RudderStack unchanged.
EventsYour data plane URLPaths start with /v1/ (track, page, identify, and so on). Don’t cache these requests. Forward Origin and Content-Encoding.
Server-side cookies (optional)Data plane, same as eventsThe /rsaRequest path. The data plane handles it.
Error reportsData planeThe /rsaMetrics path.

Step 1: Create the Worker

In the Cloudflare dashboard, go to Workers & Pages and create a Worker, or create one with Wrangler. Use the code below and set DATA_PLANE_ORIGIN to your data plane URL. Treat it as a starting point and test it on staging first.

javascript
// Cloudflare Worker: first-party proxy for the RudderStack JavaScript SDK (v3)
const CDN_ORIGIN = "https://cdn.rudderlabs.com";
const CONFIG_ORIGIN = "https://api.rudderstack.com";
const DATA_PLANE_ORIGIN = "https://<YOUR_DATA_PLANE_HOST>"; // Connections page in the RudderStack dashboard

export default {
  async fetch(request) {
    const url = new URL(request.url);
    let origin = DATA_PLANE_ORIGIN; // events, /rsaRequest
    let init = {};

    if (/^\/(v3|\d+\.\d+\.\d+)\//.test(url.pathname)) {
      origin = CDN_ORIGIN; // SDK, plugins, device mode integrations
      init = { cf: { cacheTtlByStatus: { "200-299": 300, "300-599": -1 } } }; // errors are never cached
    } else if (url.pathname.startsWith("/sourceConfig")) {
      origin = CONFIG_ORIGIN; // write key arrives in the Authorization header
    }

    const upstream = new Request(origin + url.pathname + url.search, request);
    upstream.headers.set("X-Forwarded-For", request.headers.get("CF-Connecting-IP"));
    if (origin !== DATA_PLANE_ORIGIN) upstream.headers.delete("Cookie"); // keep the site's cookies first-party
    return fetch(upstream, init);
  }
};

SDK requests are cached for five minutes. When the custom domain isn’t on CloudFront, set the maximum TTL to 300 seconds. See TTL settings. Error responses aren’t cached, so a file that is briefly missing from the CDN doesn’t stay broken for five minutes. Source configuration and events are never cached.

The Worker removes the browser’s cookies from SDK and source configuration requests, so cookies the website sets for its whole domain, such as login cookies, aren’t sent to RudderStack.

The data plane and the config API send their own CORS headers. The SDK files need CORS too. The modern build loads its plugins as JavaScript modules, which browsers fetch with CORS. The Worker passes on the Access-Control-Allow-Origin: * header that cdn.rudderlabs.com sends, so you don’t add CORS headers in the Worker.

Step 2: Attach a custom domain

  1. Open the Worker, then go to Settings > Domains & Routes > Add > Custom Domain.
  2. Enter the hostname, for example rs.example.com. It has to belong to a zone in your Cloudflare account. A custom domain sends every path on that hostname to the Worker and doesn’t support wildcard DNS records.
  3. Wait for the DNS record and certificate, then open https://rs.example.com/v3/modern/rsa.min.js in a browser. The SDK file loads.

Step 3: Update the SDK snippet

Copy the snippet from the Setup tab of the JavaScript source in the RudderStack dashboard and change three values: the SDK base URL, the data plane URL, and the config URL. See How to Install the JavaScript SDK for the snippet, and Loading options for each load option.

javascript
var sdkBaseUrl = "https://rs.example.com";

rudderanalytics.load("<WRITE_KEY>", "https://rs.example.com", {
  configUrl: "https://rs.example.com"
});

The default snippet sets sdkBaseUrl to https://cdn.rudderlabs.com. The SDK adds /sourceConfig to configUrl on its own.

If you install the SDK from npm, or the files aren’t at the default paths, also set pluginsSDKBaseURL and destSDKBaseURL:

javascript
rudderanalytics.load("<WRITE_KEY>", "https://rs.example.com", {
  configUrl: "https://rs.example.com",
  pluginsSDKBaseURL: "https://rs.example.com/v3/modern/plugins",
  destSDKBaseURL: "https://rs.example.com/v3/modern/js-integrations"
});

Step 4: Turn on server-side cookies

Server-side cookies keep the SDK’s cookies from being shortened by Safari’s tracking prevention. This step is optional. See How to Use Server-side Cookies in JavaScript SDK v3.

Turn them on with useServerSideCookies: true in the load options. The SDK then sends a POST request to /rsaRequest, and the Worker forwards it to the data plane. By default the request goes to the root of the website’s domain, for example https://example.com/rsaRequest for a page on www.example.com.

To handle these requests, add a Worker route for example.com/rsaRequest* (the root domain has to be proxied through Cloudflare) or set dataServiceEndpoint to https://rs.example.com/rsaRequest (v3.34.0 or later).

Use the dataServiceEndpoint option. In tests, Safari kept these cookies for a year when the website and rs.example.com were both on Cloudflare. The 7-day Safari storage limit applies only when you use the dataServiceEndpoint option on a domain that isn’t on Cloudflare.
  • The proxy hostname has to resolve inside your own DNS zone. If it resolves through a CNAME to a different registrable domain, Safari limits these cookies to seven days.
  • Origin must reach the data plane, otherwise the data plane rejects the requests. The Worker above forwards it.
  • One response carries several Set-Cookie headers. Anything that merges them drops all but one cookie without an error.
  • If the request fails, the SDK sets the cookies in the browser instead and logs an error in the console. Events still arrive, so check that /rsaRequest returns 200 in the Network tab rather than relying on events.

After you deploy, check that each cookie comes back as its own Set-Cookie header. Replace <your_host> with example.com for the route option, or with rs.example.com for the dataServiceEndpoint option:

bash
curl -i -X POST 'https://<your_host>/rsaRequest' \
  -H 'Content-Type: application/json' \
  --data '{
    "reqType": "setCookies",
    "workspaceId": "<your_workspace_id>",
    "data": {
      "options": { "maxAge": 31536000000, "path": "/", "sameSite": "Lax", "secure": true },
      "cookies": [
        { "name": "rl_anonymous_id", "value": "test-anonymous-id" },
        { "name": "rl_user_id", "value": "test-user-id" }
      ]
    }
  }'

The response must contain two separate Set-Cookie headers. A single combined header, or only the last cookie, means something between the browser and the data plane merged or dropped them.

Step 5: Update the Content Security Policy

If the site sets a Content Security Policy, allow the new hostname in script-src and connect-src. Remove cdn.rudderlabs.com, api.rudderstack.com, and the old data plane host once nothing else uses them. Device mode destinations can still need their own vendor domains, so check the destination’s page before you remove entries. If script-src doesn’t allow 'unsafe-eval', the loading snippet’s browser check fails and it loads the legacy build. The SDK still works, but the legacy build is larger.

Self-host the files

Use these steps in place of the SDK file route in Proxy through your domain. Keep the Worker for source configuration and events. The SDK routes in the Worker go unused.

Self-host JavaScript SDK in Your CDN describes the same directory layout. The Cloudflare steps below cover an R2 bucket.

Set the directory structure

RudderStack recommends the layout used on its own CDN. There are two folders under a base directory:

  • v3 holds the core SDK files rsa.min.js and rsa.min.js.map, in modern and legacy subfolders.
  • 3.x.y, named after the exact SDK version, holds the device mode integrations in js-integrations and the plugins in plugins. Plugins exist only for the modern bundle, because the legacy bundle has them built in.

Keep the core file and the version folder on the same SDK version. The core file looks for its plugins and integrations in the folder named after its own version.

Step 1: Create the bucket and connect a domain

  1. In Cloudflare, go to R2 and create a bucket.
  2. Open the bucket, then go to Settings > Public access > Custom Domains > Connect Domain, and enter a hostname such as sdk.example.com. Cloudflare adds the DNS record.
Connecting a custom domain makes the bucket public, so keep only the SDK files in it.
  1. Add a CORS policy before the site starts using the bucket. In the bucket’s Settings, under CORS Policy, allow GET from the website’s origin:
json
[{"AllowedOrigins": ["https://www.example.com"], "AllowedMethods": ["GET"]}]
The modern build loads its plugins as JavaScript modules, which browsers fetch with CORS. Without this policy, no plugins load and the SDK sends no events. Files requested before the policy existed stay cached without the CORS header until you purge the cache.

Step 2: Download the files

Pick the SDK version and use it in every URL. Replace 3.x.y below:

https://cdn.rudderlabs.com/3.x.y/modern/rsa.min.js
https://cdn.rudderlabs.com/3.x.y/modern/rsa.min.js.map
https://cdn.rudderlabs.com/3.x.y/modern/js-integrations/list.html
https://cdn.rudderlabs.com/3.x.y/modern/plugins/list.html

The v3 alias always points at the latest release, so an explicit version keeps the core file and the folders matched. The two list.html pages list the integration and plugin files and their source maps. RudderStack recommends downloading all of them so troubleshooting stays possible. Repeat with legacy in place of modern if you need to support older browsers. The legacy bundle has no plugins folder. The modern plugins and js-integrations folders also each have an archive of all their files, all_plugins.tar.gz and all_integration_sdks.tar.gz, if you prefer one download per folder.

Step 3: Upload the files to the bucket

Upload with the dashboard, Wrangler, or any S3-compatible client, and keep this layout.

Check that .js files are served with a JavaScript content type. Browsers refuse to run plugins served without one, and the SDK then sends no events. wrangler r2 object put sets no content type unless you pass --content-type application/javascript, so check a plugin URL with curl -I after uploading. Uploads through the R2 dashboard get application/x-javascript, which browsers accept.
<base>/v3/modern/rsa.min.js
<base>/v3/modern/rsa.min.js.map
<base>/3.x.y/modern/js-integrations/<destination>.min.js
<base>/3.x.y/modern/plugins/<files from plugins/list.html>

Step 4: Update the loading snippet

Set sdkBaseUrl to your base path. The snippet adds /v3/ itself, so leave it out:

javascript
var sdkBaseUrl = "https://sdk.example.com/<base-directory-path>";

If the core file isn’t called rsa.min.js, or its folder isn’t v3, also set sdkFileName and sdkVersion:

javascript
var sdkBaseUrl = "https://sdk.example.com/rs-js-sdk-artifacts";
var sdkVersion = "custom-version-id";
var sdkFileName = "customsdk.min.js";

With the recommended structure, the SDK finds integrations and plugins on its own. Otherwise, point to them with destSDKBaseURL and pluginsSDKBaseURL, as in Step 3: Update the SDK snippet. Set configUrl and the data plane URL to the Worker hostname in the same way.

Review requests that still leave your domain

Two defaults can still reach other hosts. That matters if avoiding them is the reason you chose option B.

  • Polyfills: polyfillIfRequired defaults to true, and polyfillURL defaults to a polyfill-fastly.io address. The loading snippet also loads a script from polyfill-fastly.io on old browsers. Change that URL in the snippet too. Set polyfillIfRequired to false, or point polyfillURL at a copy you host. Whether you can drop polyfills depends on the browsers you support. See Loading options.
  • Device mode destinations: A destination can load a vendor script from the vendor’s domain. The destination’s page names any domain to allowlist.

Upgrade the SDK

Nothing updates on its own with option B. To move to a new version, download that version’s files (Step 2: Download the files) into a new 3.x.y folder and replace the core file under v3.

Keep the previous version folder until you confirm the new one works, and for at least the browser cache time on the bucket’s domain (four hours by default). Browsers keep the old core file until it expires.

Verify the setup

  1. Open the site with the browser’s Network tab open. The SDK file, the /sourceConfig request, and the track and page calls should all go to your hostname. Nothing should go to cdn.rudderlabs.com, api.rudderstack.com, or the old data plane URL.
  2. Install the RudderStack Assistant browser extension. Its Custom domains section shows whether the data plane URL and plugins base URL are standard or custom.
  3. Send a test event and confirm it appears in the source’s live events in the RudderStack dashboard. See Debug Destination Live Events. Check request_ip on the test event. It must show the client IP, not a Cloudflare IP.
  4. Test with an ad blocker turned on, and in Safari. These are the main reasons for the setup.
  5. Confirm that the integration file for each connected device mode destination returns a 200 response.
  6. If server-side cookies are on, run the curl check from Step 4: Turn on server-side cookies.

Troubleshooting

Issue
Solution
CORS errorsThese usually come from a misconfigured CDN, not an SDK defect. See the FAQ on Use Custom Domains. Confirm that OPTIONS requests are answered, Origin is forwarded, and CORS headers are present on source configuration and event responses.
404 on a plugin or integration fileOnly some paths are proxied, or the version folder is missing (option B). The failing URL contains the exact SDK version, so compare it with what you stored.
Option B loads no plugins and sends no eventsThe console shows a CORS or MIME type error on a plugin file. Add the bucket’s CORS policy (Step 1: Create the bucket and connect a domain) and serve .js files with a JavaScript content type (Step 3: Upload the files to the bucket).
Source configuration fails to loadCheck that the Authorization header reaches api.rudderstack.com. The write key travels in it.
No events arriveConfirm the data plane URL in the Worker matches the dashboard and that event requests aren’t cached.
An old SDK version keeps loadingSDK responses are cached for up to five minutes in this setup. Purge the cache in Cloudflare if you need the change sooner.
RudderEncrypt parse error in SafariUpgrade to the latest v3. Proxying the SDK alone doesn’t fix it. If it continues on Safari or iOS, proxy all three hosts and see the JavaScript SDK FAQ.
Server-side cookies aren’t setLook for a missing Origin header, merged Set-Cookie headers, a cached response, a root domain that isn’t proxied through Cloudflare (route option), or a hostname that resolves through a CNAME on another domain.

See more

RudderStack documentation

Cloudflare documentation

Questions? Let's figure it out together.

Join the RudderStack Slack community to connect with other users, customers, and the RudderStack team — or reach out for direct support.