Deployment
The repository supports exactly two deployment shapes, and both are described here. They are not interchangeable configurations of one design: they differ in how the containers reach each other, and one difference — whether the browser treats the web app and the API as the same site — has a real consequence for how sessions are delivered.
Shape 1: docker compose, one host
The local shape, and the one a reviewer meets first: docker compose up --build
brings up Postgres, waits for it to be genuinely healthy, applies migrations,
seeds demonstration data, and serves the application. There is no second terminal
and no manual step.
Two things about this shape are worth stating.
Postgres is on the critical path; the model is not. The API declares a
dependency on Postgres being healthy and deliberately declares none on the model
runtime, whose first boot pulls roughly two gigabytes. The application is fully
usable throughout that pull — the assist surfaces simply report unavailable until
the model is there. The API's own healthcheck is /api/health, which also
reports whether a SELECT 1 succeeds, so the web app starts only once the API
can actually reach its database.
The web app and the API share a site. Both are on localhost, and a port
does not make a site, so the session cookie's default SameSite=Lax is sent on
the web app's requests to the API. This is the stronger cookie posture and the
one this shape gets for free.
Shape 2: Fly.io, separate apps, region sin
The deployed shape. Three Fly apps, one of them private, plus a Fly Postgres instance.
The model runtime is genuinely private, but read the mechanism carefully. The
service block does declare port 11434 — Fly requires a port to be declared — but
the app is allocated only a private IPv6 address (fly ips allocate-v6 --private), so that port listens on Fly's internal network and nowhere else.
Privacy comes from the address, not from the absence of a port. The model is
reachable only from other apps in the same organisation, at
remedyo-ollama.flycast:11434, and the API is the only thing that calls it.
Worth verifying after a deploy rather than assuming: fly ips list --app remedyo-ollama should show a private address and no public one. If a public
address was allocated by mistake, the model is on the internet and the address
should be released.
This shape needs a different cookie setting, and that is not a preference.
remedyo-web.fly.dev and remedyo-api.fly.dev look like sibling subdomains, but
fly.dev is on the Public Suffix List, so browsers treat them as two different
sites. A SameSite=Lax cookie is discarded on that cross-site request, which
makes every login succeed and then appear to fail. The API therefore runs with
CROSS_SITE_COOKIE=true, which sets SameSite=None; Secure.
The cost is real and is stated rather than hidden: with SameSite=None the
cookie rides along on cross-site requests, so CSRF no longer has SameSite as a
backstop. What remains is the single allowed CORS origin and the fact that every
mutating route requires a JSON body, which forces a preflight a foreign origin
cannot pass. Serving both behind one hostname would remove the trade-off
entirely, and is the better long-term fix.
Clinical assist is off here by default. LLM_ENABLED=false on the API app
means the assist surfaces are absent. Turning it on requires deploying the model
app, which needs a memory size no shared-CPU instance provides — the model
runtime is the one component with a real hosting cost. The comment in
apps/ollama/fly.toml states that cost plainly rather than leaving it to be
discovered on a bill.
One machine is held running on the API so that the migrate-and-seed start command cannot run concurrently on two machines against the same database. The web app is allowed to scale to zero.
What both shapes share
The same three images and the same configuration surface: DATABASE_URL,
JWT_SECRET, WEB_ORIGIN, CROSS_SITE_COOKIE, LLM_ENABLED, LLM_BASE_URL
and LLM_MODEL. Nothing about the application's behaviour is decided by which
shape it is running in — only how the containers find each other, and whether
the browser considers them one site.