Skip to content

Serve previews on their own hostnames

Last updated View as MarkdownAgent setup

Serve each sandbox preview from its own hostname, such as ada.example-previews.com. Code in a preview runs in the visitor's browser, and each preview hostname is a separate origin. Code in one preview cannot read your application pages or the storage of another preview. Paths reach the server unchanged, so applications do not need a base path.

.example-previews.com
A wildcard DNS record and the route *.example-previews.com/* send every preview hostname to your Worker.
ada
Your Worker reads the sandbox name and calls getByName("ada"). A name that is not a valid DNS label gets a 404.
/app/
The path reaches the web server in the container unchanged, with the preview hostname in the Host header.

Prerequisites

  • A Durable Object whose fetch() handler forwards requests to a web server in its container. To build one, refer to Preview a web application.
  • A domain for previews on Cloudflare, such as example-previews.com, with Cloudflare managing its DNS. Use a domain that your application does not use.

Route preview hostnames to your Worker

  1. In the DNS settings of the preview domain, add a proxied wildcard record. For example, add an AAAA record with the name * and the content 100::, with the proxy status Proxied.

    The record lets Cloudflare receive requests for every preview hostname. The Worker route handles those requests, so Cloudflare never contacts the address in the record.

  2. In wrangler.jsonc, add the preview domain as a variable and route its subdomains to your Worker. Replace example-previews.com with your preview domain:

    {
    	"vars": {
    		"PREVIEW_DOMAIN": "example-previews.com",
    	},
    	"routes": [
    		{
    			"pattern": "*.example-previews.com/*",
    			"zone_name": "example-previews.com",
    		},
    	],
    	"workers_dev": true,
    }
    workers_dev = true
    
    [vars]
    PREVIEW_DOMAIN = "example-previews.com"
    
    [[routes]]
    pattern = "*.example-previews.com/*"
    zone_name = "example-previews.com"

    Wrangler turns off the workers.dev URL when routes is set. workers_dev: true keeps it for requests that manage previews.

  3. Generate types for the new variable:

    npx wrangler types
  4. In the fetch() handler of your Worker, send each preview hostname to the Durable Object with the same name:

    src/index.jsjs
    export default {
    	async fetch(request, env) {
    		const url = new URL(request.url);
    		const suffix = `.${env.PREVIEW_DOMAIN}`;
    
    		// A preview hostname reaches only the sandbox with the same name.
    		if (url.hostname.endsWith(suffix)) {
    			const name = url.hostname.slice(0, -suffix.length);
    
    			// Accept only sandbox names that are valid DNS labels
    			if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
    				return new Response("Not found", { status: 404 });
    			}
    
    			// Forward with the `http:` scheme, because `getTcpPort().fetch()`
    			// does not accept `https:` URLs
    			url.protocol = "http:";
    			return env.MY_CONTAINER.getByName(name).fetch(new Request(url, request));
    		}
    
    		// Other hostnames manage previews.
    		return new Response("Not found", { status: 404 });
    	},
    };
    src/index.tsts
    export default {
    	async fetch(request: Request, env: Env): Promise<Response> {
    		const url = new URL(request.url);
    		const suffix = `.${env.PREVIEW_DOMAIN}`;
    
    		// A preview hostname reaches only the sandbox with the same name.
    		if (url.hostname.endsWith(suffix)) {
    			const name = url.hostname.slice(0, -suffix.length);
    
    			// Accept only sandbox names that are valid DNS labels
    			if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
    				return new Response("Not found", { status: 404 });
    			}
    
    			// Forward with the `http:` scheme, because `getTcpPort().fetch()`
    			// does not accept `https:` URLs
    			url.protocol = "http:";
    			return env.MY_CONTAINER.getByName(name).fetch(new Request(url, request));
    		}
    
    		// Other hostnames manage previews.
    		return new Response("Not found", { status: 404 });
    	},
    } satisfies ExportedHandler<Env>;

    The server receives the full path and the preview hostname in the Host header.

    Every request to a preview hostname goes to the preview. Keep routes that manage previews, such as stopping one, on other hostnames, so code in a preview cannot call them from its own origin. Authenticate preview visitors on the preview hostname, and do not send your application session cookie there.

  5. Deploy your Worker:

    npx wrangler deploy
  6. Open the preview named ada in your browser, and store a value in its browser console. Replace example-previews.com with your preview domain:

    https://ada.example-previews.com/
    localStorage.setItem("name", "ada");
  7. Open the preview named grace, and read the value in its browser console:

    https://grace.example-previews.com/
    localStorage.getItem("name");

    The console prints null. Each preview hostname is a separate origin, so grace cannot read the storage of ada.

Serve previews under a subdomain

Universal SSL covers a domain and its first-level subdomains, such as ada.example-previews.com. It does not cover deeper hostnames such as ada.previews.example.com. To serve previews under a subdomain, add Total TLS or an advanced certificate for it.

Previews under a domain that your application also uses share cookies with your application. Browsers send a cookie set with Domain=example.com to every subdomain, including every preview under previews.example.com.

Set cookies on preview hostnames

Code in one preview can set a cookie with Domain=example-previews.com, and every other preview then receives that cookie. Set cookies on preview hostnames without a Domain attribute, and do not trust a cookie because it arrived on a preview hostname.

Was this helpful?