Automatic HTTP routing for Docker containers — A Traefik-based proxy that gives your containers clean domain names like myapp.local instead of dealing with localhost:8080 port chaos.
Simply add VIRTUAL_HOST=myapp.local to any container or use native Traefik labels, and your applications become accessible with both HTTP and HTTPS automatically. No port management, no /etc/hosts editing, no hunting for the right port number. Only explicitly configured containers are exposed, keeping your development environment secure by default.
Using an AI coding agent? There is a
spark-http-proxyagent skill that teaches agents to expose containers, generate certificates, configure DNS, and troubleshoot routing with this proxy: sparkfabrik/sf-agents-harness → skills/system/spark-http-proxy.
- Features
- Quick Start
- Container Configuration
- Container Management
- Network Management
- DNS Server
- Advanced Configuration with Traefik Labels
- HTTPS Support
- Dinghy Layer Compatibility
- DNS Server
- Tailnet Peer Routing
- Metrics & Monitoring
- 🚀 Automatic Container Discovery - Zero-configuration HTTP routing for containers with
VIRTUAL_HOSTenvironment variables or Traefik labels - 🌐 Built-in DNS Server - Resolves custom domains (
.loc,.dev, etc.) to localhost, eliminating manual/etc/hostsediting - 🌍 Dynamic Network Management - Automatically joins Docker networks containing manageable containers for seamless routing
- 🔐 Automatic HTTPS Support - Provides both HTTP and HTTPS routes with auto-generated certificates and mkcert integration for trusted local certificates
- 🕸️ Tailnet Peer Routing - Optional: a hostname served by a container on one of your machines is reachable, under the same name, from your other machines on the same Tailscale tailnet
- 📊 Monitoring Ready - Optional Prometheus metrics and Grafana dashboards for traffic monitoring and performance analysis
Note: We thank the codekitchen/dinghy-http-proxy project for the inspiration and for serving us well over the years. Spark HTTP Proxy includes a compatibility layer that supports the
VIRTUAL_HOSTandVIRTUAL_PORTenvironment variables from the original project, while providing enhanced functionality for broader use cases and improved maintainability.
# Install Spark HTTP Proxy
mkdir -p ${HOME}/.local/spark/http-proxy
git clone git@github.com:sparkfabrik/http-proxy.git ${HOME}/.local/spark/http-proxy/src
sudo ln -s ${HOME}/.local/spark/http-proxy/src/bin/spark-http-proxy /usr/local/bin/spark-http-proxy
sudo chmod +x /usr/local/bin/spark-http-proxy
spark-http-proxy install-completion
# Or alternatively if you like to live on the edge.
bash <(curl -fsSL https://raw.githubusercontent.com/sparkfabrik/http-proxy/main/bin/install.sh)
# Start the HTTP proxy
spark-http-proxy start
# Generate trusted SSL certificates
# Option 1: Wildcard certificate (covers nginx.spark.loc, api.spark.loc, etc.)
spark-http-proxy certs generate "*.spark.loc"
# Option 2: Specific certificate (covers only nginx.spark.loc)
spark-http-proxy certs generate "nginx.spark.loc"
# Run an nginx container
docker run -d -e VIRTUAL_HOST=nginx.spark.loc nginx
# Access your app with HTTPS
curl https://nginx.spark.locThat's it! 🎉 Your nginx container is now accessible at https://nginx.spark.loc with a trusted certificate.
When generating certificates, you can choose between specific domains or wildcards:
- Specific certificate:
spark-http-proxy certs generate "nginx.spark.loc"- covers onlynginx.spark.loc - Wildcard certificate:
spark-http-proxy certs generate "*.spark.loc"- coversnginx.spark.loc,api.spark.loc, etc.
*.spark.loc will NOT work for nested domains like test.foo.spark.loc. To match nested domains, you need to generate a more specific wildcard like *.foo.spark.loc.
hosts answers three questions at once: which hostnames this proxy serves, which
machine serves each, and for containers on this machine, which directory they run
from.
$ spark-http-proxy hosts
HOSTNAME MACHINE CONTAINER DIRECTORY
graphile-poc.githuman.sparkfabrik.loc local githuman-graphile-poc ~/webapps/graphile-poc
pg-workflows.githuman.sparkfabrik.loc local githuman-pg-workflows ~/webapps/pg-workflows
macos.test.spark.loc Mac-Test - -hosts describe <hostname> reads the container behind one hostname live from
Docker and the proxy, so it answers "what is this container" without a
docker inspect by hand:
$ spark-http-proxy hosts describe sparkdock.githuman.sparkfabrik.loc
sparkdock.githuman.sparkfabrik.loc
container githuman-sparkdock
image node:lts
status running, up 29 hours
directory ~/webapps/sparkfabrik/agents/tailcat-use-cases/sparkdock
routed by VIRTUAL_HOST, port 3847
backend http://172.17.0.3:3847
network bridge
reachable 200
mounts ~/webapps/sparkfabrik/agents/tailcat-use-cases/sparkdock -> same path (rw)
githuman-npm-cache -> /cache/npm (rw)
githuman-data -> /data/githuman (rw)
command docker-entrypoint.sh bash -c npx githuman@0.9.0 serve --host 0.0.0.0 --auth <redacted>The port and backend are what Traefik routes to, read from its API, so they are
right for VIRTUAL_HOST and for native traefik.* labels alike. reachable is
the HTTP status of a request sent through the proxy with that Host header,
which is the path a browser takes; on Docker Desktop the container's own address
is inside the VM and would not answer from the host. Secrets in the command line
are redacted by flag name (--auth, --token, --password and similar, bare or
--flag=value), by assignment name (*_TOKEN=, *_SECRET=, ...) and in URL
userinfo. A value passed some other way is printed as is.
A remote host shows the machine and no directory: local paths are not published
to the tailnet. A record whose container Docker no longer has is reported as
such and the command fails. hosts --json prints the local records as JSON. Hostnames served by other machines are not in it; tailscale-peers --json carries those.
Directories come from the compose working directory, or the first bind mount for a
container started with docker run.
If the services image predates this command it writes no record, and hosts says so
and names upgrade rather than reporting that nothing is served.
# Configure system DNS (eliminates need for manual /etc/hosts editing)
spark-http-proxy configure-dns
# View status and dashboard
spark-http-proxy status
# Start with monitoring (Prometheus + Grafana)
spark-http-proxy start-with-metricsFor more examples and advanced configurations, check the examples/ directory.
Important: Only containers with explicit configuration are automatically managed by the proxy. Containers without VIRTUAL_HOST environment variables or traefik.* labels are ignored to ensure security and prevent unintended exposure.
For more complex scenarios beyond the Quick Start examples:
# docker-compose.yml
services:
myapp:
image: nginx:alpine
environment:
- VIRTUAL_HOST=myapp.local # Required: your custom domain
- VIRTUAL_PORT=8080 # Optional: defaults to exposed port or 80
- VIRTUAL_PATH=/api # Optional: mount under a path of VIRTUAL_HOST
expose:
- "8080"VIRTUAL_HOST accepts several forms:
- Single domain:
VIRTUAL_HOST=myapp.local - Multiple domains:
VIRTUAL_HOST=app.local,api.local - Wildcards:
VIRTUAL_HOST=*.myapp.local - Regex patterns:
VIRTUAL_HOST=~^api\\..*\\.local$
VIRTUAL_PATH is a separate variable, not another host form. It mounts the
container under a path of the hostname VIRTUAL_HOST names, so two containers
can share one domain.
A browser-served frontend and its API often need to be on one origin: same
domain, so no CORS, no preflight, and one certificate. Point both containers at
the same VIRTUAL_HOST and give the second a VIRTUAL_PATH:
# docker-compose.yml
services:
frontend:
image: node:22-alpine
environment:
- VIRTUAL_HOST=myapp.local
- VIRTUAL_PORT=5173
api:
image: node:22-alpine
environment:
- VIRTUAL_HOST=myapp.local # the same domain
- VIRTUAL_PATH=/api # mounted under it
- VIRTUAL_PORT=3000http://myapp.local/ reaches the frontend and http://myapp.local/api/...
reaches the API, so the page can call /api/... with no host in front of it.
What to know before using it:
/apimatches/apiand everything under it, never/api-docs. Matching is by path segment, so a mount cannot capture a sibling that merely starts with the same characters.- Nothing is stripped. The API receives
/api/users, not/users, so it has to serve the prefix itself.VIRTUAL_DESTis not supported. - A certificate covers the hostname, so a mounted path needs none of its own and is served by the certificate of the domain it sits on.
VIRTUAL_PORTbelongs to the container. Each container has its own, independently of the others sharing the domain.VIRTUAL_PATHapplies to every domain the container names. WithVIRTUAL_HOST=a.local,b.localthe container is mounted at that path on both.- A
traefik.*label disables both variables. A container carrying anytraefik.label is handled by Traefik's Docker provider instead, so itsVIRTUAL_HOSTandVIRTUAL_PATHare ignored entirely. - Stopping the mounted container does not produce a 404. Its routes go with
it and its paths fall through to whichever container serves the domain, which
for a dev server usually means a page and a
200. - Changing the value needs the container recreated, since environment variables cannot be changed in place.
The proxy uses opt-in container discovery (exposedByDefault: false). Only containers with explicit configuration are managed:
- Dinghy: Containers with
VIRTUAL_HOST=domain.localenvironment variable - Traefik: Containers with labels starting with
traefik.*
Unmanaged containers are ignored and never exposed.
One-off containers created by docker compose run are also ignored, even when they inherit VIRTUAL_HOST from the service definition. They are labelled com.docker.compose.oneoff=True by Compose, and routing them would let a short-lived container claim the domain of the long-running service. Use docker compose up for containers that must be reachable through the proxy.
The proxy automatically joins Docker networks that contain manageable containers, enabling seamless routing without manual network configuration. This process is handled by the join-networks service.
📖 Detailed Network Joining Flow Documentation - Complete technical documentation with flow diagrams explaining how automatic network discovery and joining works.
The HTTP proxy includes a built-in DNS server that automatically resolves configured domains to localhost, eliminating the need to manually edit /etc/hosts or configure system DNS.
The DNS server supports both Top-Level Domains (TLDs) and specific domains:
# docker-compose.yml
services:
dns:
environment:
# Configure which domains to handle (comma-separated)
- HTTP_PROXY_DNS_TLDS=loc,dev # Handle any *.loc and *.dev domains
- HTTP_PROXY_DNS_TLDS=spark.loc,api.dev # Handle only specific domains
- HTTP_PROXY_DNS_TLDS=loc # Handle any *.loc domains (default)
# Where to resolve domains (default: 127.0.0.1)
- HTTP_PROXY_DNS_TARGET_IP=127.0.0.1
# DNS server port (default: 19322)
- HTTP_PROXY_DNS_PORT=19322Configure TLDs to handle any subdomain automatically:
# Environment: HTTP_PROXY_DNS_TLDS=loc
✅ myapp.loc → 127.0.0.1
✅ api.loc → 127.0.0.1
✅ anything.loc → 127.0.0.1
❌ myapp.dev → Not handledSupport multiple development TLDs:
# Environment: HTTP_PROXY_DNS_TLDS=loc,dev,docker
✅ myapp.loc → 127.0.0.1
✅ api.dev → 127.0.0.1
✅ service.docker → 127.0.0.1Handle only specific domains for precise control:
# Environment: HTTP_PROXY_DNS_TLDS=spark.loc,api.dev
✅ spark.loc → 127.0.0.1
✅ api.dev → 127.0.0.1
❌ other.loc → Not handled
❌ different.dev → Not handledWhile VIRTUAL_HOST environment variables provide simple automatic routing, you can also use Traefik labels for more advanced configuration. Both methods work together seamlessly.
services:
myapp:
image: nginx:alpine
labels:
# Define the routing rule - which domain/path routes to this service
- "traefik.http.routers.myapp.rule=Host(`myapp.docker`)"
# Specify which entrypoint to use (http = port 80)
- "traefik.http.routers.myapp.entrypoints=http"
# Set the target port for load balancing
- "traefik.http.services.myapp.loadbalancer.server.port=80"Note:
traefik.enable=trueis not required since auto-discovery is always enabled in this proxy.
| Label | Purpose | Example |
|---|---|---|
| Router Rule | Defines which requests route to this service | traefik.http.routers.myapp.rule=Host(\myapp.docker`)` |
| Entrypoints | Which proxy port to listen on | traefik.http.routers.myapp.entrypoints=http |
| Service Port | Target port on the container | traefik.http.services.myapp.loadbalancer.server.port=8080 |
To effectively use Traefik labels, it helps to understand the key concepts:
An entrypoint is where Traefik listens for incoming traffic. Think of it as the "front door" of your proxy.
# In our Traefik configuration:
entrypoints:
http: # ← This is just a custom name! You can call it anything
address: ":80" # Listen on port 80 for HTTP traffic
websecure: # ← Another custom name
address: ":443" # Listen on port 443 for HTTPS traffic (if configured)
api: # ← You could even call it "api" or "http" or "frontend"
address: ":8080" # Listen on port 8080Important: http is just a custom name that we chose. You could name your entrypoints anything:
http,https,frontend,api,public- whatever makes sense to you!
When you specify traefik.http.routers.myapp.entrypoints=http, you're telling Traefik:
"Route requests that come through the entrypoint named 'http' (which happens to be port 80) to my application"
The entrypoint name must match between:
- Traefik configuration (where you define
web: address: ":80") - Container labels (where you reference
entrypoints=web)
The load balancer determines how traffic gets distributed to your actual application containers.
# This label creates a load balancer configuration:
- "traefik.http.services.myapp.loadbalancer.server.port=8080"This tells Traefik:
"When routing to this service, send traffic to port 8080 on the container"
Here's how a request flows through Traefik:
1. [Browser] → http://myapp.docker
↓
2. [Entrypoint :80] ← "web" entrypoint receives the request
↓
3. [Router] ← Checks rule: Host(`myapp.docker`) ✓ Match!
↓
4. [Service] ← Routes to the configured service
↓
5. [Load Balancer] ← Forwards to container port 8080
↓
6. [Container] ← Your app receives the request
While we typically use simple port mapping, Traefik's load balancer supports much more:
services:
# Multiple container instances (automatic load balancing)
web-app:
image: nginx:alpine
deploy:
replicas: 3 # 3 instances of the same app
labels:
- "traefik.http.routers.webapp.rule=Host(`webapp.docker`)"
- "traefik.http.routers.webapp.entrypoints=web"
# Traefik automatically balances between all 3 instances!
# Health check configuration
api-service:
image: myapi:latest
labels:
- "traefik.http.routers.api.rule=Host(`api.docker`)"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.services.api.loadbalancer.server.port=3000"
# Configure health checks
- "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=30s"This separation of concerns provides powerful flexibility:
- Entrypoints: Control where Traefik listens (ports, protocols)
- Routers: Control which requests go where (domains, paths, headers)
- Services: Control how traffic reaches your apps (ports, health checks, load balancing)
Example of advanced routing:
services:
# Same app, different routing based on subdomain
app-v1:
image: myapp:v1
labels:
- "traefik.http.routers.app-v1.rule=Host(`v1.myapp.docker`)"
- "traefik.http.routers.app-v1.entrypoints=web"
- "traefik.http.services.app-v1.loadbalancer.server.port=8080"
app-v2:
image: myapp:v2
labels:
- "traefik.http.routers.app-v2.rule=Host(`v2.myapp.docker`)"
- "traefik.http.routers.app-v2.entrypoints=web"
- "traefik.http.services.app-v2.loadbalancer.server.port=8080"
# Route 90% traffic to v1, 10% to v2 (canary deployment)
app-main:
image: myapp:v1
labels:
- "traefik.http.routers.app-main.rule=Host(`myapp.docker`)"
- "traefik.http.routers.app-main.entrypoints=web"
- "traefik.http.services.app-main.loadbalancer.server.port=8080"
# Weight-based routing (advanced feature)
- "traefik.http.services.app-main.loadbalancer.server.weight=90"The proxy automatically exposes both HTTP and HTTPS for all applications configured with VIRTUAL_HOST. Both protocols are available without any additional configuration.
When you set VIRTUAL_HOST=myapp.local, you automatically get:
- HTTP:
http://myapp.local(port 80) - HTTPS:
https://myapp.local(port 443)
services:
myapp:
image: nginx:alpine
environment:
- VIRTUAL_HOST=myapp.local # Creates both HTTP and HTTPS routes automaticallyTraefik automatically generates self-signed certificates for HTTPS routes. For trusted certificates in development, you can use mkcert to generate wildcard certificates.
HTTP Strict Transport Security (HSTS) headers are automatically disabled for all HTTPS traffic at the entrypoint level to prevent browser caching issues during development. This ensures that:
- Browsers won't remember HTTPS requirements if certificates are changed or revoked
- Switching between different development setups remains seamless
- Certificate issues don't persist in browser cache and block access
This is implemented using Traefik's disable-hsts middleware applied to the HTTPS entrypoint, ensuring all HTTPS traffic (both dinghy-layer and native Traefik routes) benefits from this development-friendly configuration. This is essential for development environments where certificates may frequently change, expire, or be regenerated.
For browser-trusted certificates without warnings, use the spark-http-proxy certs generate command. This command automatically handles the entire certificate generation process:
# Generate wildcard certificate for .loc domains
spark-http-proxy certs generate "*.loc"
# Generate certificates for specific domains
spark-http-proxy certs generate "myapp.local"
# For complex multi-level domains, generate additional certificates:
spark-http-proxy certs generate "*.project.loc"The certs generate command automatically:
- Installs mkcert if not already available (using Homebrew on macOS)
- Creates the certificate directory (
~/.local/spark/http-proxy/certs) - Generates certificates with safe filenames for wildcard domains
- Applies the certificate to the running proxy, without restarting it and without dropping connections to anything else it is serving
List the certificates currently installed, one row each, with the file that holds the certificate and the one that holds its key:
spark-http-proxy certs listCertificates in ~/.local/spark/http-proxy/certs
DOMAIN CERTIFICATE KEY
api.spark.loc api.spark.loc.pem api.spark.loc-key.pem
*.spark.loc _wildcard_.spark.loc.pem _wildcard_.spark.loc-key.pem
2 certificates. Remove one with: spark-http-proxy certs delete '*.spark.loc'
A wildcard is stored as _wildcard_, and a key that is not beside its certificate shows as missing. certs list needs neither Docker nor the proxy.
Describe one certificate to see what it covers, its validity, who issued it, and whether the running proxy is serving it. Name a hostname instead of a certificate and the command finds the certificate that covers it, or says why none does:
spark-http-proxy certs describe "*.spark.loc"
spark-http-proxy certs describe "app.spark.loc" # covered by *.spark.loc
spark-http-proxy certs describe "a.b.spark.loc" # not covered: a wildcard matches one label*.spark.loc
certificate ~/.local/spark/http-proxy/certs/_wildcard_.spark.loc.pem
private key ~/.local/spark/http-proxy/certs/_wildcard_.spark.loc-key.pem
covers *.spark.loc
valid 2025-07-13 to 2027-10-13
issued by mkcert paolo@workstation
served yes, by the running proxy
certs describe reads the certificate with openssl, which sparkdock installs as openssl@3. Without a usable openssl it stops with a message saying so; the other certs commands do not depend on it.
Remove certificate pairs for one or more domains. This deletes both the .pem and -key.pem files and applies the change to the running proxy, which stops serving the removed certificates without being restarted:
spark-http-proxy certs delete "nginx.spark.loc"
spark-http-proxy certs delete "*.spark.loc"
spark-http-proxy certs delete "nginx.spark.loc" "api.spark.loc" "*.old.loc"Pass the same domains you used with certs generate, including wildcards. The command lists every match, reports any domain it cannot find, and asks for a single confirmation before deleting.
The former names generate-mkcert, list-certs and remove-cert still work, print a deprecation warning naming their replacement, and are no longer listed in the help or the shell completion.
If you prefer to generate certificates manually using mkcert directly:
# Install the local CA
mkcert -install
# Create the certificates directory
mkdir -p ~/.local/spark/http-proxy/certs
# Generate wildcard certificate for .loc domains
mkcert -cert-file ~/.local/spark/http-proxy/certs/wildcard.loc.pem \
-key-file ~/.local/spark/http-proxy/certs/wildcard.loc-key.pem \
"*.loc"Note: A certificate written by hand is not picked up on its own. It is applied the next time the proxy starts, or immediately with spark-http-proxy restart. Certificates made with spark-http-proxy certs generate need neither.
The certificates will be automatically detected and loaded when you start the proxy:
spark-http-proxy startStart it this way rather than with docker compose directly. The command creates the directories the proxy bind-mounts before the containers do. Docker creates a missing bind-mount source itself, owned by root, and a certificate directory owned by root cannot be written to afterwards.
If a machine already reached that state, certs generate fails with a permission error and peer routing stops with a chmod error. Take the directories back:
sudo chown -R "$(id -un)" ~/.local/spark/http-proxy/certs ~/.local/spark/http-proxy/state
chmod 700 ~/.local/spark/http-proxy/stateThe Traefik container's entrypoint script scans ~/.local/spark/http-proxy/certs/ for certificate files and automatically generates the TLS configuration in /traefik/dynamic/auto-tls.yml. You don't need to manually edit any configuration files!
Now your .loc domains will use trusted certificates! 🎉
✅ https://myapp.loc - Trusted
✅ https://api.loc - Trusted
✅ https://project.loc - Trusted
Note: The *.loc certificate covers single-level subdomains. For multi-level domains like app.project.sparkfabrik.loc, generate additional certificates as shown in the commented example above.
Traefik automatically matches certificates to incoming HTTPS requests using SNI (Server Name Indication):
-
Certificate Detection: The entrypoint script scans
/traefik/certsand extracts domain information from each certificate's Subject Alternative Names (SAN) -
Automatic Matching: When a browser requests
https://myapp.loc, Traefik:- Receives the domain name via SNI
- Looks through available certificates for one that matches
myapp.loc - Finds the
*.locwildcard certificate and uses it - Serves the HTTPS response with the trusted certificate
-
Wildcard Coverage:
*.loccovers:myapp.loc,api.loc,database.loc*.locdoes NOT cover:sub.myapp.loc,api.project.loc- For multi-level domains, generate specific certificates like
*.project.loc
-
Fallback: If no matching certificate is found, Traefik generates a self-signed certificate for that domain
You can see which domains each certificate covers in the container logs when it starts up.
If you prefer to use Traefik labels instead of VIRTUAL_HOST, you can achieve the same HTTP and HTTPS routes manually:
services:
myapp:
image: nginx:alpine
labels:
# HTTP router
- "traefik.http.routers.myapp.rule=Host(`myapp.local`)"
- "traefik.http.routers.myapp.entrypoints=http"
- "traefik.http.routers.myapp.service=myapp"
# HTTPS router
- "traefik.http.routers.myapp-tls.rule=Host(`myapp.local`)"
- "traefik.http.routers.myapp-tls.entrypoints=https"
- "traefik.http.routers.myapp-tls.tls=true"
- "traefik.http.routers.myapp-tls.service=myapp"
# Service configuration
- "traefik.http.services.myapp.loadbalancer.server.port=80"This manual approach gives you the same result as VIRTUAL_HOST=myapp.local but with more control over the configuration.
This HTTP proxy provides compatibility with the original dinghy-http-proxy environment variables:
| Variable | Support | Description |
|---|---|---|
VIRTUAL_HOST |
✅ Full | Automatic HTTP and HTTPS routing |
VIRTUAL_PORT |
✅ Full | Backend port configuration |
VIRTUAL_PATH is not a dinghy-http-proxy variable. It comes from
nginx-proxy, which uses it for the
same purpose:
| Variable | Support | Description |
|---|---|---|
VIRTUAL_PATH |
✅ Full | Mount a container under a path of its VIRTUAL_HOST |
VIRTUAL_DEST |
❌ None | Rewriting the path before the backend sees it |
Without VIRTUAL_DEST the request reaches the backend unchanged, which matches
both nginx-proxy's own default and how an ingress forwards a path prefix.
- Security:
exposedByDefault: falseensures only containers withVIRTUAL_HOSTortraefik.*labels are managed - HTTPS: Unlike the original dinghy-http-proxy, HTTPS is automatically enabled for all
VIRTUAL_HOSTentries - Multiple domains: Comma-separated domains in
VIRTUAL_HOSTwork the same way - Container selection: Unmanaged containers are completely ignored, preventing accidental exposure
The HTTP proxy includes a built-in DNS server that automatically resolves configured domains to localhost, eliminating the need to manually edit /etc/hosts or configure system DNS.
The DNS server supports both Top-Level Domains (TLDs) and specific domains:
# docker-compose.yml
services:
dns:
environment:
# Configure which domains to handle (comma-separated)
- HTTP_PROXY_DNS_TLDS=loc,dev # Handle any *.loc and *.dev domains
- HTTP_PROXY_DNS_TLDS=spark.loc,api.dev # Handle only specific domains
- HTTP_PROXY_DNS_TLDS=loc # Handle any *.loc domains (default)
# Where to resolve domains (default: 127.0.0.1)
- HTTP_PROXY_DNS_TARGET_IP=127.0.0.1
# DNS server port (default: 19322)
- HTTP_PROXY_DNS_PORT=19322Configure TLDs to handle any subdomain automatically:
# Environment: HTTP_PROXY_DNS_TLDS=loc
✅ myapp.loc → 127.0.0.1
✅ api.loc → 127.0.0.1
✅ anything.loc → 127.0.0.1
❌ myapp.dev → Not handledSupport multiple development TLDs:
# Environment: HTTP_PROXY_DNS_TLDS=loc,dev,docker
✅ myapp.loc → 127.0.0.1
✅ api.dev → 127.0.0.1
✅ service.docker → 127.0.0.1Handle only specific domains for precise control:
# Environment: HTTP_PROXY_DNS_TLDS=spark.loc,api.dev
✅ spark.loc → 127.0.0.1
✅ api.dev → 127.0.0.1
❌ other.loc → Not handled
❌ different.dev → Not handledTo use the built-in DNS server, configure your system to use it for domain resolution:
# Configure systemd-resolved to use http-proxy DNS for .loc domains
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/http-proxy.conf > /dev/null <<EOF
[Resolve]
DNS=172.17.0.1:19322
Domains=~loc
EOF
# Restart systemd-resolved to apply changes
sudo systemctl restart systemd-resolved
# Verify configuration
systemd-resolve --statusREFUSED responses in the logs. This doesn't affect functionality - external domains resolve through fallback mechanisms. Solutions:
- Accept current behavior (recommended): The
REFUSEDresponses are correct and harmless - See systemd-resolved limitations documentation for details
# Configure specific domains (recommended)
sudo mkdir -p /etc/resolver
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/loc
echo "port 19322" | sudo tee -a /etc/resolver/locYou can test DNS resolution manually without system configuration:
# Test with dig (UDP - default)
dig @127.0.0.1 -p 19322 myapp.loc
# Test with dig (TCP - useful for Lima and other virtualization environments)
dig @127.0.0.1 -p 19322 +tcp myapp.loc
# Test with nslookup
nslookup myapp.loc 127.0.0.1 19322
# Test with curl (using custom DNS)
curl --dns-servers 127.0.0.1:19322 http://myapp.locA developer with more than one machine runs one proxy per machine, and each
proxy only sees its own Docker socket. A container exposed as app.loc on the
desktop does not exist as far as the laptop is concerned. Peer routing makes
that hostname mean the same thing on every machine you own.
It is off by default, because it changes what a proxy answers for names it does not serve itself. Read What enabling it exposes before turning it on.
A fourth sidecar service asks the local Tailscale daemon which machines belong
to your account and are online, reads each one's routing table from the Traefik
API it already publishes on port 30000, and writes Traefik configuration
forwarding any hostname it does not serve locally to http://<peer>:80 with the
Host header preserved.
- Nothing new is installed on the other machine. Running the proxy there is enough to be discoverable.
- DNS does not change. Every machine still answers
127.0.0.1, and the local proxy decides whether a request is served here or forwarded. - A local container always wins. A hostname served locally is answered locally and never forwarded, and the collision is reported.
- Encryption terminates locally, with the certificates already installed on
the machine the browser is talking to. No certificate authority is shared
between machines, and the hop between machines travels inside WireGuard. The
consequence is that this machine needs a certificate covering a hostname it
does not itself serve. A wildcard covers one label level, so
*.spark.loccoversapp.spark.locbut notapp.client.spark.loc, and a forwarded hostname outside the wildcard you hold produces a browser warning until you runspark-http-proxy certs generatefor it. Improving this is tracked in #118. - Only your own machines are used. A machine is used when the Tailscale status document says it belongs to the same account as this one. That check runs on every cycle, over the same document whatever platform produced it, and no setting turns it off or widens it.
- A forwarded hostname is never forwarded onward, so two machines cannot bounce a request between them.
- Only this proxy is adopted. Port 30000 identifies a Traefik, and a tailnet may carry an unrelated one. Every Spark HTTP Proxy publishes a declaration of itself, and a machine whose declaration is absent contributes nothing. The check fails closed, so both machines need this version or newer before anything is forwarded.
sequenceDiagram
participant B as Browser on machine A
participant D as DNS server on machine A
participant PA as Proxy on machine A
participant CA as Container on machine A
participant PB as Proxy on machine B
participant CB as Container on machine B
B->>D: where is app.loc?
D-->>B: 127.0.0.1, as it does for every name
Note over B,PA: HTTPS terminates here, with machine A's own certificates
B->>PA: GET / (Host: app.loc)
alt a local container serves app.loc
PA->>CA: GET / (Host: app.loc)
CA-->>PA: 200
else no local container serves it
Note over PA,PB: plain HTTP inside the tailnet, Host header unchanged
PA->>PB: GET / (Host: app.loc)
PB->>CB: GET / (Host: app.loc)
CB-->>PB: 200
PB-->>PA: 200
end
PA-->>B: 200
Reading the diagram. The browser always talks to its own machine's proxy, because DNS answers 127.0.0.1 for every name it handles, including names this machine knows nothing about. Solid arrows are requests, dashed arrows are responses. The alt block is the precedence rule: a local container answers when there is one, and only otherwise does the request leave the machine. The two notes mark the properties that are otherwise invisible: HTTPS terminates on the machine the browser is talking to, and the Host header crosses the tailnet unchanged, so the second machine's proxy performs the final match itself.
The request path above reads configuration that a separate loop writes. They are different stories at different speeds, and conflating them is what makes the timing confusing.
graph TB
subgraph sources["Status document, one transport per platform"]
direction LR
sock["tailscaled unix socket<br/>Linux"]
file["status file written by the host<br/>macOS"]
end
own{"Same account,<br/>and online?"}
declares{"Declares itself<br/>as this proxy?"}
read["Read the machine's routing table"]
local{"Served by a local<br/>container?"}
write["Write tailscale-peer-machine.yaml"]
dyn[("Traefik dynamic directory")]
proxy["Local proxy, file provider"]
skipped(["skipped, with the reason"])
foreign(["not this proxy"])
collision(["local wins, collision reported"])
sources -->|"every cycle"| own
own -->|"no"| skipped
own -->|"yes"| declares
declares -->|"no"| foreign
declares -->|"yes"| read
read --> local
local -->|"yes"| collision
local -->|"no"| write
write --> dyn
dyn -.->|"watched, no restart"| proxy
classDef reject fill:#f6d6d6,stroke:#a33,color:#000
classDef accept fill:#d6f0d9,stroke:#1a7f37,color:#000
class skipped,foreign,collision reject
class write,dyn accept
style sources fill:#eef6ff,stroke:#1f6feb,color:#000
Reading the diagram. The blue group is the status document, and the single arrow leaving it means either transport feeds the same filter: the platform decides how the document arrives, never what it is checked against. Diamonds are decisions, red rounded boxes are the three ways a machine contributes nothing, and they are the three statuses tailscale-peers reports. Green is what gets written and where. The dotted edge is the seam with the diagram above: the cycle writes a file, the proxy watches the directory, and nothing restarts.
How long it takes. A hostname appearing on another machine becomes reachable on the next cycle, so up to a minute by default, and the same again for one to disappear after it stops being served. Change it with HTTP_PROXY_TAILSCALE_REFRESH_INTERVAL. A cycle is one small request per due machine, so the cost is in how often the tailnet is swept rather than in the sweep itself.
Start the proxy with peer routing on, the same way you would start it with monitoring:
spark-http-proxy start-with-tailscaleDo the same on your other machine. Nothing else is configured: no peer list, no addresses, no changes to any project.
macOS needs no extra step. The macOS Tailscale build exposes no unix socket a container can mount, so the host writes the status document instead. The CLI detects that by looking for the socket, installs a launchd agent that keeps the document current, and removes the agent when peer routing is stopped. The command is the same one:
spark-http-proxy start-with-tailscaleTo refresh the document yourself, or from your own scheduler:
spark-http-proxy tailscale-statusSee what was found. This reports the proxy's most recent cycle rather than going looking itself:
$ spark-http-proxy tailscale-peers
Tailnet peers, from the cycle at 2026-01-02T09:15:04Z
Source: socket, tailnet status produced at 2026-01-02T09:15:04Z
PROXY
MACHINE ADDRESS HOSTNAMES
machine-a 100.100.0.11 app.loc, api.loc
EXCLUDED
MACHINE ADDRESS STATUS
machine-b 100.100.0.12 answered, but does not declare itself as this proxy
phone 100.100.0.32 offline
router 100.100.0.20 connection refused
tv 100.100.0.31 offline
5 machines, 1 running this proxy forwarding 2 hostnames, 4 excluded.Most rows on a real tailnet are phones, routers and televisions. They are
expected, not a fault. Add --json for the same information machine-readably.
Do not wait for the next cycle. Discovery polls every 60 seconds by default, so a container started on another machine takes up to a minute to become reachable. This runs a cycle now instead:
$ spark-http-proxy tailscale-peers --refresh
Tailnet peers, from the cycle at 2026-01-02T09:16:58Z
Source: socket, tailnet status produced at 2026-01-02T09:16:58Z
PROXY
MACHINE ADDRESS HOSTNAMES
machine-a 100.100.0.11 app.loc, api.loc, new-thing.loc
EXCLUDED
MACHINE ADDRESS STATUS
machine-b 100.100.0.12 answered, but does not declare itself as this proxy
phone 100.100.0.32 offline
router 100.100.0.20 connection refused
tv 100.100.0.31 offline
5 machines, 1 running this proxy forwarding 3 hostnames, 4 excluded.It waits for the cycle to finish and prints its report, so what you see is the result of that cycle rather than the one before it. If the cycle does not complete in time it says so and exits non-zero, rather than printing the previous report as though it were fresh.
On macOS it refreshes the status document first. The machine list there comes from a document the host writes every five minutes, so a forced cycle that only re-read it would find fresh routes from machines already known while missing a machine that came online two minutes ago. Rewriting the document first makes the command mean the same thing on both platforms.
HTTPS needs a certificate on this machine. TLS terminates on the machine the browser is talking to, so the peer's certificate is never presented and never matters. What matters is whether this machine holds a certificate covering the forwarded hostname.
When it does not, Traefik serves its default certificate and the browser refuses:
$ curl https://macos.test.spark.loc/
* SSL: no alternative certificate subject name matches target hostname 'macos.test.spark.loc'
* subject: CN=TRAEFIK DEFAULT CERTThe fix is the usual command, run on the machine doing the reaching:
spark-http-proxy certs generate 'macos.test.spark.loc'A wildcard covers exactly one label, which is the part that surprises people.
Holding *.spark.loc is not enough for macos.test.spark.loc, because that name
has an extra label. Observed against a machine holding *.spark.loc:
| Hostname | Certificate served |
|---|---|
test123.spark.loc |
*.spark.loc |
a.b.spark.loc |
Traefik's default |
macos.test.spark.loc |
Traefik's default |
So a nested name needs either its own certificate or a wildcard at its own level,
*.test.spark.loc, which then covers every name directly under it.
Turn it off again without stopping the proxy:
spark-http-proxy stop-tailscaleThat stops this machine forwarding to others. Every hostname it was forwarding is withdrawn immediately and the machine keeps serving its own containers.
It does not withdraw this machine from the tailnet. Its proxy still runs, still publishes the declaration that says what it is, and still answers for its own containers, so your other machines keep discovering and reaching it. To stop that, stop the proxy itself or close the ports.
The choice is remembered. start-with-tailscale records it and stop-tailscale clears it, so restart, upgrade and a later tailscale-peers in any shell see the same state. The monitoring stack is recorded the same way by start-with-metrics and stop-metrics, in ~/.local/spark/http-proxy/optional-stacks.
An explicit HTTP_PROXY_TAILSCALE_ENABLED or HTTP_PROXY_METRICS_ENABLED wins where it is set, for CI and other non-interactive callers, then the recorded choice, then off. A stack already running when nothing is recorded is recorded on the next command, so upgrading does not turn it off.
HTTP_PROXY_TAILSCALE_ENABLED=true spark-http-proxy startDo the same on the other machine, and their hostnames become mutually reachable. Disabling it and restarting removes every forwarded hostname.
| Variable | Default | Meaning |
|---|---|---|
HTTP_PROXY_TAILSCALE_ENABLED |
false |
Turns the behaviour on |
HTTP_PROXY_TAILSCALE_SOURCE |
detected | Where the tailnet status document comes from: socket or file. Detected from whether the daemon socket exists; set it to override |
HTTP_PROXY_TAILSCALE_REFRESH_INTERVAL |
60s |
How often peers are re-read |
HTTP_PROXY_TAILSCALE_STATUS_MAX_AGE |
10m |
How old a host-written status document may be before it is treated as no document |
HTTP_PROXY_TAILSCALE_SOCKET |
/var/run/tailscale/tailscaled.sock |
The daemon socket, for the socket source |
HTTP_PROXY_TAILSCALE_STATUS_FILE |
/state/tailscale-status.json |
The status document, for the file source |
HTTP_PROXY_TAILSCALE_AGENT_PATH |
standard system and Homebrew paths | The PATH the macOS refresh agent runs with, for a Tailscale client installed somewhere else |
HTTP_PROXY_TAILSCALE_REFRESH_TIMEOUT |
30s |
How long tailscale-peers --refresh waits for the cycle before reporting that it did not complete |
Discovery is polling, so a container appearing on another machine takes up to one interval to become reachable. The interval is paced for a background daemon: the trigger is a person starting a container elsewhere, not a request waiting.
The macOS Tailscale build exposes no unix socket a container can mount, so the status document is produced on the host instead. It is the same document, read by the same filter: this is the same discovery over a different transport, not a weaker mode.
spark-http-proxy start-with-tailscaleThe source is detected rather than configured: the CLI uses the daemon socket when it is present and the host-written document when it is not, so macOS needs no flag and cannot be started against a socket it does not have.
Keeping the document current is a launchd agent,
com.sparkfabrik.http-proxy.tailscale-status, installed with peer routing and
removed by stop-tailscale, clean and destroy. It runs
spark-http-proxy tailscale-status every half of the staleness tolerance, so 5
minutes by default, and writes only failures to
~/.local/spark/http-proxy/state/tailscale-status.log. Setting the tolerance
below 2 minutes leaves no interval that can keep up, so the agent is not
installed and the CLI says so.
Run the refresh yourself with:
spark-http-proxy tailscale-statusA document older than
HTTP_PROXY_TAILSCALE_STATUS_MAX_AGE, 10 minutes by default, is treated as no
document rather than as an empty tailnet, so a machine that stops refreshing it
withdraws its peers instead of forwarding to machines that may have gone away.
That tolerance is deliberately separate from the refresh interval: how fresh the
document must be depends on how often the host writes it, not on how often peers
are polled. The command
finds the Tailscale client on PATH first and inside the application bundle
second, which is where it lives on macOS.
On macOS, disable peer routing with stop-tailscale before removing the proxy
itself: an agent left behind points at a binary that is gone, and launchd keeps
reporting it.
spark-http-proxy creates that directory, owner only, before starting the stack. Starting the containers with docker compose directly instead leaves the service to create it as root, so create it yourself first if you do that.
The document and the report both live in ~/.local/spark/http-proxy/state. That
directory is a trust input: the document in it decides which machines traffic
is forwarded to, so it is created readable and writable by its owner alone. Keep
it that way.
spark-http-proxy tailscale-peers
spark-http-proxy tailscale-peers --jsonEvery machine discovery considered is listed, including the ones that gave nothing, with the reason:
The rows are grouped, the useful ones first, and a group holding no machine is not shown at all:
| Group | Holds | Statuses in --json |
|---|---|---|
PROXY |
machines running this proxy, and the hostnames they contribute | ok |
EXCLUDED |
everything else, with the reason in the STATUS column |
unreachable, no proxy, not this proxy, skipped |
The group says what the status used to say in a column of its own, so
--json remains the place to read the status token itself. Column widths are
computed per group, and rows stay alphabetical within one.
Most machines on a tailnet are phones, routers and televisions, so the
EXCLUDED group is the ordinary case rather than a fault. The command
reports the proxy's most recent cycle rather than performing discovery of its
own, so it cannot disagree with what the proxy is doing, and it still answers
when the proxy is stopped, saying that what it shows is not current.
- Your local development containers become reachable from your other devices. Those containers usually have no authentication of any kind. This is the point of the feature, and it is worth being deliberate about.
- The proxy's ports are published on all interfaces, so 80, 443 and 30000
are reachable from any network the machine is on, not only from the tailnet.
That is true today, before enabling anything here, but this feature makes it
load-bearing. Narrow it by binding the published ports to the tailnet address,
by editing the
ports:entries in your compose file, or by firewalling them. - A machine is adopted only if it declares itself as this proxy, so an unrelated Traefik on the tailnet is not treated as a source of routes. The declaration is not a secret and not an authentication mechanism: anything that can reach port 30000 can publish one. It separates this proxy from other software, not trusted machines from untrusted ones. What limits forwarding to your own machines is the Tailscale account check.
- The route registry is an unauthenticated read-only API. Port 30000 is the Traefik API, and any device that can reach it can enumerate the project hostnames this machine serves. It discloses hostnames and routing rules, not request contents.
Monitor your HTTP proxy traffic with built-in Prometheus metrics and Grafana dashboards:
# Start with monitoring stack (Prometheus + Grafana)
spark-http-proxy start-with-metricsAccess the pre-configured Grafana dashboard at http://localhost:3000 (admin/admin):
The dashboard provides insights into:
- Request rates and response times
- HTTP status codes distribution
- Active connections and bandwidth usage
- Container routing statistics
Monitor routing rules and service health at http://localhost:8080:
The Traefik dashboard shows:
- Active routes and services
- Real-time traffic flow
- Health check status
- Load balancer configuration
Both dashboards are automatically configured and ready to use with no additional setup required.

