|
| 1 | +--- |
| 2 | +title: Reverse proxy setup |
| 3 | +description: Configure a reverse proxy to serve Fern docs from a subpath on your domain, with provider-specific instructions for routing and caching. |
| 4 | +--- |
| 5 | + |
| 6 | +When you host Fern docs on a [subpath](/learn/docs/preview-publish/setting-up-your-domain) like `mydomain.com/docs`, your infrastructure must proxy requests from that path to Fern's origin. Subdomain setups (`docs.mydomain.com`) use a CNAME record instead and don't require a reverse proxy. |
| 7 | + |
| 8 | +## How it works |
| 9 | + |
| 10 | +A working reverse proxy does two things: |
| 11 | + |
| 12 | +1. **Routes requests** from your subpath to Fern's origin at `app.buildwithfern.com`, preserving the original path. The `x-fern-host` header tells Fern which docs site to serve. |
| 13 | +2. **Disables caching** of HTML responses so visitors always receive the current deployment. |
| 14 | + |
| 15 | +Every proxied request needs two headers: |
| 16 | + |
| 17 | +| Header | Value | Purpose | |
| 18 | +|---|---|---| |
| 19 | +| `x-fern-host` | Your bare domain without the subpath (e.g. `mydomain.com`, not `mydomain.com/docs`) | Tells Fern which docs site to serve | |
| 20 | +| `Host` | `app.buildwithfern.com` | Routes the request to Fern's origin | |
| 21 | + |
| 22 | +Fern sets `Cache-Control: public, max-age=0, must-revalidate` on HTML responses, which most providers respect by default. If yours overrides origin cache headers or applies its own time-to-live, explicitly disable caching for the proxied path. |
| 23 | + |
| 24 | +<Warning> |
| 25 | +Don't cache HTML responses from Fern. Cached HTML references JavaScript and CSS from older deployments — those files no longer exist, so pages fail to load. Static assets (`/_next/static/*`) are served directly by Fern's CDN and don't pass through your proxy. |
| 26 | +</Warning> |
| 27 | + |
| 28 | +## Set up your provider |
| 29 | + |
| 30 | + |
| 31 | +<AccordionGroup> |
| 32 | +<Accordion title="Cloudflare Workers"> |
| 33 | + |
| 34 | +<Steps> |
| 35 | +<Step title="Create the Worker"> |
| 36 | + |
| 37 | +Create a [Cloudflare Worker](https://developers.cloudflare.com/workers/) that intercepts requests to your docs subpath and proxies them to Fern: |
| 38 | + |
| 39 | +```js |
| 40 | +const SUBPATH = "/docs"; |
| 41 | +const FERN_HOST = "mydomain.com"; |
| 42 | + |
| 43 | +export default { |
| 44 | + async fetch(request) { |
| 45 | + const url = new URL(request.url); |
| 46 | + |
| 47 | + if ( |
| 48 | + url.pathname !== SUBPATH && |
| 49 | + !url.pathname.startsWith(`${SUBPATH}/`) |
| 50 | + ) { |
| 51 | + return new Response("Not found", { status: 404 }); |
| 52 | + } |
| 53 | + |
| 54 | + const upstreamUrl = new URL(request.url); |
| 55 | + upstreamUrl.protocol = "https:"; |
| 56 | + upstreamUrl.hostname = "app.buildwithfern.com"; |
| 57 | + upstreamUrl.port = ""; |
| 58 | + |
| 59 | + const proxiedRequest = new Request(upstreamUrl, request); |
| 60 | + proxiedRequest.headers.set("x-fern-host", FERN_HOST); |
| 61 | + |
| 62 | + return fetch(proxiedRequest); |
| 63 | + }, |
| 64 | +}; |
| 65 | +``` |
| 66 | + |
| 67 | +Replace `SUBPATH` and `FERN_HOST` with your subpath and bare domain. |
| 68 | +</Step> |
| 69 | +<Step title="Attach the Worker to a route"> |
| 70 | + |
| 71 | +In the Cloudflare dashboard under **Workers Routes**, attach the Worker to a route pattern like `mydomain.com/docs/*`. |
| 72 | +</Step> |
| 73 | +<Step title="Check for conflicting cache rules"> |
| 74 | + |
| 75 | +The Worker forwards Fern's `Cache-Control` header, so caching works automatically. In the Cloudflare dashboard, confirm that no Page Rule or Cache Rule sets "Cache Level: Cache Everything" for `/docs*` — if one exists, remove it or override it with "Cache Level: Bypass." |
| 76 | +</Step> |
| 77 | +</Steps> |
| 78 | +</Accordion> |
| 79 | + |
| 80 | +<Accordion title="AWS CloudFront"> |
| 81 | + |
| 82 | +<Steps> |
| 83 | +<Step title="Add a Fern origin"> |
| 84 | + |
| 85 | +Add an origin to your CloudFront distribution pointing to `app.buildwithfern.com` (HTTPS only, port 443). Add a custom origin header: |
| 86 | + |
| 87 | +| Header name | Value | |
| 88 | +|---|---| |
| 89 | +| `x-fern-host` | `mydomain.com` | |
| 90 | + |
| 91 | +CloudFront automatically sets the `Host` header to the origin domain. |
| 92 | +</Step> |
| 93 | +<Step title="Create a cache behavior for `/docs*`"> |
| 94 | + |
| 95 | +Set the behavior to: |
| 96 | + |
| 97 | +- **Origin**: the Fern origin you just created |
| 98 | +- **Cache policy**: `CachingDisabled` (AWS managed policy) |
| 99 | +- **Origin request policy**: `AllViewer` (forwards all headers, query strings, and cookies) |
| 100 | + |
| 101 | +To use a custom cache policy instead of `CachingDisabled`, set min, max, and default TTL to `0` and forward `Host` and `x-fern-host`. |
| 102 | +</Step> |
| 103 | +</Steps> |
| 104 | + |
| 105 | +<Warning> |
| 106 | +CloudFront ignores `CDN-Cache-Control` and `Surrogate-Control` — only the standard `Cache-Control` header is read. A custom cache policy with a non-zero default TTL caches responses regardless of Fern's `Cache-Control: max-age=0` directive. |
| 107 | +</Warning> |
| 108 | +</Accordion> |
| 109 | + |
| 110 | +<Accordion title="Netlify"> |
| 111 | + |
| 112 | +<Steps> |
| 113 | +<Step title="Add a rewrite rule"> |
| 114 | + |
| 115 | +In `netlify.toml` (or `_redirects`): |
| 116 | + |
| 117 | +```toml netlify.toml |
| 118 | +[[redirects]] |
| 119 | + from = "/docs/*" |
| 120 | + to = "https://app.buildwithfern.com/docs/:splat" |
| 121 | + status = 200 |
| 122 | + force = true |
| 123 | + |
| 124 | + [redirects.headers] |
| 125 | + x-fern-host = "mydomain.com" |
| 126 | +``` |
| 127 | +</Step> |
| 128 | +</Steps> |
| 129 | + |
| 130 | +<Note> |
| 131 | +Netlify rewrites with `status = 200` act as a reverse proxy — the visitor's browser sees your domain while the content comes from Fern. Netlify respects the origin's `Cache-Control` header, so no extra caching configuration is needed. |
| 132 | +</Note> |
| 133 | +</Accordion> |
| 134 | + |
| 135 | +<Accordion title="Vercel"> |
| 136 | + |
| 137 | +<Steps> |
| 138 | +<Step title="Add a route with a transform"> |
| 139 | + |
| 140 | +Use a [route with a transform rule](https://vercel.com/changelog/transform-rules-are-now-available-in-vercel-json) to rewrite the path and set `x-fern-host` in one step: |
| 141 | + |
| 142 | +```json vercel.json |
| 143 | +{ |
| 144 | + "$schema": "https://openapi.vercel.sh/vercel.json", |
| 145 | + "routes": [ |
| 146 | + { |
| 147 | + "src": "/docs(/.*)?", |
| 148 | + "dest": "https://app.buildwithfern.com/docs$1", |
| 149 | + "transforms": [ |
| 150 | + { |
| 151 | + "type": "request.headers", |
| 152 | + "op": "set", |
| 153 | + "target": { "key": "x-fern-host" }, |
| 154 | + "args": "mydomain.com" |
| 155 | + } |
| 156 | + ] |
| 157 | + } |
| 158 | + ] |
| 159 | +} |
| 160 | +``` |
| 161 | +</Step> |
| 162 | +</Steps> |
| 163 | + |
| 164 | +<Note> |
| 165 | +Vercel respects the origin's `Cache-Control` header for rewrite destinations, so no extra caching configuration is needed. |
| 166 | +</Note> |
| 167 | +</Accordion> |
| 168 | + |
| 169 | +<Accordion title="Nginx"> |
| 170 | + |
| 171 | +<Steps> |
| 172 | +<Step title="Add a `location` block"> |
| 173 | + |
| 174 | +```nginx |
| 175 | +location /docs { |
| 176 | + proxy_pass https://app.buildwithfern.com; |
| 177 | + proxy_set_header Host app.buildwithfern.com; |
| 178 | + proxy_set_header x-fern-host mydomain.com; |
| 179 | + proxy_set_header X-Real-IP $remote_addr; |
| 180 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 181 | + proxy_set_header X-Forwarded-Proto $scheme; |
| 182 | + proxy_ssl_server_name on; |
| 183 | +
|
| 184 | + # Prevent encoding issues with Brotli responses |
| 185 | + proxy_set_header Accept-Encoding "gzip, deflate"; |
| 186 | +
|
| 187 | + # Do not cache HTML |
| 188 | + proxy_no_cache 1; |
| 189 | + proxy_cache_bypass 1; |
| 190 | + add_header Cache-Control "public, max-age=0, must-revalidate" always; |
| 191 | +} |
| 192 | +``` |
| 193 | +</Step> |
| 194 | +</Steps> |
| 195 | + |
| 196 | +<Warning> |
| 197 | +Nginx doesn't natively support [Brotli](https://github.com/google/brotli) decompression. The `Accept-Encoding` override above prevents HTTP/2 transfer errors caused by Fern's default Brotli-compressed responses. |
| 198 | +</Warning> |
| 199 | +</Accordion> |
| 200 | + |
| 201 | +<Accordion title="Akamai"> |
| 202 | + |
| 203 | +<Steps> |
| 204 | +<Step title="Add a routing rule"> |
| 205 | + |
| 206 | +In a **Site Delivery** or **Ion** property, add a rule matching path `/docs*`: |
| 207 | + |
| 208 | +- **Origin Server**: `app.buildwithfern.com` |
| 209 | +- **Forward Host Header**: Origin Hostname |
| 210 | + |
| 211 | +Add a **Modify Outgoing Request Header** behavior: |
| 212 | + |
| 213 | +| Action | Header name | Value | |
| 214 | +|---|---|---| |
| 215 | +| Add | `x-fern-host` | `mydomain.com` | |
| 216 | +</Step> |
| 217 | +<Step title="Disable caching on the rule"> |
| 218 | + |
| 219 | +Add a **Caching** behavior to the same rule: |
| 220 | + |
| 221 | +| Setting | Value | |
| 222 | +|---|---| |
| 223 | +| Caching Option | No Store | |
| 224 | + |
| 225 | +Alternatively, set **Honor Origin Cache-Control** to **Yes** so Akamai respects Fern's `Cache-Control: public, max-age=0, must-revalidate` header. |
| 226 | +</Step> |
| 227 | +</Steps> |
| 228 | +</Accordion> |
| 229 | + |
| 230 | +<Accordion title="Caddy"> |
| 231 | + |
| 232 | +<Steps> |
| 233 | +<Step title="Add to your Caddyfile"> |
| 234 | + |
| 235 | +```caddyfile |
| 236 | +mydomain.com { |
| 237 | + handle /docs* { |
| 238 | + reverse_proxy https://app.buildwithfern.com { |
| 239 | + header_up Host app.buildwithfern.com |
| 240 | + header_up x-fern-host mydomain.com |
| 241 | + } |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | +</Step> |
| 246 | +</Steps> |
| 247 | + |
| 248 | +<Note> |
| 249 | +Caddy doesn't cache responses by default, so no extra caching configuration is needed unless you've explicitly enabled caching with a `cache` directive or external module. |
| 250 | +</Note> |
| 251 | +</Accordion> |
| 252 | +</AccordionGroup> |
| 253 | + |
| 254 | +## Verify your setup |
| 255 | + |
| 256 | +Run these checks against your live subpath: |
| 257 | + |
| 258 | +```bash |
| 259 | +# Check that the page loads and x-fern-host is recognized |
| 260 | +curl -sI https://mydomain.com/docs | head -20 |
| 261 | + |
| 262 | +# Verify Cache-Control on the HTML response |
| 263 | +curl -sI https://mydomain.com/docs | grep -i cache-control |
| 264 | +# Expected: cache-control: public, max-age=0, must-revalidate |
| 265 | + |
| 266 | +# Verify the page is not cached by your proxy (age should be 0 or absent) |
| 267 | +curl -sI https://mydomain.com/docs | grep -i "^age:" |
| 268 | +``` |
| 269 | + |
| 270 | +If the `age` header is present and non-zero, your proxy is serving a cached response. Revisit your provider's configuration to ensure HTML caching is disabled. |
0 commit comments