Your local vhost returns the wrong site through a tunnel
A named local site — myapp.test in Herd or Valet, a docker container behind nginx, an Apache virtual host — is selected by the Host header, not by the port. A tunnel arrives carrying the public hostname, that name matches no vhost, and the web server answers with its default site or a 404. Nothing is broken: the request simply never says which site it wants.
The fix is to tell the agent both where to connect and what name to send:
$ devgate http 80 --host=myapp.test --host-header=rewrite Two flags, two different questions
They look interchangeable and are not. --host is where the agent connects — a socket address, resolved on your machine. --host-header is what your service reads in the Host header — the name it routes on.
The agent forwards the bytes it is given and never rewrites them, so whatever devgate puts on the wire is what the upstream reads. That is why the header has to be set explicitly rather than inferred.
Herd, Valet, and anything on port 80
Herd and Valet serve every site from one nginx on port 80 and pick the site by name. Connecting to port 80 alone gets you whichever site nginx considers default. Name the site as well:
$ devgate http 80 --host=myapp.test --host-header=rewrite rewrite is a sentinel, not a hostname: it means “send what --host points at”, appending :port when the port is not 80. Writing the name twice works too and is what you want when the two differ.
Docker, nginx server_name, a service on loopback
Here the socket is ordinary — localhost:8080 — but the service answers only to a vhost name. Leave --host alone and name the header directly:
$ devgate http 8080 --host-header=myapp.test When you should not rewrite the header
Rewriting is the exception, not the default. An app that builds absolute URLs — canonical tags, redirects, e-mail links, a session-cookie domain, OAuth callbacks — derives them from the Host it was given. Rewrite it and the app starts handing your visitor links to a name that only resolves on your machine.
So: rewrite when the local server must match a vhost to serve the right site at all. Leave it alone when the app routes fine and only needs to know its public address. Passing the public hostname through untouched is the default for exactly this reason.
Symptom → cause
| What you see | What it means |
|---|---|
| The tunnel opens the wrong site | The Host header matches another vhost, or nginx fell back to its default server block. |
| 404 from nginx / Apache | No vhost matched the public hostname and there is no default site to fall back to. |
| “Blocked request. This host is not allowed.” | Vite’s allowedHosts check — the dev server rejects the hostname before your app sees it. |
| “Invalid Host header” | webpack-dev-server / older create-react-app host check, same idea as Vite’s. |
| Page loads but CSS, JS and links point at localhost | The app builds absolute URLs from the Host it was given. Here you want the public hostname passed through, not rewritten. |
| Redirect loop back to the vhost name | The app redirects to its canonical host. Rewriting Host tells it the request arrived on that host, which is exactly what causes the loop when the browser is on the public URL. |
The last two rows are not fixed by the flags: a dev server that refuses unknown hostnames has its own allow-list (server.allowedHosts in Vite, allowedHosts in webpack-dev-server), and a framework that generates absolute URLs needs to be told its public address — APP_URL in Laravel, plus trusted proxies so it honours the X-Forwarded-Proto: https devgate sends.
Check what actually arrives
The running agent prints every request it forwards — method, path, status, duration, size — so you can see whether a request reached your app at all and what it answered. A 404 in that log is your web server rejecting the name; no line at all means the request never got past the tunnel.
You can also ask the local server directly, bypassing the tunnel, to confirm which name it wants:
$ curl -sI -H 'Host: myapp.test' http://127.0.0.1:80 Make it stick
On a reserved endpoint the flag updates that endpoint’s stored setting, so the next run — and anyone else on the team who brings it up — gets the same behaviour without remembering the flag. The value is editable in the dashboard alongside the endpoint.
Questions people actually ask
Because the local web server routes by the Host header. The tunnel puts the public hostname in it, that name matches no server_name / vhost, and the server falls back to its default site or a 404. Send the vhost name upstream with --host-header and the right site answers.
--host is where the agent connects locally — a socket address. --host-header is what the local service reads in the Host header. They are different settings: one picks the socket, the other picks the vhost.
Run: devgate http 80 --host=myapp.test --host-header=rewrite. Herd and Valet serve every site from port 80 and pick the site by name, so both flags are needed: one to reach nginx, one to name the site.
Vite refuses requests whose Host is not in server.allowedHosts. Add the tunnel hostname (or your vhost name, if you rewrite the header) to allowedHosts in vite.config.js — this is a dev-server check, not a tunnel error.