Transport modes
Direct calls vs a Cloudflare tunnel — how the gateway reaches your origin, and when to use each.
Direct calls vs a Cloudflare tunnel — how the gateway reaches your origin, and when to use each.
A backend's transport decides how the gateway gets a request to your origin.
There are two modes. direct is the default: the gateway calls your origin URL
over public HTTPS. tunnel is for backends with no public ingress: your service
opens an outbound-only Cloudflare tunnel and the gateway reaches it through that.
Both modes are equally secure on the request path — every forwarded request is signed and verified the same way. Transport only changes the network path, not the trust model.
// Direct (default): the gateway calls originUrl over public HTTPS.
@Backend("core", {
transport: { mode: "direct" },
originUrl: "https://api.example.com",
})
core!: unknown;
// Tunnel: no public origin URL; the runner dials out to Cloudflare.
@Backend("core", {
transport: { mode: "tunnel", runner: "embedded" },
})
core!: unknown;
Use direct when your API is already reachable on a public HTTPS URL. This is
the default and needs no extra process — declare the origin and you are done.
@Product({ name: "croncloud", origin: "https://api.example.com" })
class CronCloud {
@Requests()
requests!: unknown;
@Backend("core", {
transport: { mode: "direct" },
originUrl: "https://api.example.com",
})
core!: unknown;
@Feature("cron-jobs", {
plans: ["starter"],
routes: { "POST /v1/cron-jobs": {} },
})
cronJobs!: unknown;
@Plan("starter", {
name: "Starter",
limits: {
requests: { rate: 600, interval: "minute", enforcement: "enforce" },
},
})
starter!: unknown;
}
In direct mode your backend returns usage by signing it into
response headers with withUsage() — the gateway settles
and strips them before the subscriber sees the response. There is no inbound
connection from you to the platform on the request path.
Use tunnel when your origin has no public ingress — it sits behind a firewall,
on a private network, or in a container with no inbound ports. Your service runs
cloudflared, which dials out to Cloudflare and holds the connection open;
the gateway routes requests back down that tunnel. Nothing inbound is ever
exposed.
The tunnel has a runner — who supervises the cloudflared process:
| Runner | Who runs cloudflared | When to use |
|---|---|---|
embedded | The @farthershore/backend SDK, as a child process of your app. | Single-process deploys; simplest DX. |
sidecar | You — a separate container/process you operate. | Kubernetes, Compose, or when you already manage cloudflared. |
@Backend("core", {
transport: { mode: "tunnel", runner: "embedded" },
})
core!: unknown;
With runner: "embedded", the SDK spawns and supervises cloudflared for you
using a tunnel token it fetches at bootstrap. Call fs.start() once during
boot; for every non-tunnel transport (and for sidecar) it is a safe no-op.
import { fartherShore } from "@farthershore/backend";
const fs = fartherShore.initFromEnv(); // reads FS_RUNTIME_TOKEN
// Launches the embedded cloudflared supervisor IF this backend is
// tunnel + embedded. No-op for direct / sidecar.
await fs.start();
// ... run your server ...
process.on("SIGTERM", () => void fs.shutdown()); // tears the tunnel down first
By default the embedded runner is fail-open: if cloudflared cannot start,
your app keeps running rather than crashing. Opt into crash-on-failure when the
tunnel is load-bearing:
const fs = fartherShore.initFromEnv({
tunnel: { failClosed: true },
});
Other tunnel options on initFromEnv (all advanced opt-ins): enabled: false
to skip the embedded runner entirely, binaryPath to point at a specific
cloudflared, and logger to capture its redacted output.
Request verification is always fail-closed — a tunnel that cannot start is a
different axis from a request that cannot be verified. Even with failClosed
unset, unverifiable requests are still rejected. See
metering & verification.
With runner: "sidecar", you own the cloudflared process — typically a second
container next to your app. The SDK does not spawn anything; fs.start() is a
no-op. You still call initFromEnv() and verify requests exactly the same way;
only the tunnel process is yours to run.
Tunnel backends need a runtime token whose capabilities include tunnel. Create
the backend with the tunnel transport, then mint the token:
# Declare the backend record as a tunnel origin
farthershore backend create <productId> \
--name private-origin --transport tunnel --runner embedded --default
# Mint a token that includes the tunnel capability
farthershore backend tokens create <productId> \
--backend <backendId> --capabilities gateway_verification,metering,health,tunnel
The mint command prints FS_RUNTIME_TOKEN once — set it in your backend's
environment. See runtime tokens for the full
lifecycle.
direct. Nothing to run.tunnel + embedded and call
fs.start().cloudflared (k8s, Compose)? Use
tunnel + sidecar.@Backend.tunnel capability.@farthershore/backend reference — the full SDK export surface.