Skip to content

Dev Proxy

Test a production publisher hostname against a dev or staging upstream — with a real browser, real TLS, and no DNS change — using ts dev proxy.

What it does

ts dev proxy is a TLS-terminating forward (MITM) proxy that runs on your machine. When you open https://www.example-publisher.com in a browser pointed at it, the address bar shows the production hostname but the request is served by the upstream you specify — a Trusted Server Compute service, a staging instance, or localhost. No production DNS, Fastly service, or certificate is touched, and no other users are affected.

Why a local proxy is necessary. The browser binds TLS SNI to the URL hostname. Fastly routes by SNI to the service that owns that domain. So even if you rewrite /etc/hosts or use --host-resolver-rules, the SNI still delivers the request to the production service. The only way to reach a different upstream while keeping the production hostname in the address bar is to rewrite the SNI in flight — which requires terminating the browser's TLS locally.

To terminate TLS, the proxy presents a certificate for the production hostname. For this to produce a green padlock in Chrome, Firefox, and Safari — and to satisfy HSTS — the certificate must be signed by a CA the browser trusts. ts dev proxy generates a per-machine Certificate Authority on first run; you trust it once.

Primary use case. Validate routing and behavior of a new or changed Trusted Server deployment at the publisher's real domain — cookies, Host-sensitive logic, CMP/consent flows, first-party context — before any DNS cutover.

Non-goals. Not a production proxy or load-testing tool. Does not modify any Fastly service. Local only, developer-facing.

Install and run

ts dev proxy is a subcommand of the ts CLI, and is macOS-only — its dependencies are scoped to macOS, so the command is not present in the CLI on other platforms. On macOS, install (or update) the ts binary from the repository root (see the CLI guide for details):

bash
cargo install-cli

Rerun cargo install-cli after pulling CLI changes to refresh the installed binary. Then trust the dev CA once (see the development CA), map the production hostname to your upstream, and launch a browser:

bash
ts dev proxy ca install
ts dev proxy \
  --map www.example-publisher.com=trusted-server-example.edgecompute.app \
  --rewrite-host \
  --launch chrome

--rewrite-host sends the upstream Host: <TO> so upstreams that reject the default Host: <FROM> — most dev and staging services, which aren't configured for the production hostname — still serve a page. Trade-off: against a real Trusted Server adapter, first-party URLs then render on the upstream host, not the production domain. To keep them on the production domain, point at an upstream that accepts Host: <FROM> and omit --rewrite-host — see Host header behavior.

Run ts dev proxy --help to list every flag.

Passing the rewrite rule

The upstream is always passed explicitly — there is no inference from trusted-server.toml or any config file. Give a single rule with the -f/-t shorthand, or one or more --map FROM=TO rules:

bash
ts dev proxy -f www.example-publisher.com -t trusted-server-example.edgecompute.app

With no --map/-f/-t, the proxy exits with no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to).

Connection options — --rewrite-host, --basic-auth/--basic-auth-file, --insecure, and --upstream-plaintext — apply to every mapping, not per-rule.

Explicit rule and browser launch

bash
ts dev proxy \
  --map www.example-publisher.com=trusted-server-example.edgecompute.app \
  --launch chrome,firefox,safari

--launch takes a comma list (chrome, firefox, safari) or all. When omitted the proxy runs without opening any browser. With multiple --map rules, every mapping is proxied, but --launch opens only the first rule's FROM hostname — navigate to the others manually.

Other examples

bash
# Gated staging upstream, Firefox only:
ts dev proxy \
  -f www.example-publisher.com \
  -t staging.example.net \
  --rewrite-host \
  --basic-auth-file ./staging-creds.txt \
  --launch firefox

# Local instance over plain HTTP, no browser:
ts dev proxy \
  -f www.example-publisher.com \
  -t localhost:3000 \
  --upstream-plaintext

The development CA

The proxy relies on a per-machine Certificate Authority that your browser must trust. ts dev proxy ca install (in the quick start above) generates it if needed and trusts it in one step, so you don't have to run the proxy first. Running the proxy also generates the CA on first use, printing:

generated dev CA at ~/Library/Application Support/trusted-server/dev-proxy/ca-cert.pem
— run `ts dev proxy ca install` to trust it

The CA key is written with mode 0600 and, by default, is stored outside the repository; it is never committed.

Trust the CA on macOS (Chrome and Safari)

bash
ts dev proxy ca install

This adds the CA to the macOS login keychain (no sudo required; prompts for your login password). Chrome and Safari both consult the macOS keychain and will trust the proxy's certificates immediately.

Trust the CA in Firefox

Firefox does not reliably consult the macOS login keychain. When you use --launch firefox, the proxy imports the CA into the temporary Firefox profile's NSS database using certutil. certutil is not built into macOS — install it with brew install nss; without it the proxy prints a warning and Firefox opens with certificate errors. If you are pointing an existing Firefox profile at the proxy manually, run:

bash
certutil -A -n "Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION" \
  -t "CT,," \
  -i "$(ts dev proxy ca path)" \
  -d "sql:$HOME/Library/Application Support/Firefox/Profiles/<profile>"

Revoking trust when done

bash
ts dev proxy ca uninstall

This removes the CA from the macOS keychain. Run it when you are finished — the CA is trusted for ~10 years and its key sits on disk.

Security note

The dev CA is a standing MITM capability on your machine. Its key (ca-key.pem) must be treated like a credential:

  • It is generated per-machine and never committed to the repository.
  • The CA directory has mode 0700 and the key file 0600.
  • The CA CN is Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION.
  • Trust it only on a development machine you control.
  • Run ca uninstall when done; run ca regenerate to rotate.

CA companion commands

bash
ts dev proxy ca path        # print the CA certificate path
ts dev proxy ca install     # trust the CA (macOS login keychain)
ts dev proxy ca uninstall   # remove the CA from the trust store
ts dev proxy ca regenerate  # generate a new CA (invalidates prior trust)

ca path and ca install generate the CA if it does not exist yet, so they work on a freshly cloned machine before the proxy has been run.

ca regenerate first removes the previously-installed CA from the macOS keychain (revoking its trust) before generating fresh key material, so an exfiltrated old key is no longer accepted. If the keychain removal can't be confirmed it aborts without touching the on-disk key, so the stored CA still matches OS trust — remove the old CA manually in Keychain Access, then retry. Run ca install afterward to trust the new CA.

Host header behavior

The proxy always sends X-Forwarded-Host: <FROM> (the production hostname) — the standard "original host" header for a forward proxy. Trusted Server core anchors all HTML/URL rewriting to it (it prefers X-Forwarded-Host, then Host), so first-party URLs stay on the production domain regardless of the Host header — as long as the upstream preserves X-Forwarded-Host. That decouples routing (Host) from the first-party host. (Real Trusted Server adapters strip inbound X-Forwarded-Host; the caveat below covers what that means with --rewrite-host.)

By default Host: <FROM> too. Fastly routes by SNI (= TO) and passes Host through unchanged, so Host: <FROM> reaches the upstream — but the upstream still has to be configured to accept it, which a dev or staging service usually isn't.

Targeting a specific server by IP. To point at a particular server or load balancer — for example when the TO hostname isn't in DNS yet — keep --to a hostname (so the TLS SNI and certificate stay valid) and pin its connection address with --resolve HOST:IP (like curl's --resolve, repeatable):

bash
ts dev proxy \
  --from www.example-publisher.com \
  --to ts.example-publisher.com \
  --resolve ts.example-publisher.com:192.0.2.10 \
  --launch chrome

The proxy dials 192.0.2.10 while the SNI stays ts.example-publisher.com and X-Forwarded-Host stays www.example-publisher.com — so TS rewrites first-party URLs onto the production domain. This keeps the tool self-contained — no /etc/hosts edit. (Pointing --to at a bare IP instead would make the SNI an IP, which sends no SNI extension at all, so a host-routed endpoint serves its default vhost.) Add --insecure if the endpoint serves a certificate that doesn't match the hostname.

Sending Host: TO. If your upstream routes or validates on its own hostname (e.g. a Fastly Deliver service that rejects an unconfigured Host), pass --rewrite-host to send Host: <TO>. The proxy still stamps X-Forwarded-Host: <FROM>, so first-party URL rewriting stays anchored to FROMas long as the upstream preserves that header.

Caveat with real Trusted Server adapters. The Fastly and Spin adapter request paths strip inbound X-Forwarded-Host before routing, so with --rewrite-host a real Trusted Server upstream falls back to Host (TO) and emits first-party URLs on TO, not FROM. Even so, --rewrite-host is the right choice for most upstreams — dev and staging services rarely have the production hostname configured and would reject the plain Host: <FROM>. Drop it only when the upstream is configured to accept Host: <FROM> and you specifically need first-party URLs anchored to FROM — the --resolve example above is exactly that case.

The TLS SNI is always the TO host either way:

FormHost headerX-Forwarded-HostTLS SNI
(omitted)FROMFROMTO host
--rewrite-hostTO hostFROMTO host

Port handling: with --rewrite-host and a non-default TO port (e.g. localhost:3000), the port is included in the Host header but never in the SNI (a bare hostname; a port in SNI is invalid).

Non-loopback listen

The proxy binds 127.0.0.1:18080 by default. A non-loopback --listen is rejected unless you also pass --allow-non-loopback:

bash
ts dev proxy \
  --map www.example-publisher.com=trusted-server-example.edgecompute.app \
  --listen 0.0.0.0:18080 \
  --allow-non-loopback

Even with --allow-non-loopback, unmatched CONNECT authorities are refused (403) rather than blind-tunneled, so the proxy cannot act as an open CONNECT proxy on the LAN.

All options

ts dev proxy [OPTIONS] [COMMAND]

Options:
      --map <FROM=TO>           Rewrite rule (repeatable)
  -f, --from <HOST>             Single-rule FROM (pairs with --to)
  -t, --to <HOST[:PORT]>        Single-rule TO (hostname; pairs with --from)
      --resolve <HOST:IP>       Pin HOST's connection to IP (curl-style, repeatable)
      --listen <ADDR>           Listen address [default: 127.0.0.1:18080]
      --allow-non-loopback      Permit non-loopback --listen (disables blind tunnel)
      --launch <LIST>           Browsers to launch (chrome,firefox,safari or all)
      --rewrite-host            Send Host: <TO> instead of the default <FROM>
      --basic-auth <USER:PASS>  Inject Basic auth (visible in ps — prefer --basic-auth-file)
      --basic-auth-file <PATH>  Read USER:PASS from a file
      --insecure                Skip upstream TLS certificate verification
      --upstream-plaintext      Connect to upstream over plain HTTP
      --connect-timeout <SECONDS>  Upstream connect timeout in seconds [default: 10]
      --ca-dir <PATH>           CA cert/key directory [default: ~/Library/Application Support/
                                trusted-server/dev-proxy on macOS]

The tool is flags-only; there are no environment variable overrides. The [COMMAND] slot is the ca subcommand — see CA companion commands.

Browser details

BrowserHow the proxy is configuredCA trust
ChromeTemp --user-data-dir; --proxy-server="https=127.0.0.1:<port>" (HTTPS only — plain HTTP goes direct)macOS login keychain via ca install
FirefoxTemp profile with user.js setting network.proxy.ssl (HTTPS only — network.proxy.http is unset so plain HTTP goes direct)CA imported into the profile's NSS DB at launch
SafariSystem PAC at http://127.0.0.1:<port>/proxy.pac via networksetup on the active network service, scoped to the configured FROM hosts; then opens Safari at the first rule's FROM URL; prior setting restored on exitmacOS login keychain via ca install

Unlike Chrome/Firefox (which run in a throwaway profile), Safari uses your system proxy settings, so --launch safari sets the macOS auto-proxy on the active network service (e.g. Wi-Fi) and then opens Safari at the FROM URL.

Changing the system network proxy requires admin, so --launch safari runs the networksetup command under sudo — it prompts once for your password in the terminal (only that command is elevated; the proxy keeps running as you). If sudo is declined or there is no terminal (e.g. the proxy is backgrounded), it prints the exact networksetup command and the System Settings path so you can set the PAC manually. The change is system-wide (all apps) but PAC-scoped to the FROM hosts, and — on a clean exit — only while the proxy runs. The prior setting — including whether auto-proxy was enabled or disabled — is saved and restored. On a clean exit (Ctrl-C) the restore uses sudo and may prompt once more if a long run outlived the cached credential. After a hard kill (SIGKILL) the next ts dev proxy run restores the leftover state non-interactively (sudo -n), or, if it can't, keeps the saved state and prints the exact manual networksetup command.

Troubleshooting

Mapped HTTP/1.1 upstream connections are reused automatically. With debug logging enabled, a clean shutdown prints an aggregate, redacted proxy metrics summary that can help distinguish DNS, connection, TLS, and pool wait latency; it never includes request URLs, headers, credentials, or certificate contents.

SymptomCauseFix
"unknown domain" or 404 from upstreamThe upstream service does not accept Host: <FROM> (the default). A domain can be active on only one Fastly service at a time, so you cannot add the production hostname to a dev service.Use a Trusted Server Compute upstream (routes by SNI, not Host), or pass --rewrite-host to send Host: <TO>.
Upstream returns 401Upstream is behind Basic auth.Pass --basic-auth user:pass or --basic-auth-file ./creds.txt.
Upstream unreachable (502 / 503)Upstream service is down or the domain is not provisioned.Verify the upstream URL and its Fastly service health.
Browser shows an untrusted-certificate warningThe dev CA is not trusted in the browser.Run ts dev proxy ca install for Chrome and Safari. For Firefox, use --launch firefox (auto-imports when certutil is installed — brew install nss) or run certutil manually (see above). After ca regenerate, re-trust with ca install.
Listen address already in useAnother process holds port 18080.Pass --listen 127.0.0.1:18081 (or another free port).
--listen rejected as non-loopbackA non-loopback address was given without the required flag.Add --allow-non-loopback.

Released under the Apache License 2.0.