Port Management & Reverse Proxy
Assign ports to your services, choose another port when one is busy, and give each service a stable local URL with the optional reverse proxy.
Start with port assignment and the HTTP quick start. For HTTPS URLs without a port number, follow local proxy setup. Use LAN mode to reach services from another device.
Port Assignment
Configure the ports your daemon expects to use:
[daemons.api]
run = "node server.js"
port = 3000For multiple ports:
[daemons.multi]
run = "./start.sh"
port = [8080, 8443]Pitchfork checks availability, injects the resolved ports into the daemon's environment, and reports a conflict if a port is occupied and bumping is disabled.
Your application must use the assigned port. A program that hardcodes a port will not move just because pitchfork sets PORT. For example:
[daemons.web]
run = "python3 -u -m http.server $PORT --bind 127.0.0.1"
port = { expect = [8000], bump = 10 }
ready_cmd = "curl -fsS http://127.0.0.1:$PORT/"For the Node.js examples below, server.js must read process.env.PORT.
Resolved ports are exposed via the following environment variables:
| Variable | Description |
|---|---|
$PORT | First resolved port (alias for $PORT0) |
$PORT0 | First resolved port |
$PORT1 | Second resolved port |
$PORTN | Nth resolved port (0-indexed) |
When a single port is configured, both $PORT and $PORT0 are set to the same value. For multiple ports, each port is available at its corresponding index:
[daemons.multi]
run = "./start.sh --http-port $PORT0 --grpc-port $PORT1"
port = [8080, 8443]Lifecycle hooks receive the same values as namespaced PITCHFORK_PORT / PITCHFORK_PORT0..N (see hook environment variables).
Auto Port Bumping
When a port is occupied, enable bump to automatically find the next available port:
[daemons.api]
run = "node server.js"
port = { expect = [3000], bump = 10 } # bump up to 10 timesUsing bump = true enables unlimited bump attempts:
[daemons.api]
run = "node server.js"
port = { expect = [3000], bump = true }These environment variables reflect the resolved port, so they work correctly with auto-bumping. See Port Assignment for the full list of available variables.
Active Port Tracking
After a daemon starts, pitchfork detects the port the process is actually listening on. This detected port is the source of truth for the reverse proxy.
Reverse Proxy
The reverse proxy routes requests from stable URLs to the daemon's actual port.
The URL stays the same when port bumping assigns a different port. For example, http://api.myproject.localhost:8088 can route to http://localhost:3001.
Quick Start
Start with HTTP on an unprivileged port. Add this to ~/.config/pitchfork/config.toml so it applies regardless of the directory where the supervisor starts:
[settings.proxy]
enable = true
https = false
port = 8088In a project directory named myproject, add this to pitchfork.toml:
[daemons.api]
run = "node server.js"
port = { expect = [3000], bump = 10 }Then enable the proxy and register the project by starting the daemon:
pitchfork supervisor start --force
pitchfork start api
pitchfork proxy statusOpen http://api.myproject.localhost:8088 in a browser that resolves .localhost names. Project daemons with a port get an automatic hostname unless they set proxy = false. There is no need to register a slug.
The supervisor reads proxy settings at startup; restart it after editing them. For system-wide hostname resolution and HTTPS on standard ports, continue to local proxy setup. See also custom TLDs and LAN access.
Local Proxy Setup
pitchfork proxy setup configures hostname resolution, certificate trust, and access through the standard HTTP or HTTPS port. It shows a plan and asks for confirmation before applying changes; --yes skips the confirmation prompt. The supervisor runs as your normal user; setup requests privileges for the steps that need them.
After registering a project as in the quick start, configure local HTTPS on an unprivileged listener port in your user config:
# ~/.config/pitchfork/config.toml
[settings.proxy]
enable = true
https = true
port = 8443Apply setup, restart the supervisor to load the settings, and check the result:
pitchfork proxy setup --dry-run # Preview changes without applying them
pitchfork proxy setup
pitchfork supervisor start --force
pitchfork proxy doctorOn supported systems, setup routes names under your TLD to pitchfork and redirects port 443 to 8443. You can then open https://api.myproject.localhost without a port number. Follow any manual instructions printed by setup, such as configuring DNS on Linux without systemd-resolved.
Checking the Setup
pitchfork proxy doctor checks the listener, DNS responder, system hostname resolution, certificate trust, and standard-port access. It uses a fresh hostname for the resolution check so an existing /etc/hosts entry cannot mask missing wildcard DNS.
When doctor detects pitchfork's PAC URL in the system proxy settings, it checks that the PAC file is available instead of requiring system DNS resolution. Failed checks produce a nonzero exit status; warnings alone do not.
Platform Requirements
| Step | macOS | Linux |
|---|---|---|
| DNS | Writes /etc/resolver/<tld> with sudo | With systemd-resolved, .localhost needs no change; other TLDs need a drop-in and service restart with sudo |
| CA trust for HTTPS | Uses the login keychain; macOS may prompt for authorization | Installs the CA into the system trust store with sudo |
| Standard ports with an unprivileged listener | Adds a pf redirect and enables pf with sudo | Adds a loopback iptables redirect with sudo (ip6tables when proxy.host is IPv6) |
| Direct binding below port 1024 | Setup asks you to choose an unprivileged port | Grants the binary cap_net_bind_service with sudo, unless it already carries other capabilities |
Routing a custom TLD through systemd-resolved requires systemd 247 or newer. Restarting the service briefly interrupts DNS, including on repeated setup runs. Without systemd-resolved, setup prints dnsmasq instructions for you to apply.
On macOS, /etc/resolver/localhost makes subdomains such as api.myproject.localhost depend on the supervisor's DNS responder. Plain localhost continues to resolve through /etc/hosts.
Browser Setup with PAC
Use a proxy auto-config (PAC) file for applications that support system or browser proxy settings:
pitchfork proxy setup --pacThe PAC file sends names under proxy.tld through pitchfork and leaves other requests direct. Setup configures active network services on macOS and the user's proxy settings under GNOME. On other desktops, it prints the URL to enter in your browser's automatic proxy settings: http://127.0.0.1:<proxy.port>/proxy.pac with the default proxy.host.
PAC avoids system DNS changes and port redirects. Use an unprivileged listener port, such as 8443, so the browser can connect directly without a bind capability. Linux still needs sudo to trust the CA for HTTPS if it is not already trusted; macOS may request authorization for keychain or network settings changes. Applications that ignore PAC settings still need DNS and a reachable proxy port.
In LAN mode, --pac is ignored: its TLD is .local, which mDNS already resolves, and a PAC file would route every .local URL through pitchfork.
Undoing Setup
pitchfork proxy setup --undo --dry-run
pitchfork proxy setup --undoUndo removes pitchfork's resolver configuration, port redirects, PAC settings, bind capability, and CA trust. Setup records configurations in $PITCHFORK_STATE_DIR/proxy/setup.toml, so undo can find resources even after settings such as proxy.port or proxy.tld change. Re-running setup reconciles previous configurations before applying the new one.
Undo checks ownership before removing files or proxy settings and leaves unrelated files and PAC URLs alone. If setup replaced an existing automatic proxy setting, undo restores the saved setting while pitchfork's PAC URL is still in use. On macOS, undo removes pitchfork's firewall rules and releases its pf reference. On Linux, it removes cap_net_bind_service from the current pitchfork binary only if that is the binary's sole capability.
After a Reboot or Upgrade
Some system changes need to be reapplied:
| Change | When it may be lost | Recovery |
|---|---|---|
| Linux bind capability | pitchfork upgrade or reinstall | Run pitchfork proxy setup |
| Linux iptables redirect | Reboot, unless the distribution restores NAT rules | Run pitchfork proxy setup |
macOS pf enablement | Reboot; the anchor remains, but pf may be disabled | Run pitchfork proxy setup |
Run pitchfork proxy doctor when URLs stop working after a reboot or upgrade.
Standard Ports (80/443)
Use proxy.port = 8443 with HTTPS, or proxy.port = 8088 with HTTP, then run pitchfork proxy setup. Setup redirects local traffic from port 443 or 80 to the listener, so you can omit the port in URLs. The supervisor remains unprivileged.
On Linux, you can instead keep the default proxy.port = 443; setup grants the binary permission to bind privileged ports. On macOS, choose an unprivileged port and use the redirect. PAC connects directly to the configured listener and does not install a redirect. These redirects serve connections from the proxy host; LAN clients must use the listener port. See platform requirements.
Auto-Start
Opening a stopped daemon's proxy URL starts the daemon and its depends dependencies, just like pitchfork start. Auto-start is enabled by default once the proxy is configured. Opening a project or stack page does not start any daemons.
Start Dependencies Before the App
For an app that needs a database and a migration, define the startup order in pitchfork.toml:
[daemons.db]
run = "postgres -D ./data"
ready_port = 5432
[daemons.migrate]
run = "./migrate.sh"
oneshot = true
depends = ["db"]
[daemons.app]
run = "npm run dev"
port = 3000
depends = ["migrate"]This example assumes an initialized PostgreSQL data directory, a migration script, and an app that listens on port 3000. With all three stopped, opening app.<project>.localhost starts db, waits for port 5432, runs migrate to successful completion, and then starts app. Use the scheme and proxy port from your proxy configuration.
Dependencies at the same level start in parallel. Running services are reused; an in-flight oneshot is awaited, and a completed oneshot runs again. Dependencies in other registered projects use a qualified ID such as depends = ["shared/db"]. The proxy loads configuration from the checkout identified by the hostname. Dependencies from other registered projects use their own project's top-level [env] defaults for templates and process environment. Daemons in the requested checkout use its merged [env], including nested configuration overrides. See shared environment.
Concurrent proxy requests that share dependencies coordinate startup so a shared service starts once and is ready before either dependent starts. Disabled daemons are not started. If a dependency fails, the app stays stopped and the proxy returns a 502 error page identifying the failed dependency.
Wait for Startup
The first request waits for startup and is forwarded when the daemon is ready and its port is known. Additional requests to the same daemon during startup receive a "Starting…" page that refreshes every two seconds.
proxy.auto_start_timeout limits this wait to 30 seconds by default, including dependency startup, readiness checks, and port detection. On timeout, the request receives an error page, but startup continues in the background. Reload the page to check again. For a longer startup sequence, increase the wait:
[settings.proxy]
auto_start_timeout = "60s"To require manual startup instead, set auto_start = false in [settings.proxy]. Requests to stopped daemons then return a 502 error.
Idle Shutdown
Idle shutdown stops daemons that the proxy auto-started after they are no longer in use. It is disabled by default. Enable it with proxy.idle_timeout, or set proxy_idle_timeout on an individual daemon.
Configure the Idle Timeout
This example sets a 15-minute default and a five-minute timeout for web:
[settings.proxy]
idle_timeout = "15m"
[daemons.db]
run = "postgres -D ./data"
ready_port = 5432
[daemons.web]
run = "npm run dev"
port = 5173
depends = ["db"]
proxy_idle_timeout = "5m"It assumes an initialized PostgreSQL data directory and an app listening on port 5173. Opening web.<project>.localhost, with the configured proxy scheme and port, starts db and web. After five minutes without activity, web becomes eligible for shutdown. Pitchfork stops it before db, and stops db only if no other daemon or tracked shell session still needs it. The next visit starts them again.
| Configuration | Effect |
|---|---|
settings.proxy.idle_timeout = "15m" | Sets the default for daemons started through their proxy URL. The environment equivalent is PITCHFORK_PROXY_IDLE_TIMEOUT=15m. |
proxy_idle_timeout = "5m" on a daemon | Overrides the default, or enables idle shutdown for that daemon when the default is off. |
proxy_idle_timeout = false on a daemon | Exempts it from idle shutdown, even when the proxy starts it as a dependency. "0" has the same effect. |
settings.proxy.idle_timeout = "" | Disables the default; this is the initial value. "0" also disables it. Per-daemon settings still apply. |
A dependency without its own proxy_idle_timeout inherits the timeout of the requested daemon. In this example, db inherits five minutes from web, not the 15-minute default. A dependency that is already running keeps its existing idle-shutdown eligibility and timeout.
What Counts as Activity
The idle timer starts after startup finishes or the last active request or connection ends. These keep a daemon active:
- HTTP requests, until the response body has been sent or the request is abandoned. Long downloads and server-sent event streams count for their full lifetime.
- WebSockets and other upgraded connections, while open, even without data flowing. A browser tab with an HMR connection keeps its dev server up.
- TLS passthrough connections, while open. The proxy cannot inspect the requests inside them.
DNS lookups and idle HTTP keep-alive connections do not count. Neither does traffic sent directly to the daemon's port. For clients that bypass the proxy, start the daemon explicitly or set proxy_idle_timeout = false.
Which Daemons Can Stop
Only daemons started by proxy access, including dependencies started for that request, are eligible. Daemons started through the CLI, TUI, web UI, shell hook, or boot_start are exempt from idle shutdown.
To keep an already proxy-started app and its dependencies running, explicitly start it:
pitchfork start webThis removes their idle-shutdown eligibility even if they are already running. Other lifecycle controls, such as manual stops and shell-hook autostop, still apply.
An eligible daemon stays up while:
- A dependent is running, starting, stopping for a restart, or waiting to retry after an error. Shared dependencies stop only after their last dependent.
- A shell tracked by the shell hook, or a
pitchfork project entersession, is inside its directory or a subdirectory. - Proxy activity or startup is in progress. Startup protects the full dependency graph, including when it continues after a request times out.
Workers and databases without their own proxy traffic can be stopped as proxy-started dependencies once their idle timeout has elapsed and no remaining dependent or tracked session needs them. Dependencies stop after the daemons that use them.
Timing and Restarts
Pitchfork checks idleness every general.interval, which defaults to 10 seconds. An eligible daemon is selected on a check after its timeout has elapsed; completing shutdown can take longer, for example while earlier dependents stop.
A request arriving during idle shutdown waits for the stop to finish, then starts the daemon again. This wait is bounded by proxy.auto_start_timeout.
The timeout is recorded when the proxy starts a daemon. Configuration changes apply on its next proxy start. Retries and file-watch restarts preserve its eligibility and recorded timeout. A supervisor restart also preserves those values, but resets activity tracking so each eligible daemon gets a fresh idle period.
Viewing Proxy URLs
With the proxy enabled, pitchfork start, pitchfork list, pitchfork status, and the web UI show URLs for routable daemons. pitchfork proxy status groups automatic hostnames by project and worktree and lists registered slugs and conflicts.
The list --json and status --json output includes a url field. The existing proxy_url field remains an alias for the same value. If a daemon has a valid registered slug, that URL takes precedence over its automatic hostname.
Daemons receive their URL in PITCHFORK_URL. Configuration templates can use {{ url }} for the current daemon or {{ daemons.api.url }} for a dependency. See configuration templates.
Hostnames
Automatic hostnames identify a daemon by its project and, for linked git worktrees, its checkout:
| Checkout | Hostname | Example |
|---|---|---|
| Primary | <daemon>.<project>.<tld> | api.myproject.localhost |
| Linked worktree | <daemon>.<worktree>.<project>.<tld> | api.fix-login.myproject.localhost |
The TLD defaults to localhost. The URL's scheme and port come from the proxy settings; the examples in the quick start use HTTP on port 8088.
Where Labels Come From
| Label | Source |
|---|---|
| Project | The label of the primary checkout's registration, else its explicit namespace (including a registered one), else its directory name |
| Worktree | The linked worktree's worktree_label, or its directory name |
| Daemon | The daemon's proxy string, or its name |
Labels use lowercase ASCII letters, digits, and hyphens. Other characters become hyphens, repeated and leading/trailing hyphens are removed, and labels are limited to 63 characters. For example, Fix Login becomes fix-login.
To keep a worktree's hostname independent of its directory name, add this to that checkout's pitchfork.local.toml:
worktree_label = "fix-login"You can also set it in an external config registered for that checkout with pitchfork config add. Keep the label in a checkout-specific file: committing the same label for multiple worktrees creates a conflict.
Project Discovery
The proxy uses projects registered in [namespaces] or [slugs], and projects with a daemon in pitchfork's state file. Start a daemon once to make its project known, or register its configuration without starting it:
pitchfork config add /home/user/myproject/pitchfork.toml --dir /home/user/myproject --namespace myprojectChanging the current directory alone does not register a project.
A registered namespace is used as the project label. A tool that registers under a generated namespace, such as shop-528f92b13a6784f0, can name the hostname label separately with --label:
pitchfork config add /home/user/myproject/pitchfork.toml \
--dir /home/user/myproject --namespace shop-528f92b13a6784f0 --label shopThe daemons are then served at web.shop.<tld>, and those in linked worktrees at web.<worktree>.shop.<tld>. The label is stored as label = "shop" in the [namespaces.<namespace>] entry, must already be a valid hostname label (lowercase letters, digits, and hyphens, up to 63 characters), and is looked up for the primary checkout's registration. Registering again with a different --label replaces it; registering without --label keeps it. Removing the project's last registered file (pitchfork config remove) also forgets the label.
Daemons defined in global configuration have no project hostname; use a slug to route them. A linked worktree of a bare git repository is treated as a separate project, using <daemon>.<project>.<tld>.
Rename or Disable a Hostname
Use proxy to override the daemon label or disable its automatic hostname:
[daemons.admin]
run = "node admin.js"
port = 3001
proxy = false
[daemons.web-frontend]
run = "npm run dev"
port = 5173
proxy = "web" # web.myproject.localhostproxy = true is the default. Automatic hostnames require a configured port. Existing slug mappings are independent of this setting and still take precedence.
When the proxy is enabled, routed daemons receive HOST=127.0.0.1. Applications that honor HOST therefore bind to loopback. This now applies to automatic hostnames as well as slugs. A daemon with proxy = false and no slug does not receive this variable; LAN mode also leaves it unset.
Worktree Namespaces
Hostnames distinguish checkouts, but daemon identity still depends on the namespace and daemon name. Directory-derived namespaces keep worktrees separate by default. An explicit top-level namespace is inherited by every worktree that uses the same config, so only one copy of a same-named daemon can run at a time.
Give each checkout a distinct namespace to run those copies together. If a daemon is already running in another checkout with the same identity, the proxy returns an error identifying the conflicting checkouts. See namespaces.
Reserved Addresses and Conflicts
<project>.<tld> opens the project page, and <worktree>.<project>.<tld> opens that worktree's stack in the web UI. Both redirect to the web UI's address. Opening these pages does not start any daemons.
If the web UI is off, these addresses list the checkout's daemons and explain how to enable it. If the checkout has no registered project page, they explain how to register its directory.
A worktree label takes precedence over a daemon label. If a worktree is named api, api.myproject.localhost displays the worktree page. Rename the worktree label or set a different proxy label for the primary checkout's api daemon to give that daemon an unambiguous hostname.
If two projects, two worktrees within a project, or two daemons within a checkout produce the same label, neither conflicting entry is routed. For example, foo_bar and foo-bar both become foo-bar. Run pitchfork proxy status to see Conflicts, then change the relevant namespace, worktree_label, or daemon proxy label; when the clashing project label comes from a registration, register a different one with pitchfork config add <file> --label <other>. Hostnames that exceed the 253-byte DNS limit, including the TLD, are also rejected.
Slugs (Legacy)
Slugs predate hostnames and still work. They are defined in the global config (~/.config/pitchfork/config.toml) under [slugs], and each maps to a project directory and (optionally) a specific daemon name:
# ~/.config/pitchfork/config.toml
[slugs]
api = { dir = "/home/user/my-api", daemon = "server" }
frontend = { dir = "/home/user/my-app", daemon = "dev" }
# If daemon name matches slug, it can be omitted:
docs = { dir = "/home/user/docs-site" } # defaults daemon = "docs"Slugs are resolved before automatic hostnames, so an existing slug wins a matching route. Use them for existing integrations, global daemons, or LAN mDNS discovery. Project daemons can use automatic hostnames without a slug.
HTTPS Support
Local Certificate Authority
With proxy.https = true (the default) and no custom certificate configured, pitchfork generates a local certificate authority (CA). Its certificate is stored at $PITCHFORK_STATE_DIR/proxy/ca.pem; its private key is stored alongside it. Trust the CA with pitchfork proxy setup to use HTTPS across your proxy hostnames.
Per-Hostname Certificates
On the first TLS connection to a hostname under your TLD, pitchfork signs a certificate for that name using its local CA. Certificates are cached in $PITCHFORK_STATE_DIR/proxy/host-certs/ across restarts. The cache is bounded; evicted certificates are generated again when needed.
A certificate for the exact hostname supports nested names such as api.fix-login.myproject.localhost. A wildcard such as *.localhost covers only one label. Pitchfork refuses to generate certificates outside its TLD.
Trusting HTTPS from Daemons
When the proxy uses HTTPS and its certificate file exists, pitchfork passes that path to daemons it starts. With the generated CA, services can use it to verify HTTPS calls through the proxy:
| Variable | Value |
|---|---|
PITCHFORK_CA_FILE | Path to the CA certificate |
NODE_EXTRA_CA_CERTS | The same path, for Node.js |
For other runtimes, pass PITCHFORK_CA_FILE to the appropriate trust setting. For example, Python requests reads REQUESTS_CA_BUNDLE:
[daemons.worker]
run = 'REQUESTS_CA_BUNDLE="$PITCHFORK_CA_FILE" python worker.py'With a custom certificate, these variables point to proxy.tls_cert. Configure your client's trust store for that certificate's issuer as needed.
Auto-Trust
When the proxy starts with HTTPS enabled, pitchfork automatically attempts to install the CA certificate into your system trust store (proxy.auto_trust = true by default). Run pitchfork proxy doctor to check whether trust is configured.
On macOS, auto-trust triggers a system authorization dialog (Touch ID or password) the first time. Subsequent starts skip the prompt because the certificate is already trusted.
On Linux, auto-trust requires write access to the system CA directory, so use pitchfork proxy setup to perform that step with sudo. If auto-trust fails, pitchfork logs a warning and continues starting the proxy.
To disable auto-trust:
[settings.proxy]
auto_trust = falseManual Trust
If auto-trust is disabled or failed, you can manually install the certificate:
pitchfork proxy trustOn macOS, this installs the certificate into your user login keychain — no sudo required.
On Linux, this requires sudo:
sudo pitchfork proxy trustRemoving the Certificate
To remove the pitchfork CA from the system trust store:
pitchfork proxy untrustOn Linux, this requires sudo:
sudo pitchfork proxy untrustCustom Certificate
Set proxy.tls_cert and proxy.tls_key to a matching PEM certificate and key. Pitchfork serves that certificate as supplied for every hostname instead of signing certificates with its local CA. The certificate must cover the names you use, and clients must trust its issuer. Setup skips CA installation for custom certificates; set auto_trust = false to also disable startup CA trust. Set both paths together: a missing key or certificate, or a mismatched pair, prevents HTTPS startup.
A certificate wildcard covers one label: *.localhost does not cover api.myproject.localhost. Include each project and worktree you need. For example, using mkcert:
# Install mkcert and set up local CA
mkcert -install
# Cover the primary checkout and the fix-login worktree
mkcert -cert-file cert.pem -key-file key.pem \
"*.myproject.localhost" "*.fix-login.myproject.localhost" \
myproject.localhost fix-login.myproject.localhost localhost 127.0.0.1[settings.proxy]
enable = true
https = true
auto_trust = false
tls_cert = "/path/to/cert.pem"
tls_key = "/path/to/key.pem"These settings configure the certificate served by the proxy. To let a daemon present its own certificate or require client certificates, use TLS passthrough.
TLS Passthrough
By default, pitchfork terminates TLS with its own certificate and forwards plain HTTP to the daemon. Set proxy_tls = "passthrough" when the daemon needs to handle TLS itself, for example to use its own certificate, require mutual TLS (mTLS), or serve gRPC over TLS.
Configure a TLS Daemon
Enable the proxy and declare the daemon's TLS port in pitchfork.toml:
[settings.proxy]
enable = true
https = true
[daemons.api]
run = "./serve --port 8443 --tls-cert server.pem --tls-key server-key.pem"
port = 8443
proxy_tls = "passthrough"Replace ./serve and its flags with your server's command. The server must listen for TLS on the declared port. Passthrough requires a nonzero port and a build with the proxy-tls feature, which is enabled by default.
Register the project or start the daemon once, then connect using its automatic hostname. For a project named myproject:
pitchfork start api
curl --cacert ca.pem https://api.myproject.localhost/The daemon's certificate must cover api.myproject.localhost, and the client must trust its issuing CA (ca.pem in this example). pitchfork proxy trust trusts the proxy's CA; it does not install trust for the daemon's certificate. For mTLS, configure the server to require and verify client certificates, then supply a client certificate and key:
curl --cacert ca.pem --cert client.pem --key client-key.pem \
https://api.myproject.localhost/The proxy reads the SNI hostname from the TLS ClientHello and forwards the connection to the daemon on 127.0.0.1, without decrypting it. The daemon's certificate, client certificates, SNI, and ALPN negotiation pass through unchanged, including for HTTP/2 and gRPC. Existing slugs also support passthrough.
All clients appear to connect from loopback
The daemon sees every proxied client as 127.0.0.1. Pitchfork cannot add X-Forwarded-For or other HTTP headers to an encrypted stream.
With proxy.lan = true, network clients also appear to come from loopback. Do not rely on the client's IP address to protect admin or debug endpoints. Require authentication, such as mTLS verified by the daemon, or keep passthrough off the LAN.
Auto-Start with Passthrough
When auto-start is enabled, a request to a stopped daemon starts it and its dependencies as described in auto-start. The proxy holds the connection until the daemon is ready, so the client sees a delayed TLS handshake instead of the HTML "Starting…" page.
proxy.auto_start_timeout bounds the startup wait, including time spent waiting for another request to start the same daemon. The default is 30 seconds. For a slower daemon, increase the budget and the client's handshake timeout:
[settings.proxy]
auto_start_timeout = "60s"If startup fails or times out, the proxy closes the connection and logs the reason. Startup continues in the background after a timeout. The proxy cannot return an HTML error page or log HTTP requests inside the encrypted stream.
Worktrees
Automatic worktree hostnames, such as api.fix-login.myproject.localhost, use proxy_tls and proxy_tls_port from that checkout's configuration. Include the worktree hostname in the daemon's certificate and give each checkout a distinct namespace to run them together.
For legacy slug worktree routes, an unavailable worktree configuration falls back to its last known TLS route. If no worktree route is known, it inherits the slug's TLS mode but uses the worktree daemon's first port.
Connection Requirements and Errors
- Clients must send the destination hostname as SNI. Changing only the HTTP
Hostheader cannot select a passthrough route. A connection without SNI fails the TLS handshake. - With Encrypted Client Hello, routing uses the visible outer name; the proxy cannot route on the encrypted inner name.
- If the proxy cannot inspect a ClientHello before the handshake, it refuses to terminate TLS for a passthrough hostname with its own certificate.
- Plain HTTP sent to the HTTPS listener is redirected to HTTPS. With
proxy.https = false, requests for a passthrough hostname receive a 502 error explaining that HTTPS is required.
Why Plain TCP Is Not Proxied
Passthrough requires a TLS ClientHello with an SNI hostname at the start of the connection. Plain TCP services such as Redis and PostgreSQL connections that begin with SSLRequest do not meet this requirement. Connect to those services directly using the port shown by pitchfork status <daemon>.
Seeing Which Mode a Daemon Uses
pitchfork status and pitchfork list append (passthrough) to the proxy URL for a passthrough daemon. Terminating daemons have no mode annotation. Both commands include proxy_tls in --json output when a proxy URL is available.
Choosing a Port on a Multi-Port Daemon
Use proxy_tls_port to choose which declared port receives proxy traffic. Despite its name, this setting applies to both TLS modes: it selects the TLS listener for passthrough or the HTTP listener for termination.
[daemons.api]
run = "./serve --http 8080 --grpc 9443"
port = [8080, 9443]
proxy_tls = "passthrough"
proxy_tls_port = 9443Here, the daemon's hostname routes to its gRPC listener on port 9443. proxy_port is an alternative spelling; use one spelling per daemon. Pitchfork preserves that spelling when it rewrites the configuration.
The selected port must be a nonzero value declared in port. Pitchfork rejects invalid selections when it reads the configuration. Selection follows the port's position in the list after auto-bump: if [8080, 9443] becomes [8081, 9444], the hostname routes to port 9444.
Without an explicit selection, passthrough uses the first declared port. Termination uses the detected active port, falling back to the first port. All hostnames for a daemon share the same selection; mapping different hostnames to different ports of one daemon is not supported.
Custom TLD
Use a custom TLD instead of localhost:
[settings.proxy]
enable = true
tld = "test"Keep the listener settings from local proxy setup. After changing the TLD, run pitchfork proxy setup, restart the supervisor, and run pitchfork proxy doctor to check resolution for names such as api.myproject.test. On macOS this creates /etc/resolver/test; on Linux with systemd-resolved it installs a routing drop-in. Without systemd-resolved, follow the dnsmasq instructions printed by setup. See platform requirements.
Wildcard Subdomain Matching
With proxy.wildcard = true (the default), extra labels on the left of a hostname route to the same daemon. For example, api.myproject.localhost and tenant.api.myproject.localhost reach the same service. This also works for legacy slugs: tenant.myapp.localhost routes to myapp.
The loopback DNS responder resolves these nested names without extra entries. For HTTPS, the local CA signs a certificate for each requested hostname. If you supply a custom certificate, it must cover those names itself.
Browser support for .localhost subdomains varies. Use local proxy setup for system-wide resolution, or PAC for applications that honor proxy settings. Custom TLDs need local DNS configuration or PAC as well.
DNS Resolver Reference
With proxy.dns = true (the default), the supervisor listens for UDP and TCP DNS queries on 127.0.0.1:15353. Change proxy.dns_port if that port is occupied. The default avoids privileged port 53 and the mDNS port, 5353.
The responder answers names under proxy.tld, including nested names such as api.fix-login.myproject.localhost, without per-host entries. DNS resolution does not register a daemon or create a proxy route. Names outside the configured TLD receive REFUSED; the responder does not forward queries.
The returned addresses follow the proxy listener:
proxy.host | A record | AAAA record |
|---|---|---|
127.0.0.1 (default) | 127.0.0.1 | None |
0.0.0.0 | 127.0.0.1 | None |
::1 | None | ::1 |
:: | 127.0.0.1 | ::1 |
| A specific address | That address if IPv4 | That address if IPv6 |
Unsupported record types and address families receive NODATA. The :: case assumes a dual-stack host. Port redirects use the address family selected by proxy.host; an IPv6 listener gets an IPv6 redirect, not redirects for both families.
In LAN mode, DNS answers use the LAN IPv4 address, but setup leaves system resolution of .local to mDNS. See LAN mode for slug discovery.
Migrating from /etc/hosts
proxy.sync_hosts is deprecated and scheduled for removal after one release. It still defaults to true and maintains exact /etc/hosts entries for registered slugs; it cannot cover automatic project hostnames or subdomains.
Run pitchfork proxy setup, verify resolution with pitchfork proxy doctor, then disable hosts-file synchronization and restart the supervisor:
[settings.proxy]
sync_hosts = falseLAN Mode
LAN mode lets other devices on your local network (phones, tablets, other computers) access your daemons through the proxy. Instead of using .localhost (which only resolves on the host machine), LAN mode switches to the .local TLD and publishes slug hostnames via mDNS.
Quick Start
Use an unprivileged HTTPS listener in ~/.config/pitchfork/config.toml:
[settings.proxy]
enable = true
lan = true
port = 8443From the project directory, register a slug for the daemon, configure local CA trust, and restart the supervisor:
pitchfork proxy add myapp --daemon api
pitchfork proxy setup
pitchfork supervisor start --forceOn the other device, trust the proxy host's CA, then open https://myapp.local:8443. Include the listener port: setup's loopback redirects do not redirect connections from other devices. LAN discovery uses registered slugs; automatic project hostnames are not published through mDNS.
On Linux, you can use port = 443 and run setup to grant the bind capability, then omit the port from the URL.
How it works
When LAN mode is enabled:
- The TLD is forced to
.local(mDNS requirement) - The proxy binds to
0.0.0.0instead of127.0.0.1(overridable viaproxy.host) - Automatic project hostnames are not published through mDNS; use a slug for discovery
- Each registered slug is published as an mDNS address record (
myapp.local → 192.168.1.42) - Your LAN IP is auto-detected; if it changes, mDNS records are re-published
Pinning the LAN IP
By default, pitchfork auto-detects your LAN IP. To pin a specific address:
[settings.proxy]
enable = true
lan_ip = "192.168.1.42"Setting lan_ip implies lan = true, so you can omit the lan flag.
HTTPS on LAN
Other devices need to trust the proxy host's certificate authority to use HTTPS. Copy proxy/ca.pem from that host's state directory and install it using the client device's certificate settings. Trusting that one CA covers every proxy host name, because the proxy signs a certificate per name from it. On a supported desktop with pitchfork, use pitchfork proxy trust --cert /path/to/copied-ca.pem (with sudo on Linux). Running proxy trust without --cert would select that device's own CA.
If you configured proxy.tls_cert instead, have client devices trust its issuer. Distribute the issuer's CA certificate if they do not already trust it.
For HTTP-only access on a trusted development network:
[settings.proxy]
enable = true
lan = true
https = false
port = 8088Proxy Commands
| Command | Purpose |
|---|---|
proxy setup | Preview, apply, or undo DNS, certificate trust, port, and PAC configuration |
proxy doctor | Diagnose listener, resolution, trust, and standard-port access |
proxy status | Show hostnames, slugs, and routing conflicts |
proxy add | Register a slug, for example pitchfork proxy add api --daemon server |
proxy remove | Remove a registered slug |
proxy trust | Install the generated CA or a certificate supplied with --cert |
proxy untrust | Remove the proxy CA from the trust store |
