Switchboard is an open-source prototype for programmable reverse proxy rules.
Users write request rules in Go, compile them to WebAssembly with TinyGo, upload immutable bundles to object storage, and proxy instances poll a channel pointer to hot-swap the active rule without restarting the proxy.
Switchboard keeps the long-lived dataplane stable, moves fast-changing request policy into versioned Wasm guests, and activates versions only after validation. It applies that pattern to ordinary reverse proxy deployments instead of requiring a custom CDN or edge network.
Rule deployments do not restart the proxy. Bundles are downloaded, verified, compiled, warmed, and validated off the request path, then activated with an atomic swap. In-flight requests continue using the runtime version they started with. If activation fails, the last known-good version remains active.
- Caddy handler module:
http.handlers.switchboard - CLI:
switchboard init,switchboard build,switchboard deploy,switchboard inspect - Registry: S3-compatible object storage only for deploy/inspect/load
- Runtime: wazero
- Guest rules: TinyGo WASI modules with a small host-function ABI
- Reconciliation: background channel polling with last-known-good fallback
Run the Caddy image with Switchboard already built in:
docker pull ghcr.io/ethndotsh/switchboard-caddy:latestInstall the CLI onto your PATH:
go install github.com/ethndotsh/switchboard/cmd/switchboard@latestFor local development from this repository:
go install ./cmd/switchboardInstall TinyGo too: https://tinygo.org/getting-started/install/.
Create a rule project:
mkdir my-rules
cd my-rules
switchboard init --name my-rules --registry s3://switchboard/prodThis writes:
go.mod
switchboard.yaml
rules/basic/rule.go
Rule projects use plain Go packages. switchboard build generates the TinyGo/Wasm export wrapper:
package basic
import "github.com/ethndotsh/switchboard/sdk"
func Handle(req sdk.Request) sdk.Action {
if req.Path() == "/blocked" {
return sdk.Deny(403)
}
return sdk.Next().SetHeader("x-powered-by", "switchboard")
}Build a distributable bundle:
switchboard buildbuild runs go mod tidy before invoking TinyGo so the generated SDK import is resolved.
Deploy the bundle:
switchboard deployInspect the active channel:
switchboard inspectswitchboard.yaml supplies the defaults:
name: my-rules
rule: ./rules/basic
dist: ./dist
namespace: customer-a
channel: prod
registry: s3://switchboard/prodEnvironment variables are expanded before the YAML is parsed:
name: ${SWITCHBOARD_NAME:-my-rules}
rule: ${SWITCHBOARD_RULE:-./rules/basic}
dist: ${SWITCHBOARD_DIST:-./dist}
namespace: ${SWITCHBOARD_NAMESPACE:-customer-a}
channel: ${SWITCHBOARD_CHANNEL:-prod}
registry: s3://${SWITCHBOARD_S3_BUCKET}/${SWITCHBOARD_S3_PREFIX:-prod}Supported forms are $VAR, ${VAR}, ${VAR:-fallback} for unset or empty values, and ${VAR-fallback} for unset values only.
Switchboard expects an S3-compatible registry. For local development, run MinIO or use any S3-compatible endpoint.
Required environment variables:
SWITCHBOARD_S3_ENDPOINT=localhost:9000
SWITCHBOARD_S3_ACCESS_KEY=minioadmin
SWITCHBOARD_S3_SECRET_KEY=minioadmin
SWITCHBOARD_S3_BUCKET=switchboard
SWITCHBOARD_S3_INSECURE=trueObjects use this layout:
channels/prod.json
bundles/2026-06-19T12-00-00Z-abc123/module.wasm
bundles/2026-06-19T12-00-00Z-abc123/manifest.json
bundles/2026-06-19T12-00-00Z-abc123/checksum.txt
Namespaces are optional. Without a namespace, Switchboard keeps the global layout above. With namespace: customer-a, channels and bundles are isolated under:
namespaces/customer-a/channels/prod.json
namespaces/customer-a/bundles/2026-06-19T12-00-00Z-abc123/module.wasm
namespaces/customer-a/bundles/2026-06-19T12-00-00Z-abc123/manifest.json
namespaces/customer-a/bundles/2026-06-19T12-00-00Z-abc123/checksum.txt
Registry URL prefixes remain a base path. registry: s3://switchboard/acme plus namespace: edge writes under acme/namespaces/edge/....
Build a rule:
go run ./cmd/switchboard build --out ./dist ./examples/basicDeploy it to object storage:
go run ./cmd/switchboard deploy ./dist --channel prodInspect the active channel pointer:
go run ./cmd/switchboard inspect --channel prodA Switchboard bundle is a normal Go package. It needs one Handle(req sdk.Request) sdk.Action function, but the rule logic can live across as many files as you want:
rules/public/
rule.go
security.go
routing.go
headers.go
rule.go is the top-level ordering file:
func Handle(req sdk.Request) sdk.Action {
return sdk.Chain(req,
BlockInternalPaths,
RewriteLegacyPaths,
AddRuleHeader,
)
}The other files define ordinary Go functions:
func BlockInternalPaths(req sdk.Request) sdk.Action {
if req.Path() == "/internal" {
return sdk.Deny(404)
}
return sdk.Next()
}Build the whole package:
switchboard build ./rules/publicTo use different rule packages for different Caddy routes, deploy them to different channels and attach the channel where that route lives:
example.com {
route /admin/* {
switchboard {
registry s3
namespace customer-a
channel admin
}
reverse_proxy admin:3000
}
route {
switchboard {
registry s3
namespace customer-a
channel public
}
reverse_proxy app:3000
}
}Each route gets one atomic active bundle. Namespace groups channels; channel remains the stable deployment pointer. Inside a bundle, ordering is explicit Go code.
Use the published Caddy image:
docker run --rm -p 8080:8080 \
-v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" \
-e SWITCHBOARD_S3_ENDPOINT \
-e SWITCHBOARD_S3_ACCESS_KEY \
-e SWITCHBOARD_S3_SECRET_KEY \
-e SWITCHBOARD_S3_BUCKET \
ghcr.io/ethndotsh/switchboard-caddy:latestBuild with xcaddy:
xcaddy build --with github.com/ethndotsh/switchboard/caddy@latestFor local development from this repository:
xcaddy build --with github.com/ethndotsh/switchboard/caddy=./caddyCaddyfile:
:8080 {
route {
switchboard {
registry s3
namespace customer-a
channel prod
poll_interval 2s
pool_size 16
pool_autoscale on
min_pool_size 16
max_pool_size 64
fail_mode open
}
reverse_proxy localhost:9000
}
}The handler never downloads, compiles, or instantiates bundles on the request path. A background reconciler polls channels/{channel}.json, downloads immutable bundles, verifies checksums, compiles Wasm, warms the configured minimum guest pool, validates the candidate, and atomically swaps the active runtime.
If the warmed pool is exhausted on the request path, Switchboard treats the rule as unavailable and applies fail_mode.
Warm pools adapt by default. pool_size remains the default floor, min_pool_size can set that floor explicitly, and max_pool_size bounds background growth. Use pool_autoscale off for fixed-capacity pools.
Published images are available at:
ghcr.io/ethndotsh/switchboard-caddy:latest
ghcr.io/ethndotsh/switchboard-caddy:v0.0.2
ghcr.io/ethndotsh/switchboard-caddy:sha-<commit>
Build a Caddy image with the Switchboard module from a checkout:
docker build -t switchboard-caddy .Pin the public Go module version by overriding SWITCHBOARD_VERSION:
docker build \
--build-arg SWITCHBOARD_REPLACE= \
--build-arg SWITCHBOARD_VERSION=latest \
-t switchboard-caddy .The default Docker build uses the checkout copied into the image build context. The pinned public-module mode clears SWITCHBOARD_REPLACE, then resolves github.com/ethndotsh/switchboard through Go modules.
GitHub Actions publishes the Caddy image from the checked-out source commit on pushes to the default branch and version tags. Pull requests build the image without publishing it.
Switchboard optimizes for predictable request-path behavior rather than zero-cost rule execution. Bundles are reconciled, compiled, warmed, and validated off-path; request handling borrows an already-warmed Wasm instance from the active runtime pool.
Local in-process benchmarks on an Apple M4 Pro with CLI-built optimized artifacts:
| Path | Approximate Result | What It Measures |
|---|---|---|
| HTTP request conversion | 0 allocs/op | Adapter conversion into a Switchboard request |
| Warm simple block rule | 1.7 us/op | Borrow pooled instance, read path, emit deny |
| Warm one-header next rule | 5.2 us/op | Read path and emit one header patch |
| Warm known-header read rule | 5.1 us/op | Read a named request header and emit deny |
| Warm multi-header patch rule | 7.7 us/op | Emit set, add, add, and delete header ops |
| Parallel warm-pool invoke | 1.1 us/op | Concurrent borrows from a warmed pool |
The hot path does not download, compile, or instantiate Wasm. The remaining cost is mostly rule execution and ABI calls. The ABI reads request fields lazily and writes action patches through host calls, so "continue unchanged" and "set one header" avoid serializing the full request or action.
Switchboard borrows architectural lessons from Railway's Hikari CDN writeup: keep the host dataplane stable, move request policy into versioned guests, reconcile toward desired state, validate candidates off-path, and activate with an atomic swap. See Railway's Hikari CDN architecture.
The Wasm runtime path also borrows lessons from Arcjet's production wazero writeups: precompile modules, avoid request-path instantiation, and prefer deliberate data-shape changes over fragile parser tricks. See Lessons from running WebAssembly in production with Go & wazero and Making Arcjet's Wasm bot detector smaller and faster.
- Request body and response body mutation are intentionally out of scope.
- There is no hosted control plane.
- Caddy is the reference adapter.
- The ABI is intentionally small and will likely change.
- Registry operations require S3-compatible object storage credentials.
- Switchboard is not a CDN, cache, BGP system, anycast network, or hosted control plane.
