Topology: one public hostname and a routeless engine
downpipes ships as two Workers in your own Cloudflare account: the engine (the writer, restorer, scheduler and governance plane) and the console (the static admin app). A self-hoster runs both, so this page is about how those two Workers are reached from the network and from each other, and why the engine has no public attack surface in the design you deploy by default.
The shipped topology has one rule worth holding in your head before any of the detail: the engine carries no public hostname. The console worker holds a service binding to the engine and proxies the engine’s admin surface to the browser same-origin, so the product’s entire public surface is the single console domain. Everything else, including the reconciliation cron and the Durable Objects, runs without a route.
This is the only topology you need for a normal deployment. An optional split variant gives the engine its own custom domain for deployments that front the two surfaces with different Access policies, and it is shown later as a commented option, not the normal case.
The shipped topology in one diagram
The flow from a browser to the engine, and where the destination bucket and cron sit, looks like this.
your operators
|
(HTTPS, custom domain)
v
+---------------------------------+
Cloudflare | console.example.com |
Access ------>| downpipe-console (Worker) |
(the perimeter)| serves the SPA, proxies |
| /admin/*, /support/*, GET /metrics |
+----------------+----------------+
|
ENGINE service binding
(worker-to-worker, never the network)
v
+---------------------------------+
| downpipe-engine (Worker) |
| NO public hostname (routeless) |
| |
| */15 reconciliation cron -----+---> seals due runs
| SCHEDULER + RUNSEAL DOs |
+----------------+----------------+
|
(writes sealed archives)
v
+---------------------------------+
| your destination bucket |
| (R2 in-account, or S3-compat) |
+---------------------------------+
In plain text: an operator’s browser reaches the console worker over HTTPS behind Cloudflare Access, the console forwards admin and support calls to the routeless engine worker over an internal service binding, and the engine’s cron and Durable Objects write sealed archives straight to your destination bucket.
The cron and the Durable Objects are attached to the engine worker itself, so they keep running whether or not the engine has a route. Removing the engine’s public hostname takes away a reachable URL, not the engine’s ability to do work.
Why the engine needs no route
A Cloudflare Worker does not need a public hostname to run a scheduled trigger or to host a Durable Object. Those execute on the worker regardless of whether anything points DNS at it. The only thing a route adds is a way for the outside network to call the worker’s fetch handler directly, and the shipped topology deliberately removes exactly that, a design choice explained fully on engine architecture.
In place of a route, the console worker declares a service binding named ENGINE to the engine in console/wrangler.toml:
[[services]]
binding = "ENGINE"
service = "downpipe-engine"
A service binding is a worker-to-worker call inside Cloudflare’s runtime. It never traverses the public network, so the engine is reachable from the console worker and from nowhere else. When a browser asks the console for an engine path, the console worker forwards that request over the binding and returns the engine’s response, so to the browser the engine appears to live at the console’s own origin.
| Concern | Where it lives in the shipped topology |
|---|---|
| Public hostname | The console domain only. The engine is routeless. |
| Browser-to-engine calls | Proxied same-origin through the console worker over the ENGINE binding. |
Reconciliation cron (*/15) |
On the engine worker, with no route required. |
SCHEDULER and RUNSEAL Durable Objects |
On the engine worker, with no route required. |
| Sealed archives | Written by the engine to your destination bucket (R2 in-account, or S3-compatible). |
The same-origin proxy and what it forwards
The console worker matches two prefixes plus one exact path as the engine surface, /admin/*, /support/* and GET /metrics (the Prometheus scrape endpoint), and forwards anything matching them to the ENGINE binding. The match is exact about its prefixes, so the app’s own client-routed paths such as /runs or /keys never collide with the engine surface.
Three more paths the worker owns itself, and then everything else is the single-page app. Two of them are local sinks that call nothing: POST /csp-report is the same-origin endpoint the policy’s own report-uri names, so a blocked injection is observable rather than silent, and POST /tour/event is used only by the public tour deployment, answering 204 as a no-op on every other deploy because the binding is absent.
The third one matters for a network review. POST /control-plane/licence/claim exchanges a licence claim code, and it is the one path on which a worker in your own account calls a host outside it: control.downpipes.io. It exists because the console’s connect-src 'self' policy blocks the browser from calling that host directly, so the exchange is made same-origin and forwarded from the worker instead. It is deliberately not a general proxy. The upstream URL is built from constants, so no part of the incoming request is reflected into it, and the route matches exactly one path on exactly one method.
Build your egress allowlist from this list, not from the perimeter section
If you are writing an outbound allowlist for the account, control.downpipes.io has to be on it. Omit it and licence claim-code activation fails from the console with no fallback, because the browser’s own direct call to that host is already blocked by connect-src 'self'. Nothing else in the shipped topology makes an outbound call to a host you do not own.
The proxy forwards the request verbatim. The method, path, query and headers, including the Authorization bearer and the cf-access-jwt-assertion header that Cloudflare Access injects, all pass through unchanged, and so does the body. The engine’s own authentication remains the sole authority over who may call it. The proxy adds reachability hiding on top of authentication, never instead of it: a caller still has to satisfy the engine’s auth, and now they also have to be able to reach the console at all.
Because every browser call stays on the console’s own origin, the Content-Security-Policy the console serves can set connect-src 'self', which is the tightest value that directive can take. The console serves a strict policy on every response. It allows its own bundle and one sanctioned inline theme script by hash, with no unsafe-inline and no unsafe-hashes, and it sets frame-ancestors 'none' so the restore-capable console cannot be framed. The reason the policy is this strict is that the break-glass private key is generated in this browser during the key ceremony and never leaves it, so a strict script-src is the control that stops injected script from reading it.
The SPA discovers the topology rather than being told
The console worker serves a tiny /engine-topology.json that reports whether the ENGINE binding is present. The app reads this at boot and, when it is proxied, adopts its own origin as the engine URL automatically, overriding any remembered split hostname. So in the shipped topology an operator is never asked for an engine address, because by construction it is the console’s own origin.
Cloudflare Access is the perimeter
In the shipped topology there is one public domain to protect, so a single Cloudflare Access application in front of the console domain fronts the product’s entire surface: the app, the proxied admin API and the proxied support pulls. The edge-injected Access JWT flows through the proxy to the engine, which verifies it. Putting Access on the one console domain therefore covers the engine too, because the engine is only ever reached through that domain.
This is also why the engine is never a reachable URL here. There is no engine hostname an attacker could find and probe directly, and there is no second perimeter to keep in step with the first. As a network perimeter, Access has exactly one front door to guard.
Access is optional for authentication, and the choice it leaves you is not the two-option one it is easy to read here. The engine authenticates four session-borne methods besides Access: a passkey, OIDC, SAML, and the shared bootstrap token. Every one of those except the token carries a verified email and a stable subject and resolves its role from the same table; the bootstrap token is the only one that is email-less, subject-less and non-attributable.
So if you are not going to buy Access, the shared token is not your permanent posture. Enrolling an Owner passkey gives you an attributable identity with no Access subscription and no identity provider, and it is what lets you retire the bootstrap token rather than leaving a standing shared bearer live. For the identity and role model behind the perimeter, see identity and access.
The SIEM pull endpoints sit behind the same perimeter
Because /support/* is proxied through the console, the SIEM diagnostics and audit-feed pulls sit behind Cloudflare Access too. A log collector that needs to reach them must present an Access service token, or you adopt the split topology below so a tokenless collector can reach the engine’s /support/* on a separate hostname with its own policy.
Custom domains only, and the preview consequence
Both workers are reached on a custom domain only. The workers.dev route is disabled by policy on each, with workers_dev = false in both wrangler.toml files. The console pins its custom domain in config:
# console/wrangler.toml
workers_dev = false
route = { pattern = "console.example.com", custom_domain = true }
This is a deliberate product invariant, not a default left in place. One honest consequence follows from it: because there is no workers.dev route, you cannot get a true isolated per-version preview URL of the kind workers.dev hands out. Where a cautious rollout wants to smoke a new version before it serves traffic, the path is a zero-traffic version upload, covered in upgrades and rollback, rather than a separate preview hostname.
The optional split topology
Some deployments want to front the engine and the console with different Access policies, for example a stricter policy on the admin API than on the static app, or a separate hostname for a tokenless SIEM collector to reach /support/*. For those, the engine can take its own custom domain. The engine wrangler.toml ships this as a commented option:
# engine/wrangler.toml — the SPLIT topology (uncomment to give the engine its own
# custom domain, never a workers.dev host):
# route = { pattern = "engine.example.com", custom_domain = true }
Treat the split variant as the exception. It exists for per-hostname Access policies and similar fronting needs; it is not the normal case, and most self-hosters never reach for it. When you do use it, the engine gets a public hostname, so its Access application and its CSP considerations become yours to manage as a second perimeter, and the console still proxies the engine surface over the ENGINE binding even in the split variant, so its connect-src stays 'self'; what changes is that the engine now answers on its own hostname as well, so direct callers of that hostname (for example a tokenless SIEM collector) are governed by the engine Access policy, not the console one. The shipped single-hostname topology avoids all of that by construction.
Older references that still mention an engine domain are stale
Some older material, including parts of the Cloudflare permissions notes and the CLI init output, still describes the engine on its own custom domain as if that were normal. Prefer the operations runbook, the comments in the two wrangler.toml files and the handover notes: the engine is routeless by default, and an engine custom domain is the optional split variant only.
How this maps to the deploy token
In the shipped topology only the console domain needs a route and a DNS record. The console worker is the only one with a route line, and it is the only hostname that has to provision in your zone. The engine is deployed as code, its cron and its Durable Objects, with no route and no DNS to wait on.
So when you deploy, the hostname-provisioning step is a console concern. The engine deploy is about the worker, its bindings and its secrets, which is the subject of how sources survive a deploy. If the console’s custom domain ever gets stuck half-provisioned, the clean fix learned in practice is to delete the console worker and redeploy it fresh, which recreates the worker, the binding and the domain together, rather than cycling the route on a live worker.
Where this fits
This page is the canonical home for the shipped topology and the split variant. For the end-to-end deploy and upgrade runbook that stands both workers up, see upgrades and rollback and the deploy guidance under deploy safety and bindings. For the identity model that the Access perimeter sits in front of, see identity and access. For why no standing vendor token or inbound vendor path exists in either topology, see the no-custody trust model.
Last updated .