Proxying a Path
Not every part of a site can move at once. There is usually one section that stays where it is: an application someone else maintains, a system with a licence tied to its host, a service that took two years to get right and nobody wants to touch this quarter.
A proxy route lets your Maravilla site serve that section by forwarding the request to wherever it actually runs, and returning the response as its own. To a visitor there is one site. The address bar never changes, cookies stay first-party, and one certificate covers everything.
This is a reverse proxy, not a redirect. A redirect sends the visitor away — the URL changes, the other system’s domain appears, and cookies set there are no longer yours. A proxy route keeps the request on your domain from beginning to end.
What it looks like
// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform/config';
export default defineConfig({
proxy: {
routes: [
{
path: '/legacy/*',
target: 'https://internal-app.example.net',
cache: { ttl: 300 },
},
],
},
});
Deploy, and https://yoursite.com/legacy/anything is served by internal-app.example.net — cached for five minutes, so most requests never reach it at all.
That is the whole minimum. Everything else has a default chosen so the common case needs no configuration.
Where this earns its keep
A section that has not moved yet. Migrate a large site page by page. Each route you rebuild on Maravilla, you drop from the proxy config; the URLs never change, so nothing breaks and no redirects accumulate. The migration becomes a series of small reversible steps rather than one weekend.
A system that cannot move. An application bound to a specific host, an appliance, something with a licence that names a server. It keeps running where it is, and appears under your domain at /portal or /booking.
A shop on different software. The site is Astro or SvelteKit on Maravilla; the shop stays on its own platform under /shop. Cart, checkout and sessions keep working, because cookies remain first-party — which is the part that a redirect or an iframe gets wrong.
A partner’s service, presented as yours. A booking widget, a support portal, a status page. Under your domain, with your certificate, and without exposing their hostname to your visitors.
An origin that cannot take your traffic. A small server behind a large campaign. Cached responses are served from Maravilla’s edge and never wake it up.
Two shapes, and how to choose
Everything about a route follows from one question: does the other system know the public URL it is being served at?
The upstream knows (recommended)
Configure the other system with its public address, and it generates correct links by itself. Nothing has to be rewritten:
{
path: '/legacy/*',
target: 'https://internal-app.example.net',
stripPrefix: false, // the upstream sees /legacy/... too
preserveHostHeader: true, // ...and sees your public Host
rewrite: { links: false },
}
Your site’s Host and X-Forwarded-Proto: https are passed through, so the upstream builds https://yoursite.com/legacy/... URLs on its own. This shape has no heuristics anywhere in it — prefer it whenever the upstream can be configured.
The upstream does not know
It serves from its own root and cannot be told otherwise. Then Maravilla translates:
{
path: '/legacy/*',
target: 'https://internal-app.example.net',
stripPrefix: true, // /legacy/reports → /reports
// rewrite.links defaults to true, which is what makes this work
}
Absolute URLs pointing at the upstream become same-origin URLs under your prefix, including the https:\/\/… spelling inside inline JSON. Root-relative URLs in HTML attributes, srcset lists and CSS url() are pulled under the prefix too. What this cannot reach is a URL assembled in JavaScript at runtime — so treat rewriting as the fallback, not the plan.
Restricting what a route exposes
Mounting a section of another system publishes all of it, including anything else it happens to serve under the same prefix. Three controls narrow that.
blockPaths — never forwarded, answered as 404:
{
path: '/legacy/*',
target: 'https://internal-app.example.net',
blockPaths: ['/legacy/admin*', '/legacy/login.php', '/legacy/xmlrpc.php'],
}
A 404 rather than a 403, deliberately: a 403 confirms the path exists behind the proxy. A path your site refuses to serve should look exactly like a path your site does not have. Administrators still reach the admin surface on the upstream’s own hostname, which is where it belonged.
methods — a route that only reads:
{ path: '/status/*', target: 'https://status.example.net', methods: ['GET', 'HEAD'] }
Anything else gets a 405 and never reaches the upstream.
A narrower route — the longest matching route wins, so a sub-path can be treated differently:
routes: [
{ path: '/legacy/*', target: 'https://internal-app.example.net', cache: { ttl: 300 } },
{ path: '/legacy/api/*', target: 'https://api.example.net', methods: ['GET'] },
]
Declaration order does not matter; /legacy/api/... takes the second route because it matches more of the path.
Two things are refused outright, whatever the config says. Platform paths (/_auth, /_assets, /_rt, /_push, /_mcp, /.well-known, /health, …) cannot be mounted, so a route can never break your site’s login or asset serving. And a target must be a public origin — its resolved addresses are checked, not just its hostname, so a name that points into private address space is refused rather than followed.
Caching
Caching is where a proxy route stops being a convenience and starts being the reason the section survives your traffic. Responses are cached across Maravilla’s delivery nodes, so a hit never reaches the upstream.
cache: {
ttl: 300, // fresh for five minutes
staleWhileRevalidate: 60, // then served stale while one node refreshes
bypassPaths: ['/legacy/cart', '/legacy/account*'],
varyHeaders: ['accept-language'],
}
staleWhileRevalidate does double duty: the page stays fast at the moment its TTL expires, and if the upstream is briefly down, a stale response is served instead of an error. Exactly one delivery node goes back to the origin to refresh — otherwise a popular page expiring would become a stampede against the server least able to absorb one.
Two protections are on by default, and both matter more than they look:
- A request carrying cookies is never served from, or stored in, the cache. Handing one visitor’s signed-in page to the next is the single worst thing a shared cache can do.
- The upstream’s own
Cache-Controlis honoured. If it saysno-store, nothing is cached. Override withignoreUpstreamCacheControl: trueonly for an origin whose headers you know are wrong.
bypassPaths covers what cookies alone cannot: the first request to a cart or account page arrives without a session, and caching that response would pin an empty state in front of everybody. A pattern ending in * matches a prefix, and a pattern containing ? matches path and query together — for endpoints whose route lives in the query string.
blockPaths and cache.bypassPaths use the same pattern grammar but are not the same tool:
| Reaches the upstream? | Cached? | For | |
|---|---|---|---|
blockPaths | no — 404 | n/a | Admin surfaces; anything you must not expose |
cache.bypassPaths | yes | never | Carts, checkouts, account pages, AJAX endpoints |
Every proxied response carries an x-maravilla-proxy header — HIT, STALE, MISS, BYPASS or UNCACHED — so you can check what happened with one curl -I.
Sessions and cookies
Cookies from the upstream are rewritten so they work on your domain. An upstream that sets Domain=internal-app.example.net while the visitor is on your site would have its cookie dropped by the browser as cross-domain — the symptom is a session that silently forgets itself, with nothing in any log to explain it. The Domain attribute is removed so the cookie binds to your host, and its Path is mapped onto your prefix.
Redirects are handled the same way: a Location pointing inside the proxied section is rewritten to your own URL, and one that genuinely leaves it — a hand-off to a payment provider — is passed through untouched.
Sending a secret upstream
If the other system should only answer requests that came through the proxy, give it a shared secret. Keep the value in the project’s environment, not in the repo:
{
path: '/legacy/*',
target: 'https://internal-app.example.net',
requestHeaders: { 'x-proxy-secret': '${LEGACY_PROXY_SECRET}' },
}
${...} is resolved from your project’s environment at request time, so the secret lives in the vault rather than in the repo:
maravilla secret set LEGACY_PROXY_SECRET --env production
See maravilla secret for the full flow, and maravilla env list --env production to check what a request will actually resolve. A plain, non-secret value works too — maravilla variable. Have the upstream reject anything that arrives without the header.
Only headers containing ${...} cost anything: a route with literal headers never touches your environment.
Example: a shop on other software
Putting it together for the case that exercises everything — cacheable catalogue pages alongside per-visitor state that must never be shared:
export default defineConfig({
proxy: {
routes: [
{
path: '/shop/*',
target: 'https://shop-origin.example.net',
// The shop knows its public URL, so nothing needs rewriting.
stripPrefix: false,
preserveHostHeader: true,
rewrite: { links: false },
cache: {
ttl: 300,
staleWhileRevalidate: 60,
bypassPaths: [
'/shop/cart',
'/shop/checkout*',
'/shop/my-account*',
'/shop/?wc-ajax=*', // endpoints routed by query string
],
},
// The public site serves the catalogue, not the administration.
blockPaths: ['/shop/wp-admin*', '/shop/wp-login.php', '/shop/xmlrpc.php'],
},
],
},
});
Product images, stylesheets and scripts need no extra configuration — they are paths under the same route, cached by the same ttl. Cart, checkout and AJAX requests are POSTs carrying cookies, so they are forwarded and never cached. Assets and catalogue pages are cached and mostly never reach the shop at all.
What it costs
Nothing starts your application’s runtime. A proxied path is matched and served in Rust, ahead of every other routing decision, so there is no cold start and no execution time billed for the request. A cached response is served without contacting the upstream at all.
Notes
- Routes live in your repo, not in the console. A route decides which requests leave the platform and where to — that belongs next to your code, reviewable in a pull request and revertible with it, rather than in a field that changes production with no diff. The console shows which paths are proxied and whether they are cached, under Project → Settings → Domains.
- Bodies are streamed unless a response has to be buffered to be rewritten or cached, and never beyond the configured limits. A large download or upload passes straight through.
- Requests take one hop. Redirects from the upstream are handed to the visitor’s browser rather than followed, so a redirect chain can never walk your request somewhere unchecked.
/*cannot be proxied. Routes are for the sections of your site that live elsewhere. If everything lives elsewhere, you want a DNS record, not Maravilla.