feat(deploy): a Railway deployment of the analysed cases, behind one credential

Three services -- Postgres, API, UI -- with the API on Railway's private
network only, so the UI's /api proxy is the single public entry point and
there is no CORS.

The pipeline cannot run there. Nextflow shells out to `docker run` for VEP
and bcftools, and Railway gives you a container, not a Docker daemon. Rather
than leave a button that always fails, cases are annotated locally and copied
up by scripts/seed-remote.sh, and PUBLIC_PIPELINE_ENABLED=false hides the
analyse/score actions and the create-case form.

DATABASE_IDLE_CONNECTIONS=false is what makes idling work. Railway decides a
service is idle from its *outbound* traffic and sleeps it after ~5-10 minutes;
a pooled database connection is outbound traffic, so SQLAlchemy's default pool
would have kept the API awake and billable for ever. Setting it false switches
to NullPool, which costs a connection per request -- nothing at demo traffic,
the wrong trade under real load, hence the flag rather than a rewrite.

BASIC_AUTH_USER / BASIC_AUTH_PASSWORD put one shared credential in front of
the site. Nothing deployed is patient data, so this stops the URL being
wandered into rather than protecting anyone's privacy; unset, the site is
open, which is what local development wants. Compared in constant time, and
both halves of the credential are checked even when the first fails.
This commit is contained in:
Kemal Yaylali
2026-09-12 12:23:19 +01:00
parent e76ae847a1
commit c25fb53666
9 changed files with 239 additions and 9 deletions
+7 -1
View File
@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import Depends
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
@@ -21,7 +22,12 @@ def normalize_async_url(url: str) -> str:
return parsed.set(query=query).render_as_string(hide_password=False)
engine = create_async_engine(normalize_async_url(settings.database_url), pool_pre_ping=True)
# NullPool opens and closes a connection per checkout, so an idle API sends nothing and a host
# that sleeps idle containers can actually do so. See settings.database_idle_connections.
_pool = {} if settings.database_idle_connections else {"poolclass": NullPool}
engine = create_async_engine(
normalize_async_url(settings.database_url), pool_pre_ping=True, **_pool
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)