Deployment
Deployment
Ship both services to Railway, survive the first failed build, and verify grounded answers in production.
By the end of this stage the app is live. The FastAPI backend and the React frontend each run as a Railway service on a public URL, talking to the same hosted Supabase project you have used all along. You can send someone a link, they can log in, and they get grounded answers with citations from a real server.
This stage exists because a project that only runs on your laptop is not a product. It is also where the video gets honest. The first deploy fails, and the fixes teach you more than a clean run would have.
daveebbelaar/document-copilot2:59:00Why Railway
Dave has never used Railway before this video. That is the point. It is picked for tutorial simplicity, because it can take a repo and turn it into running services with public URLs in minutes. On real client projects Datalumina deploys to Azure or Hetzner (or AWS) with hand-written Docker setups and CI/CD, but that would eat an hour of video without teaching anything new about this app.
The deployment takes this shape:
- One Railway project, two services from the single monorepo.
document-copilot-backendbuilds frombackend/(FastAPI + Uvicorn).document-copilot-frontendbuilds fromfrontend/(Vite build served by Caddy).- Supabase stays hosted at Supabase. Do not add Railway Postgres for this app.
The full click-through and CLI reference lives in docs/guides/railway-deployment.md in the repo, updated after the deploy with everything that went wrong.
Scope the GitHub authorization
When Railway asks to connect to GitHub, it defaults to requesting access to all your repositories. Do not accept that. If the platform (or anything acting on its behalf) is ever compromised, the blast radius is every repo you own.
Choose "Only select repositories" and grant access to document-copilot alone. Dave calls this out as a habit for every tool you try, not just Railway.
Drive the setup with the CLI and MCP server
Rather than clicking through service settings twice (once per service), install the Railway CLI and add the Railway MCP server to your editor, then let the agent do the setup:
brew install railway
railway loginWith the MCP server registered in Cursor, the prompt is roughly to confirm access with whoami, create a project named document-copilot, read the deployment guide and the repo structure, then create both services, set the variables, and deploy. The MCP server is best for project creation, status, and logs; the CLI is still the most direct way to upload monorepo subdirectories, set variables, and generate domains.
This is yolo mode
Handing an agent your credentials and control over deployments is not how production works. Dave is explicit that he only accepts it here because every document in the corpus is public SEC data and he will nuke the database and rotate every API key after recording. On client projects, secrets live in a vault, they never sit in the project, and deployments go through a manual or CI/CD pipeline that no agent can touch. If you follow this path with your own keys, rotate them when you are done.
Environment variables per service
Each service gets its own variables, mirroring the local .env files. The backend needs its secrets at runtime:
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_ANON_KEY=your-anon-public-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-secret-key
DATABASE_URL=postgresql://postgres:your-password@db.your-project-ref.supabase.co:5432/postgres
OPENAI_API_KEY=sk-your-openai-api-key
ALLOWED_ORIGINS=https://your-frontend.up.railway.appUse the direct Supabase Postgres URL for DATABASE_URL, not the transaction pooler URL. Railway's raw editor lets you paste a whole block like this at once, which is also where you keep production values distinct from local ones.
The frontend is different in one important way. VITE_* values are baked into the JavaScript bundle at build time, not read at runtime. Set them before deploying, and redeploy the frontend whenever they change:
VITE_API_BASE_URL=https://your-backend.up.railway.app
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-public-keyLocally VITE_API_BASE_URL points at http://localhost:8000. In production it must point at the backend's public Railway domain, otherwise the deployed frontend will try to call an API on the visitor's own machine.
The mental model
If this is your first deployment, Dave's framing removes the black magic. You already run this app locally with two startup commands. Deploying is nothing more than pushing that code to a cloud server (a computer that is always on, rented from someone else) and running the startup command there. Railway adds the convenience of building the code and exposing the result on a public domain, which you would otherwise wire up yourself.
Once that clicks, the platform stops mattering. The principles are the same on Railway, Azure, Hetzner, or a bare VM.
The first build fails
The backend build errors out on the first attempt, and fixing it produces the Docker setup now in the repo. Railway auto-detects a Dockerfile in each service's root directory and uses it instead of guessing at build commands.
The backend image installs dependencies with uv and starts Uvicorn, binding to the port Railway hands it:
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY alembic ./alembic
COPY alembic.ini ./
COPY app ./app
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]The frontend is a two-stage build in frontend/Dockerfile. A Node stage runs pnpm build (with the VITE_* values passed in as build args), then a Caddy stage serves the static dist/ output:
ARG VITE_API_BASE_URL
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
RUN pnpm build
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srvBecause the frontend is a single-page app, Caddy needs a fallback that serves index.html for any client-side route. frontend/Caddyfile handles that, with one detail that matters:
:{$PORT:3000} {
@health path /health
handle @health {
respond "ok" 200
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}The /health route sits ahead of the SPA fallback. If it did not, Railway's healthcheck would receive index.html instead of a plain ok and could mark the service unhealthy.
The failed deploy taught a few other lessons, now recorded in docs/guides/railway-deployment.md:
- Do not set
PORTyourself. Railway injects it, and both containers bind to whatever value they receive. - Keep Docling out of the API image. It lives behind
uv sync --extra ingestbecause it drags in large ML dependencies; on a small instance with limited RAM the install can fail or the image publish can choke. - If the Docker build succeeds but Railway fails while publishing the image, check image size and dependency bloat before retrying repeatedly.
Production hygiene
Three things change now that strangers can reach your URLs.
First, FastAPI serves interactive API documentation at /docs by default, and most people do not realize it. On your deployed backend, anyone can open https://your-backend.up.railway.app/docs and browse every endpoint. For an internal tool holding client data you want that locked down, along with a broader security pass, before real documents go in.
Second, re-enable email confirmation in Supabase if you disabled it during development, and add your production URLs under Authentication, URL Configuration (site URL plus a https://your-frontend.up.railway.app/* redirect).
Third, production Supabase needs the schema and the corpus. Run migrations against the production database, then ingestion from your machine with production values in backend/.env:
cd backend
uv run alembic upgrade head
uv sync --extra ingest
uv run python -m ingest.load_source_documents
uv run python -m ingest.chunk_and_embed --allRedeploying
There is no CI/CD pipeline wired up, so pushing to a branch does not redeploy anything. When you change code, push each service up manually with the CLI from the repo root:
railway up ./backend --path-as-root --service document-copilot-backend --detach
railway up ./frontend --path-as-root --service document-copilot-frontend --detachEach command uploads that folder as the service's root and triggers a fresh build, replacing the active deployment when it succeeds. This is the loop you will use as you keep improving the app.
Resource sizing
Railway's defaults are on the low end. Around 1 GB of memory is fine for a demo but thin for an app serving 40 analysts with concurrent chats. Dave's default on client projects is 2 vCPUs and 4 GB of RAM per service, which is usually enough. Bump the limits in the service settings when you move past the demo stage, and pick a region near your users while you are there.
Checkpoint
- Both services show a successful deployment in Railway, each on a public
up.railway.appdomain. https://your-backend.up.railway.app/healthreturns{"status":"ok"}and the frontend/healthreturnsok.- You can open the frontend URL in a browser and log in with a real account (email confirmation on).
- A question like the Nvidia demand-drivers query streams a grounded answer with clickable citations, running entirely against production.
Next: Where to go next
Recap the full build, then the honest list of what real AI engineering work starts now.