When
hostRouteisn't enough: response headers, compression, path-prefix stripping, extra declarative routes, and the raw Caddy JSON escape hatch. Five fields cover the 5% of cases YAML can't express directly.
Level: 6 · Reading time: 20 min
authBypassPaths and know how the manifest reaches Caddy.rawRoutes).Already know this rung? Skip to Level 7 — build a module where you extend asd itself with a TypeScript module.
Five fields under caddy: cover almost every case you'll meet when
the default reverse-proxy isn't quite enough:
| Field | What it does |
|---|---|
responseHeaders |
Add HTTP response headers (Cache-Control, CORS, security headers, custom branding) |
compression |
Enable gzip + zstd compression on responses |
forwardedPrefix |
Strip a path prefix before forwarding to the upstream (/api/v1 → upstream sees /) |
routes |
Extra declarative routes alongside hostRoute (additional matchers / handlers) |
rawRoutes |
Raw Caddy JSON — full control, no schema validation |
We'll walk each one in order of how often you'll reach for it. The
last (rawRoutes) is the escape hatch — try the four above first.
CORS, Cache-Control, security headers:
# packages/api/net.manifest.yaml
caddy:
hostRoute: { … }
responseHeaders:
Access-Control-Allow-Origin: ["*"]
Access-Control-Allow-Methods: ["GET, POST, OPTIONS"]
Cache-Control: ["no-store"]
X-Frame-Options: ["DENY"]
Strict-Transport-Security: ["max-age=63072000; includeSubDomains"]
Headers go on every response Caddy returns through this route. Values
are arrays — Caddy sets one header per array entry.
caddy:
hostRoute: { … }
compression: true # both gzip + zstd
Or the explicit form:
caddy:
compression:
gzip: true
zstd: true
Negotiation is handled by Caddy: the client's Accept-Encoding
header decides which algorithm gets used.
Your upstream is a SPA mounted at /admin/ publicly but serves from
/ internally:
caddy:
hostRoute:
host: "admin-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
forwardedPrefix: "/admin"
A request to https://admin-xyz1.…/admin/users reaches the upstream
as a request to /users. The forwarded URL still says /admin/users
so the upstream can build correct redirect links if it needs to.
hostRouteA second host that should hit the same upstream (e.g. legacy domain
during a migration):
caddy:
hostRoute:
host: "new-domain-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
routes:
- match:
- host: ["legacy.example.com"]
handle:
- handler: reverse_proxy
upstreams:
- dial: "127.0.0.1:${{ env.MYAPP_PORT }}"
Both routes coexist; hostRoute is still the canonical one, the
extras are listed in order.
For things YAML can't express: WebSocket upgrade rules, complex
matchers, custom handlers, anything caddy.routes doesn't reach:
caddy:
rawRoutes:
- match:
- host: ["api-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"]
handle:
- handler: reverse_proxy
headers:
request:
set:
X-Forwarded-Proto: ["https"]
X-Forwarded-Host: ["{http.request.host}"]
X-Forwarded-Port: ["443"]
upstreams:
- dial: "127.0.0.1:${{ env.API_PORT }}"
flush_interval: -1 # WebSocket / SSE friendly
terminal: true
Three things this rawRoutes example does that the higher fields
can't: explicit X-Forwarded-* headers, flush_interval: -1 for
streaming responses, and terminal: true to stop route evaluation
after this match.
After applying any of these, verify with curl -I to see the
response headers Caddy actually returned:
$ asd net apply
✓ Apply complete: 0 seeded, 2 routes
$ curl -sI https://api-xyz1.eu2.tn.example.com/ | grep -E "^(HTTP|Cache-Control|Access-Control|Content-Encoding|X-)"
HTTP/2 200
Access-Control-Allow-Origin: *
Cache-Control: no-store
Content-Encoding: gzip
X-Frame-Options: DENY
For path stripping, check that the upstream sees the rewritten path:
# upstream logs (excerpt)
GET /users (originally /admin/users)
For rawRoutes, the easiest sanity check is asd net discover
followed by jq '.manifests[] | select(.id=="api").caddy.rawRoutes'
— if it parses and reflects what you wrote, Caddy will accept it.
The five fields are arranged in increasing escape-hatch order:
responseHeaders is a thin sugar for one specific Caddy handlerheaders). It exists because 80% of "I need to customise a route"compression is sugar for encode { gzip zstd }. Trivial butforwardedPrefix wraps rewrite + headers so the upstreamX-Forwarded-*.routes lets you add extra declarative entries without losingrawRoutes,rawRoutes is verbatim Caddy JSON, expanded through asd's${{ env.X }}, ${{ macro.Y() }}) and otherwiseThere's a rule that bites everyone who writes rawRoutes for the
first time: X-Forwarded-Proto must be the literal string "https",
not the dynamic {http.request.scheme}. Inside the tunnel the
scheme is http, so the dynamic value breaks any upstream that
constructs absolute callback URLs (OAuth, password reset emails, …).
The other escape-hatch fields handle this for you.
Full rules table: reference/net-manifest § Rules.
asd itself with a TypeScript module: Level 7 — build a module.cookbook/ for single-problem recipes (debug a broken route, share via public URL, CI/CD, …).reference/net-manifest — every caddy.* field with examples.