The stack routes entirely by Host header. Traefik has one entrypoint on :443 and decides
which service gets a request purely from the hostname — so https://localhost matches no router
at all and returns a 404 from the edge that looks exactly like a broken deployment.
You need four names to resolve to 127.0.0.1.
| Name | Serves | Why it is separate |
|---|---|---|
api.<domain> |
laravel-api, plus the chat-stream paths to laravel-api-stream |
the only door inward |
app.<domain> |
web — the admin console |
|
chat.<domain> |
web — hosted chat |
Same container, different ORIGIN. Hosted chat renders model-generated Markdown, the highest-risk sink in the product; sharing an origin with the admin console would let one XSS there run with the admin session cookie attached. |
<widget-domain> |
sdk (the loader and iframe assets), and /embed/* from laravel-api |
A different REGISTRABLE domain — not a subdomain. The admin session cookie is scoped to the main domain, so anything of ours on the widget's eTLD+1 makes a widget iframe on a hostile customer page same-site with a real admin credential. CHIPS cannot help: the cookie is not the widget's. |
Collapsing any of these locally teaches you a topology that does not exist in production, and the bugs it hides are the security-relevant ones. Use four names locally too.
/etc/hosts cannot express wildcards, so list the exact names.
Set these in infrastructure/docker/.env:
DOMAIN=knowledgebot.localhost
WIDGET_DOMAIN=kb-widget.localhost
Then add:
127.0.0.1 api.knowledgebot.localhost
127.0.0.1 app.knowledgebot.localhost
127.0.0.1 chat.knowledgebot.localhost
127.0.0.1 kb-widget.localhost
.localhost is reserved by RFC 6761 and will never resolve publicly, so a stale entry cannot
accidentally point at someone else's server. Note that .localhost is not automatically
resolved on every platform — macOS and most Linux distributions do it, Windows does not — hence
the explicit lines.
Edit C:\Windows\System32\drivers\etc\hosts on the Windows side, as Administrator. Editing
/etc/hosts inside WSL2 affects only Linux-side resolution, so curl inside WSL works and the
browser on Windows gets NXDOMAIN — which reads as "the stack is down".
*.localhost on systemd-resolved, or dnsmasq:
address=/knowledgebot.localhost/127.0.0.1
address=/kb-widget.localhost/127.0.0.1
Worth it once you have more than one environment, because there is no /etc/hosts line to
forget when a name changes.
The dev overlay points ACME at the Let's Encrypt staging directory, so your browser will warn about an untrusted issuer. That is correct and expected.
It is deliberate rather than convenient: production ACME allows only 5 authorization failures
per identifier per hour, and a Compose restart loop against a name that does not resolve
publicly — which .localhost never will — burns that budget in minutes. Every error message after
that describes a rate limit rather than the DNS problem that caused it.
For a local name, staging cannot issue either. Two workable paths:
- Accept the browser warning (
curl -k, "Advanced → Proceed"). Fine for everything except testingSecurecookie behaviour, which needs a trusted certificate. - Use
mkcertto generate a locally-trusted certificate and mount it throughtraefik/dynamic/as atls.certificatesentry. This is the option to take if you are working on session, cookie, or widget-embedding behaviour, because those depend on the browser actually trusting the origin.
When you move to real certificates, remove the caServer line AND delete the acme volume.
The staging account persists inside acme.json, and every certificate issued afterwards stays
untrusted with no error anywhere.
ai-api, every ai-worker-*, laravel-worker (Horizon), ai-beat, otel-collector, PostgreSQL,
Qdrant, both Valkey instances and the SeaweedFS S3 gateway have no hostname and no router, in
dev and in production. Do not add one "just locally": dev overrides get copied into staging, and
ai-api trusts its caller by design — an open port there is a complete authorization bypass, not
an information leak.
The dev overlay does bind the data stores to loopback only, which is what you use instead:
| PostgreSQL | 127.0.0.1:5432 |
| Qdrant | 127.0.0.1:6333 |
| valkey-core | 127.0.0.1:6379 |
| valkey-cache | 127.0.0.1:6380 |
| SeaweedFS S3 | 127.0.0.1:8333 |
| SeaweedFS master UI | 127.0.0.1:9333 |
The 127.0.0.1: prefix is load-bearing. A bare 5432:5432 publishes on 0.0.0.0, and Docker
DNATs published ports in the iptables nat table — ahead of the INPUT chain ufw uses. The
firewall rule is real and is simply never consulted.
For anything without a published port: docker compose exec <service> …, or an SSH tunnel.