Skip to content
downpipes docs

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. The split variant 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, S3-compatible or Azure)   |
                  +---------------------------------+

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. 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.

ConcernWhere it lives in the shipped topology
Public hostnameThe console domain only. The engine is routeless.
Browser-to-engine callsProxied 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 ObjectsOn the engine worker, with no route required.
Sealed archivesWritten by the engine to your destination bucket (R2 in-account, S3-compatible or Azure Blob).

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). The worker 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.

Four 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 fourth, /engine-topology.json, is described in the note below.

The third one matters for a network review. POST /control-plane/licence/claim exchanges a licence claim code. This endpoint is the one path on which the console worker calls a host outside your account: 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.

Cloudflare adds a CF-Worker header to the forwarded request, and that header names the zone the console runs on. The control plane records that zone for support matching, except on an MSP licence (how support recognises a customer).

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'. The engine also makes outbound calls to hosts you do not own. Its fixed hosts are api.cloudflare.com, cloudflare-dns.com, update.downpipes.io and login.microsoftonline.com. It also calls subdomains of cloudflareaccess.com, cloudflarestream.com and amazonaws.com, and the webhook, SIEM, S3 and IdP hosts that you configure.

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 must also be able to reach the console at all.

Because every browser call stays on the console’s own origin, the console’s Content-Security-Policy can set connect-src 'self', the tightest value that directive can take. The console serves a strict policy on the app and its static assets, not on proxied engine responses. It allows its own bundle and one sanctioned inline theme script by hash, with no unsafe-inline and no unsafe-hashes. The policy 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. For the full list of browser security features the console depends on, and what happens when the browser lacks one, see browser requirements.

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 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. That identity 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’s custom domain sits in your own Cloudflare zone, so its edge TLS posture (minimum version and cipher suites) is a zone setting you set yourself. The deploy prerequisites define that posture; see first deploy. 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 consequence follows: 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. One example is a stricter policy on the admin API than on the static app. Another is a separate hostname so a tokenless SIEM collector can 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.

An engine domain in the permissions notes or the CLI is the split variant

The engine’s CLOUDFLARE-PERMISSIONS.md and the default of downpipe init --engine-domain show the engine on its own custom domain. That is the optional split variant. The engine is routeless by default, as the comments in the two wrangler.toml files state.

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 gets stuck half-provisioned, the clean fix in practice is to delete the console worker and redeploy it with npm run deploy. This command recreates the worker, the binding and the domain together, rather than cycling the route on a live worker. This is safe on the console specifically, because its wrangler.toml carries only the route, the engine service binding and the static assets, so it holds no KV, no D1, no Durable Object and no secret of its own and nothing durable is lost. The console is simply offline until the domain reprovisions.

Do not apply the same move to the engine, which holds the Durable Objects and the source bindings. Deploy it with npm run deploy, not a bare wrangler deploy; see how sources survive a deploy.

Where this fits

This page covers 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 .