Module 7 · DevOps · Deep Dive

Deploy & CI

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

Why this matters A capstone that only runs on 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.
In this lesson
  1. The deployment shape
  2. Managed Postgres & the connection string
  3. Deploy the frontend to Vercel
  4. Deploy the backend container
  5. CI with GitHub Actions
  6. Production basics
  7. Migrations in the deploy pipeline
  8. Health checks & readiness
  9. Rollback strategy
  10. Observability
  11. A fuller GitHub Actions pipeline
  12. Edge protection & secret rotation
  13. Build: ship DocChat live
  14. Check yourself

1 · The deployment shape

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:

PieceWhat it isWhere it goes
FrontendNext.js app (the UI)Vercel
BackendFastAPI API (RAG, auth)Render / Railway / Fly.io
DatabasePostgres (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.

2 · Managed Postgres & the connection string

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"
Run migrations against the cloud DB A fresh managed database is empty. Point Alembic at the production DATABASE_URL once and run alembic upgrade head to create your tables before the app goes live.

3 · Deploy the frontend to Vercel

Vercel is the company that makes Next.js, so this is the smoothest path on the planet. The flow:

  1. Push your repo to GitHub.
  2. On vercel.com, Import Project and pick the repo. Vercel auto-detects Next.js — no config needed.
  3. Set environment variables in the dashboard (Settings → Environment Variables). For DocChat the frontend needs the backend's URL:
# 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_ prefix Only env vars prefixed NEXT_PUBLIC_ are exposed to the browser. Your secret keys must not carry that prefix — keep them server-side, on the backend host.
Interview hook: "How does CI/CD work for your frontend?" — Answer: "Vercel is wired to GitHub; a push to main triggers a production build and deploy, and every PR gets an isolated preview deployment. Zero manual steps."

4 · Deploy the backend container

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
CORS: the #1 deploy gotcha In dev, frontend and backend shared 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.

5 · CI with GitHub Actions

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.

CI vs CD — know the difference CI = the robot tests your code on every push (GitHub Actions above). CD = deploying automatically when CI passes (Vercel and Render already do this for you on push). Together: push → tested → deployed, hands-free.
Interview hook: "What does your CI run?" — Answer: "On every push and PR it checks out the code, installs deps, lints with ruff, and runs pytest. A red pipeline blocks the merge, so main stays shippable."

6 · Production basics

You don't need heavy DevOps for a capstone, but know these four so you can talk about them:

The cardinal rule Never commit .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.

7 · Migrations in the deploy pipeline

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:

Never drop in the same release Never drop or rename a column in the same release that stops using it. The old container is still live during the rollover and will query the column you just deleted. Split it across two deploys, always.
Interview answer · zero-downtime schema change

"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."

8 · Health checks & readiness

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:

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.

Interview hook: "What's the difference between liveness and readiness?" — Answer: "Liveness says restart me if I'm dead; readiness says stop sending me traffic until I can reach my dependencies. Readiness is what makes rolling deploys downtime-free."

9 · Rollback strategy

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.

Rollback + migrations are a pair Rollback only works cleanly if your migrations were backward-compatible (Section 7). If you dropped a column, rolling the code back to a version that still needs it will crash. Expand-contract is what makes rollback safe — design them together.

10 · Observability

"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.

PHP bridge: the PHP instinct was to 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.

11 · A fuller GitHub Actions pipeline

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.

Interview hook: "How do your tests hit a database in CI?" — Answer: "A Postgres service container in the workflow — GitHub spins up postgres:16 next to the job, waits on pg_isready, and I point DATABASE_URL at it. Real DB, fully disposable, fresh every run."

12 · Edge protection & secret rotation

Two quick hardening habits worth a sentence in an interview:

13 · Build it

Your tangible win Take DocChat live end-to-end: Postgres on Neon, the FastAPI backend in a container on Render (using your 7.1 Dockerfile), the Next.js frontend on Vercel — and add a CI workflow that runs pytest on every push. The deliverable is a public URL you paste into your next application.

Work the pieces in dependency order — database first, then backend, then frontend:

  1. Neon: create a project, copy the DATABASE_URL, run alembic upgrade head against it.
  2. Render: connect the repo, set DATABASE_URL, OPENAI_API_KEY, JWT_SECRET, FRONTEND_ORIGIN. Deploy. Note the API URL.
  3. CORS: add your Vercel origin to allow_origins and push.
  4. Vercel: import the repo, set NEXT_PUBLIC_API_URL to the Render URL. Deploy.
  5. CI: commit .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.

14 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

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?

Primary source ⭐ Vercel Documentation — the authoritative guide to deploying Next.js, environment variables, and custom domains. For CI, the canonical reference is GitHub Actions docs.