Module 7 · DevOps · Deep Dive
Turn your repo into a public URL you can paste into a job application — frontend on Vercel, backend in a container, Postgres managed in the cloud, all guarded by CI.
BasicIntermediateBuild
localhost is invisible. The single most powerful line on a UAE application is a live link a hiring manager can click. This lesson takes DocChat from your machine to the open internet: the Next.js frontend, the FastAPI backend, and a real managed Postgres — plus a CI pipeline that runs your tests on every push so you never ship a red build.
The goal is concrete: a URL like https://docchat.vercel.app that works for anyone, anywhere. To get there you deploy three separate pieces, each to the host that fits it best:
| Piece | What it is | Where it goes |
|---|---|---|
| Frontend | Next.js app (the UI) | Vercel |
| Backend | FastAPI API (RAG, auth) | Render / Railway / Fly.io |
| Database | Postgres (docs, users) | Neon / Supabase |
They find each other over HTTPS: the browser loads the frontend from Vercel, the frontend calls the backend's public URL, and the backend talks to Postgres over a connection string. Three URLs, wired by environment variables — never hardcoded.
PHP bridge: in PHP you often FTP'd everything to one cPanel host. Here the pieces are split across managed hosts — but the mental model is the same: code somewhere public, config in the environment, database separate.Do not run your own database server. A managed Postgres provider hosts it, patches it, and backs it up. Two excellent free tiers in 2026:
You sign up, create a project, and copy a connection string. It looks like this:
# Neon connection string — keep it secret, it's a password
postgresql://user:pass@ep-cool-name.eu-central-1.aws.neon.tech/docchat?sslmode=require
That single string is the value of your DATABASE_URL environment variable — the same one SQLAlchemy reads in Module 3. You set it as a secret in each host's dashboard, not in your code:
# locally, in .env (which is gitignored — never committed) DATABASE_URL="postgresql://user:pass@ep-cool-name.../docchat?sslmode=require"
DATABASE_URL once and run alembic upgrade head to create your tables before the app goes live.
Vercel is the company that makes Next.js, so this is the smoothest path on the planet. The flow:
# Vercel dashboard env var — public because the browser uses it
NEXT_PUBLIC_API_URL=https://docchat-api.onrender.com
After that first import, deployment is automatic: every git push to main auto-deploys to production, and every pull request gets its own preview URL. You never run a deploy command by hand.
NEXT_PUBLIC_ are exposed to the browser. Your secret keys must not carry that prefix — keep them server-side, on the backend host.
FastAPI needs a running Python process, so it goes to a container host. You reuse the Dockerfile you wrote in Lesson 7.1 — that's the whole point of having containerised early. Good hosts:
The flow mirrors Vercel: connect GitHub, the host detects the Dockerfile, builds it, and runs your start command. Set the backend's secrets in its dashboard:
# Backend host env vars (Render / Railway / Fly.io dashboard) DATABASE_URL=postgresql://user:pass@ep-cool-name.../docchat?sslmode=require OPENAI_API_KEY=sk-... # for the RAG embeddings/LLM JWT_SECRET=a-long-random-string # for auth tokens FRONTEND_ORIGIN=https://docchat.vercel.app
The container's start command is the production server — not the dev reloader:
# in the Dockerfile (CMD) — bind to the port the host gives you uvicorn app.main:app --host 0.0.0.0 --port $PORT
localhost so CORS never bit you. In production they live on different domains, so the browser blocks the call unless the backend explicitly allows the production frontend origin:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://docchat.vercel.app"], # the EXACT prod origin allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )
An alternative for small APIs is Vercel Python functions (serverless), which keeps everything on one platform — fine for light endpoints, but a container host is the better fit for DocChat's RAG workload.
CI (Continuous Integration) means: on every push, a robot installs your project, runs your tests, and lints/builds it — so a broken change is caught before it reaches users. On GitHub you do this with GitHub Actions: a YAML file at .github/workflows/ci.yml.
The structure is always the same: a trigger (on:), one or more jobs, each a list of steps. Here's a correct, minimal pipeline for the FastAPI backend:
.github/workflows/ci.yml
name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: pip install -r requirements.txt - name: Lint run: ruff check . - name: Run tests run: pytest -q
Push that file and GitHub runs it automatically. The result shows as a green check (or red X) on every commit and pull request. You can require the check to pass before merging — that's how teams keep main always-deployable.
You don't need heavy DevOps for a capstone, but know these four so you can talk about them:
docchat.com at Vercel by adding the domain in the dashboard and updating two DNS records. Optional, but a real domain reads as professional to a recruiter..env, API keys, or connection strings. Add .env to .gitignore and inject every secret through the host's dashboard. Leaked keys in a public repo get scraped within minutes.
In Module 3 you ran alembic upgrade head by hand. In a real deploy that step must be automatic — otherwise someone forgets, and the new code hits a table that doesn't exist yet. Wire it into the deploy itself, either as a release / pre-deploy step the platform runs before swapping traffic, or as the first thing your container entrypoint does:
entrypoint.sh — runs on every container start, before the server
#!/bin/sh set -e # apply any pending migrations against the live DATABASE_URL alembic upgrade head # then start the production server exec uvicorn app.main:app --host 0.0.0.0 --port $PORT
On Render you'd set this as a Pre-Deploy Command; on Railway/Fly the entrypoint pattern above is common. Either way, schema and code ship together.
But automation is only safe if the migrations themselves are safe. During a rolling deploy, the platform keeps the old container serving traffic while the new one boots — so for a few seconds old code and new code run against the same database at once. A migration that the old code can't tolerate will throw 500s for every user still on the old version.
The golden rule is the expand → migrate → contract pattern — make every change backward-compatible:
"How do you ship a DB schema change with zero downtime?" — "I use backward-compatible migrations: expand, migrate, then contract. Say I'm renaming name to full_name. Release 1 expands — adds the new full_name column (nullable) and has the code write to both. I backfill the old rows. Release 2 reads only full_name. Release 3 contracts — drops name, now that nothing references it. Because every release is forward- and backward-compatible, a rolling deploy where old and new pods overlap never breaks, so I never need a maintenance window. I never drop or rename a column in the same release that stops using it."
The platform needs a way to ask your app "are you alive?" and "are you ready for traffic?". You answer with two tiny endpoints — the distinction matters:
/health) — "is the process running?" If this fails, the platform restarts the container. It must not touch the DB — you don't want a brief DB blip to trigger a restart loop./ready) — "can I actually serve a request?" — i.e. can I reach Postgres? If this fails, the platform stops routing traffic to this instance but leaves it running, so it can recover.from fastapi import FastAPI from sqlalchemy import text app = FastAPI() @app.get("/health") # liveness — cheap, no I/O def health(): return {"status": "ok"} @app.get("/ready") # readiness — proves the DB is reachable def ready(db = Depends(get_db)): db.execute(text("SELECT 1")) return {"status": "ready"}
This is the backbone of zero-downtime deploys: when a new container boots, the platform waits for /ready to go green before sending it any traffic and before retiring the old one. No request ever lands on a half-started instance.
Things break in production. The grown-up answer isn't "hotfix in a panic" — it's roll back to the last good deploy in one click, then debug calmly. Every host gives you this:
Tag every image with the immutable git SHA, never :latest:
# build & push tagged with the commit SHA — every build is a distinct, recoverable artifact docker build -t docchat-api:$GIT_SHA . docker push registry.example.com/docchat-api:$GIT_SHA # rollback = redeploy the previous known-good SHA deploy docchat-api:a1b2c3d
:latest is a moving target — you can't tell which code it points at, and you can't reliably go back to "the one before". A git SHA is permanent and exact: a1b2c3d always means that build, forever.
"It works on my machine" is meaningless once the code runs on a host you can't see. Observability is how you understand a system from the outside — three pillars:
The single highest-leverage upgrade for a capstone is structured JSON logging. A plain string log is hard to search; a JSON log is queryable — you can filter by user, status, or a correlation ID that ties every line from one request together:
# instead of: print("upload failed for user 7") import logging, json logger.info(json.dumps({ "event": "upload_failed", "request_id": request_id, # correlation ID — one value per request "user_id": 7, "status": 500, }))
For exceptions, plug in Sentry — it captures every unhandled error with the full stack trace and context (which user, which request, which release), and groups duplicates so you fix the cause, not chase repeats:
import sentry_sdk sentry_sdk.init( dsn=os.environ["SENTRY_DSN"], traces_sample_rate=0.1, # sample 10% of traces for performance ) # unhandled exceptions are now auto-captured with full context
Round it out with uptime monitoring — a service that pings /health every minute and alerts you (or just checks the page is up) the moment it goes down. Now production isn't a black box.
error_log() a string and grep error.log over SSH. Structured JSON + Sentry is the same instinct, made searchable and pushed to you — no SSH, no grep, alerted before the user complains.
The Section 5 pipeline lints and tests. A production-grade one does four stages in order — lint → test → build → deploy — and adds two real-world touches: dependency caching (so installs are fast) and a Postgres service container (so tests run against a real database, not a mock):
.github/workflows/ci.yml
name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest services: postgres: # a throwaway Postgres just for this job image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: docchat_test ports: ["5432:5432"] options: >- --health-cmd pg_isready --health-interval 10s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip # cache deps — skips re-download on repeat runs - name: Install dependencies run: pip install -r requirements.txt - name: Lint run: ruff check . - name: Run tests env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/docchat_test run: pytest -q deploy: needs: test # only runs if the test job passed if: github.ref == 'refs/heads/main' # deploy only on merge to main, not PRs runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build image run: docker build -t docchat-api:${{ github.sha }} . - name: Deploy run: curl -X POST "$RENDER_DEPLOY_HOOK" # trigger the host to roll out env: RENDER_DEPLOY_HOOK: ${{ secrets.RENDER_DEPLOY_HOOK }}
Read the shape: the services: block boots a real postgres:16 alongside the job and waits for pg_isready before tests run; cache: pip reuses downloaded wheels across runs; the second job needs: test so a red test blocks deploy; and if: github.ref == 'refs/heads/main' means PRs get tested but only a merge to main ships. That's the full lint → test → build → deploy gate.
Two quick hardening habits worth a sentence in an interview:
slowapi on FastAPI. A login endpoint capped at, say, 5 attempts/minute stops credential-stuffing cheaply.JWT_SECRET, API keys, and the DB password periodically, and immediately if one ever leaks. Because every secret lives in a dashboard env var (never in code), rotating is just: generate a new value, update it in the host, redeploy — no code change.Work the pieces in dependency order — database first, then backend, then frontend:
DATABASE_URL, run alembic upgrade head against it.DATABASE_URL, OPENAI_API_KEY, JWT_SECRET, FRONTEND_ORIGIN. Deploy. Note the API URL.allow_origins and push.NEXT_PUBLIC_API_URL to the Render URL. Deploy..github/workflows/ci.yml and watch the green check appear.When all three URLs talk to each other and the green check lands, DocChat is genuinely production-shaped — the exact thing an interviewer wants to click.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Where does the Next.js frontend deploy best?
Where should the database password live?
What blocks a cross-domain browser API call?
When does a GitHub Actions workflow run here?
What does the CI pipeline actually do?
Which migration pattern gives zero downtime?
What makes a rolling deploy downtime-free?