Env Vars Reach the Container, Connectivity Does Not Come Free
Wiring env vars in Compose is only half the job; the container still needs a deliberate network path before the LLM search feature can talk to its router.
The commit was tiny: one file, docker-compose.yml, six added lines. It passes three env vars to the api service, all through Compose interpolation with empty defaults. An environment that sets nothing still gets a running container, just with empty strings inside.
The names are API_NINEROUTER_BASE_URL, API_NINEROUTER_API_KEY, and API_SEARCH_LLM_MODEL, a base URL, a key, and a model selector for the LLM-powered search layer. The comment in the diff says it plainly: when they are empty, the LLM smart search feature stays off and plain FULLTEXT search keeps working. Nothing else changed. And yet this little commit made me pause longer than most feature branches.
Passing env vars is one problem, being reachable is another
My first assumption was wrong. In my head: the env vars are in the container, so the moment the api service needs the LLM router on the same host, it just connects. Done.
Not quite. Handing over configuration and having a network path are two different problems. Compose only makes the variables appear inside the container. The container runs in its own network namespace [4], so the loopback address it knows points at itself, not at the host. A request to the router on the host stalls, not because the values are wrong, but because the path does not exist yet.
The annoying part: the merge went through, the env vars are in, and the feature stays off exactly like before. Nothing surfaces an error that points at networking, so the easy conclusion is a wrong value, when it is really a missing route.
The path to the host has to be built on purpose
Luckily the pattern is standard. The docker run reference documents a special host-gateway value that resolves to the host's internal IP [1]. On the Compose side, extra_hosts adds hostname mappings to the container's network configuration, and on Linux they land in the container's hosts file [2].
That is still only half the story. The host side has to cooperate. Uvicorn binds to the loopback address by default [3], which means a service on the host only hears connections from the host itself. If the LLM router runs with that default, no container on any bridge can reach it, no matter how good the hostname mapping is. The bind address has to be chosen deliberately.
As for the network side, containers without a network option land on the default bridge [4]. Docker Desktop is friendlier: it ships ready-made names where host.docker.internal resolves to the host's internal address and gateway.docker.internal to the VM gateway [5].
I deliberately wrote no numeric addresses here. Bridge gateways differ between machines, and the public examples lean on names and placeholders instead of raw addresses. Hardcoding an address in compose builds a config that is only correct on my laptop.
Empty defaults are the honest option
The decision I settled on: keep the env vars empty by default. An empty value is easy to read. The LLM feature is not configured, the FULLTEXT fallback stays in charge, done. No half-state to guess about.
Compare that with a fake value, a base URL that looks complete but points at something no container can reach. The feature looks on, and every request fails with an error that sends debugging in the wrong direction. My rule after this commit: empty beats fake, every time.
The rest is hygiene. The compose file names the variables but never carries their values; real values come from the deployment environment, which on my VPS is wired through my branch strategy across main, staging, and api. An API key never touches git history, and anyone cloning the repo gets no secrets of mine.
What this small commit left me with: config is only half the distance to a feature. The other half is the network path, and each one needs its own check. Next time someone tells me the env vars are already in, my first question is different: can the container actually reach the service?
Sources
- [1] docker run reference: the add-host flag and the host-gateway value
- [2] Compose Specification: extra_hosts in service definitions
- [3] Uvicorn docs: the host bind option and its default
- [4] Docker Docs: networking overview and the default bridge
- [5] Docker Desktop: special hostnames for reaching the host