Learn8 · Deployment & Ops

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-root is required, not optional. pyproject declares packages = [{include = "src"}] and src/ 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 receives SIGTERM directly. 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 repo

Three 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 it

Alembic 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-deploy

Two CI failures worth knowing, because both are “works locally” traps:

  • pnpm version conflict. pnpm/action-setup@v4 was given version: 9 while package.json declared packageManager: "pnpm@9.0.0". It refuses both rather than picking one. The packageManager field is the single source of truth.
  • Docker cache driver. cache-from: type=gha is unsupported by Docker’s default driver, so the step failed before building a layer. Needs docker/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:

  1. QUERY_CACHE_TTL_SECONDS is 7 days there (the 5-minute default is right for a real deployment, where staleness is worse than a cheap re-query).
  2. After each deploy, scripts/warm_demo_cache.py asks each suggested question once, populating Redis.
  3. 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.
  4. 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

ConstraintConsequence
Web services sleep after 15 min idle~50 s cold start — warm it before a live demo
No background workersINLINE_WORKERS=true; jobs run in-process
Free Postgres expiresPlan to re-provision or upgrade
20 model requests/dayCache-first demo, graceful 503

Being able to state the constraints and the mitigation for each is the point.