8 · Deployment & Ops
Two platforms, because they solve different problems.
retailmind.paraspipre.com docs.retailmind.paraspipre.com
│ │
┌─────────▼─────────┐ ┌─────────▼─────────┐
│ Vercel: demo app │ │ Vercel: docs │
└─────────┬─────────┘ └───────────────────┘
│ api.retailmind.paraspipre.com
┌─────────▼──────────────────────────────────────┐
│ Render: API (Docker) · Postgres · Redis │
└────────────────────────────────────────────────┘Vercel cannot host the API. Redis, Postgres, a long-lived job runner and SSE streaming do not fit serverless functions. Knowing why a platform is wrong is a better answer than knowing which one is right.
The container
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY pyproject.toml poetry.lock* ./
RUN poetry config virtualenvs.create false \
&& poetry install --only main --no-interaction --no-ansi --no-root
COPY src/ ./src/
COPY alembic.ini docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh
RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app
USER appuser
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]Every line is a decision:
PYTHONUNBUFFERED=1— logs reach the platform’s collector as they happen rather than sitting in a buffer until the process exits, which is what makes a crashed container look silent.- Dependencies before source — editing code does not invalidate the dependency layer, so rebuilds are seconds not minutes.
--no-rootis required, not optional.pyprojectdeclarespackages = [{include = "src"}]andsrc/does not exist at that layer, so without it Poetry tries to install the project itself and the build fails.- Non-root user — limits what a compromise through a dependency can reach.
- 3.13-slim — the 3.11 images carry known critical CVEs.
- Healthcheck hits
/health, not/health/ready. Liveness must not depend on Postgres, or a brief database blip makes the platform kill an otherwise healthy container.
Liveness vs readiness
@app.get("/health")
async def health():
"""Liveness probe. Always 200 if the process is up."""
return {"status": "ok", "version": VERSION, "env": settings.api_env}
@app.get("/health/ready")
async def readiness():
"""Readiness probe — actually touches Postgres and Redis."""Different questions. Is the process alive? (restart me if not) versus can it serve traffic? (route around me if not). Conflating them causes restart loops during a dependency outage — the process gets killed, comes back, still cannot reach the database, gets killed again.
Migrations
Alembic, run from the container entrypoint:
set -e
if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
echo "entrypoint: applying database migrations"
alembic upgrade head
fi
exec "$@"Two details that matter more than they look:
set -e— if the migration fails, the script exits non-zero, the container dies, and the deploy is marked failed. Without it the app would boot against a schema it does not match and fail at request time instead.exec— the server replaces the shell as PID 1 and receivesSIGTERMdirectly. Without it the shell holds PID 1, swallows the signal, and every deploy waits out the platform’s grace period before being killed.
This runs once per container, not once per deploy. Several instances starting together would race; Alembic locks its version table so the losers no-op, but a real pre-deploy step is cleaner. It lives here because the free tier rejects preDeployCommand — a constraint, not a preference, and worth saying so.
Infrastructure as code
render.yaml declares Postgres, Redis and the API in one file, read on every push to main. Infrastructure changes ship the same way code does — reviewable, revertable, reproducible.
- key: DATABASE_URL
fromDatabase: { name: retailmind-db, property: connectionString }
- key: SECRET_KEY
generateValue: true # platform generates and stores it
- key: ENCRYPTION_KEY
sync: false # entered by hand, never in the repoThree ways to handle a value: derived from another resource, generated by the platform, or entered manually. No secret is in the repository.
Two config bugs that failed the deploy
Both were shape mismatches between the platform’s output and the app’s expectations. Both are extremely common.
1 · The database URL driver
Render’s connectionString is postgresql://…. SQLAlchemy’s create_async_engine needs postgresql+asyncpg://…. Given the sync form it looks for psycopg2, which is not a dependency — so alembic upgrade head died at startup and the whole deploy failed.
@field_validator("database_url", mode="before")
@classmethod
def _normalize_database_url(cls, v):
if url.startswith("postgres://"): # Heroku-style; SQLAlchemy rejects it
url = "postgresql://" + url[len("postgres://"):]
if url.startswith("postgresql://"): # only when no driver was given
url = "postgresql+asyncpg://" + url[len("postgresql://"):]
# libpq says sslmode=require; asyncpg wants ssl= — translate itAlembic now reads through Settings rather than os.environ, so migrations and the app cannot disagree about the URL.
2 · CORS parsing crashed the app at import
API_CORS_ORIGINS=https://retailmind.paraspipre.com — the natural thing to type into a dashboard — raised SettingsError before the app could start.
Why: for a list-typed field, pydantic-settings calls json.loads() on the environment value before any field validator runs. The comma-splitting validator written for exactly this case never got the chance to execute.
api_cors_origins: Annotated[list[str], NoDecode] = [...]NoDecode disables the source-level JSON decoding so the raw string reaches the validator, which now accepts both comma-separated and JSON-array forms.
The generalisable lesson: framework magic that runs before your code is the hardest kind to debug, because the error names neither the offending value nor the fix. tests/test_config.py now boots Settings from the values parsed out of render.yaml itself, so a blueprint the app cannot load fails in CI rather than on the platform.
CI
jobs:
api: # ruff + pytest on Python 3.13
web: # typecheck + build, matrix over [demo, docs]
docker: # build the image so Dockerfile breakage surfaces here, not mid-deployTwo CI failures worth knowing, because both are “works locally” traps:
- pnpm version conflict.
pnpm/action-setup@v4was givenversion: 9whilepackage.jsondeclaredpackageManager: "pnpm@9.0.0". It refuses both rather than picking one. ThepackageManagerfield is the single source of truth. - Docker cache driver.
cache-from: type=ghais unsupported by Docker’s default driver, so the step failed before building a layer. Needsdocker/setup-buildx-action@v3.
Surviving a 20-request-per-day quota
The public demo runs on Gemini’s free tier. It survives because it mostly does not call the model:
QUERY_CACHE_TTL_SECONDSis 7 days there (the 5-minute default is right for a real deployment, where staleness is worse than a cheap re-query).- After each deploy,
scripts/warm_demo_cache.pyasks each suggested question once, populating Redis. - The question list lives in
example-queries.json, imported by the UI and read by the warmer, so a question can never appear in the UI that the warmer does not know about. - Free-form questions still hit the model; when the quota runs out they get a 503 with
Retry-After, while the suggested ones keep working.
This is a real engineering pattern, not a hack. “Pre-compute the common path, degrade gracefully on the tail” is how you serve a long tail cheaply. Frame it that way.
Build-time vs runtime configuration
if (process.env.VERCEL_ENV === "production" && !process.env.NEXT_PUBLIC_API_BASE) {
console.warn("NEXT_PUBLIC_API_BASE is not set — the demo will fail with 'Failed to fetch'");
}NEXT_PUBLIC_* values are compiled into the browser bundle. Setting one in the dashboard does nothing until you redeploy. This caused a live failure: the deployed demo had http://localhost:8000/v1 baked in, so every visitor’s browser tried to reach their own machine.
The build-time warning exists so the reason appears in the deploy log rather than as a silent 404.
Multi-zone, and why it was dropped
Docs originally served at /docs of the demo via Next.js Multi Zones: the docs app built with basePath: "/docs" and the demo rewrote /docs/* to it. That let a pages-router app (Nextra) and an app-router app share one hostname without porting either.
basePath is the load-bearing part — without it the docs HTML returns fine but requests its CSS/JS from /_next/… at the root, where the demo answers instead, and the docs render unstyled.
Once docs got their own subdomain the machinery stopped earning its keep, so /docs became a plain 307 redirect (not 308 — a permanent redirect is cached indefinitely by browsers, and this URL layout had already changed once).
Free-tier realities
| Constraint | Consequence |
|---|---|
| Web services sleep after 15 min idle | ~50 s cold start — warm it before a live demo |
| No background workers | INLINE_WORKERS=true; jobs run in-process |
| Free Postgres expires | Plan to re-provision or upgrade |
| 20 model requests/day | Cache-first demo, graceful 503 |
Being able to state the constraints and the mitigation for each is the point.