ADR 0114 — Native Ollama detection in dev bootstrap: ask once, persist to .env, gate the compose services behind a profile
- Status: Accepted
- Date: 2026-08-26
- Context: closes a real port collision found during local dev bootstrap between the compose's
ollamaservice and a native Ollama install already running on the developer's machine - References (without editing): ADR 0025 (decision 10 —
pnpm devandmake deploy-localshare ports on purpose), ADR 0100 (introduced theollama/ollama-model-loaderpair this ADR gates)
Context
scripts/dev/preflight.mjs already existed to catch a known port clash
before docker compose up: pnpm dev and make deploy-local share ports
3000/4000/8080 on purpose (ADR 0025, decision 10), and an opaque port is already allocated from Docker doesn't say who's holding the port or what to
do about it. Preflight reads docker compose config, cross-references
docker ps and ss -tlnp, and reports every collision by name.
OLLAMA_PORT (default 11434) sits in that same set — and it is also the
default port of a native Ollama install, the kind a developer might
already run for unrelated reasons on their own machine. Unlike the k3d
clash, this collision has a legitimate resolution the generic conflict
report doesn't know about: reuse the native daemon instead of running a
second one in a container. Reported as "port 11434 ← ollama (unknown
process)" like any other conflict, the fix looks like a problem to route
around, when the actual answer is "let it be reused."
Decision
1. Confirm it's really Ollama before treating it specially
detectarOllamaNativo (scripts/dev/preflight.mjs:234) only special-cases
the port when a live GET to http://localhost:<porta>/api/tags answers with
the shape Ollama always returns, even with no model pulled yet
({"models":[]}, checked via ehOllama, preflight.mjs:159). A short
timeout (1.5s) matters here: this runs before any container comes up, so a
hang here would hang the entire docker compose up. Anything on the port
that doesn't answer like Ollama falls through to the existing generic
conflict report — this decision only touches the one case where the
occupant is provably the thing this ADR is about.
2. Ask once, apply the default without a TTY, and record the decision so it isn't asked again
perguntarUsoDoOllama (preflight.mjs:198) prompts "Detectamos Ollama já
rodando na porta N. Usar essa instância? [S/n]" — but only when
process.stdin.isTTY. bootstrap.sh's menu items run their command in the
background with stdin redirected from /dev/null, on purpose, so a running
command never steals the arrow keys the menu itself is reading; a prompt
that tried to read a line from a stream that's already closed would hang
forever. Without a TTY the function logs why and returns the default (yes)
directly — the same shape of guard the rest of the product already uses
whenever a decision needs a human but one might not be watching.
The answer is written to .env and never asked again for as long as
OLLAMA_MODE is present there: detectarOllamaNativo short-circuits at the
top the moment the key exists (preflight.mjs:236). Nothing in the normal
pnpm dev loop re-asks — the one way to reopen the question is the new menu
item in Decision 5.
3. escreverEnv — the first thing in this repo that writes .env, not just reads it
Every existing script that touches .env (reset-total.sh's *_TEST_KEY
lookups, this same file's own lerEnv) only ever greps a value out. Nothing
before this needed to update one, so there was no existing bash pattern to
reuse for that direction. escreverEnv (preflight.mjs:118) is Node
because the update needs one property bash's line-oriented tools don't give
for free without extra care: read the file, rewrite a line in place when its
key already exists, append the key at the end when it doesn't, and leave
every comment and every other key untouched. On the "yes, use it" path it
writes OLLAMA_MODE=host and OLLAMA_HOST=http://host.docker.internal:<porta>;
on "no" it writes OLLAMA_MODE=container plus a fresh OLLAMA_PORT found by
scanning upward from 11500 (proximaPortaLivre, preflight.mjs:178) so
the container gets a free port instead of colliding with the native one it
was just told to leave alone.
4. Gate ollama/ollama-model-loader behind a compose profile
Both services (introduced by ADR 0100) move under profiles: ["local-llm"]
(docker/docker-compose.yml:82,119) — the same pattern
docker-compose.prod.yml already uses for the equivalent pair under the
literal name llm (different file, so the literal doesn't need to match).
A profiled service doesn't start with a bare docker compose up, which is
exactly what OLLAMA_MODE=host needs: not "started but unreachable," but
never started at all, so it never competes for the port and never burns RAM
loading a model nothing will query.
5. scripts/dev/perfil-ollama.sh decides --profile local-llm at the moment each command runs
One small script, not a duplicated grep in three places: it prints
--profile local-llm unless .env already says OLLAMA_MODE=host, and
nothing otherwise. It is consumed by the only two docker compose callers
that bring up the whole stack rather than one named service — "Docker ›
Deploy › All" and "Docker › Create" (bootstrap.sh, items 1.1.1/1.2) —
via $(bash scripts/dev/perfil-ollama.sh), written escaped as
\$(...) in the command-tree source so it evaluates when the menu item is
executed, not when the tree is built (the same escaping already used for
\${POSTGRES_USER} in Database › Delete's command). Api/Engine/Web
(1.1.2–1.1.4) target one service each and never touch ollama, so they
were left unchanged.
6. "Docker › Reconfigurar Ollama" (1.5) reopens the question
scripts/dev/reconfigurar-ollama.sh removes OLLAMA_MODE and OLLAMA_HOST
from .env unconditionally, and OLLAMA_PORT only when the mode being
forgotten was container — in host mode that port was written by whoever
installed Ollama natively, not by this mechanism, and deleting it would make
the next boot silently forget a value that was never this feature's to own.
The item needs no confirmation screen: it changes at most three lines of
.env and nothing destructive to data, the same bar the rest of the
non-trivial-but-non-destructive menu items already clear (Generate,
Migrate, Seed).
Consequences
A profile that hides the port also hides a real conflict, so the
detection path had to grow its own. With ollama under an inactive
profile, docker compose config no longer lists port 11434 at all by
default — the generic per-port loop that used to catch "something else is
squatting on 11434" would simply stop seeing it, a silent regression versus
the behavior that existed before this ADR. detectarOllamaNativo closes
that gap itself: when the port is occupied by something that answers
anything other than Ollama's shape, it returns a conflict entry in the exact
format the generic loop already produces, so it lands in the same report.
Removing api's depends_on: ollama was a forced, not incidental,
change. Compose refuses the whole file with "depends on undefined
service" when a service outside an inactive profile depends on one inside
it — confirmed by testing in an isolated sandbox before touching the real
file, and it broke every docker compose invocation (up, down, ps,
even config, which preflight.mjs itself calls). The dependency was
already vestigial for compose's purposes: api calls Ollama at agent-turn
runtime, never at its own boot, so nothing about startup ordering was lost.
"Docker › Destroy" also passes through the same conditional --profile.
docker compose down without --profile ignores containers that belong to
an inactive profile — if ollama/ollama-model-loader came up under
local-llm, a plain down leaves them running and can leave the network in
a "still in use" state. CMD["1.3"] in bootstrap.sh now resolves through
the same scripts/dev/perfil-ollama.sh helper the up paths use, so Destroy
tears down exactly what the matching up brought up — covered by
bootstrap.spec.ts's "Docker › Destroy também passa pelo perfil do Ollama"
case.
Declared, not fixed here: a project's own idle ollama container can be
mistaken for a native install. If a developer switches to
OLLAMA_MODE=container but doesn't bring the containers down first,
detectarOllamaNativo has no way to tell "a native install is on this
port" from "the project's own ollama container, from a previous run, is
still on this port" — it would ask again in that case. Not implemented: the
sequence is uncommon (reconfiguring without stopping the stack first) and
was out of the scope of the request that produced this feature.
Append order in .env is not curated. When none of the three keys
exist yet, escreverEnv appends them at the end of the file, not next to
the OLLAMA_PORT/OLLAMA_HOST block .env.example already documents them
around. Cosmetic, not a correctness problem — the values are exactly as
valid wherever they land in the file.
Alternatives considered
- Keep reporting the occupied port as a generic conflict and let the
developer resolve it by hand through
.env. Rejected: this is a common, benign situation, not an error — labeling it identically to "the k3d cluster is up" or "an unrelated process is on this port" teaches the developer to work around something that has a one-line fix, every single time they start the stack. - Detect and silently reuse the native instance, no question asked.
Rejected on the same principle the rest of the product already holds for
configuration it doesn't own (CLAUDE.md: the product never overwrites a
user's configuration without an explicit decision) — even a low-stakes
dev convenience gets one explicit question, asked once, not a silent
default a developer only discovers later by reading
.env. - Leave
ollama/ollama-model-loaderoutside any profile and just stop publishing the port inhostmode. Rejected: the containers would still start, still load a model into memory, and still spend CPU/RAM that nothing would ever query — a profile keeps them from starting at all, which is what "use the native one instead" actually means.