Deployment: The Hardest Part

Everything was working on my machine. The FastAPI server was running, the React dev server was proxying requests, streaming worked, characters responded. I felt invincible.

Then I tried to deploy.

Deployment almost broke me. Not because any single problem was impossible, but because there were so many problems, and they all had to be solved before anything worked. On localhost, everything just connects. On a server, every piece needs to be explicitly configured.

I Chose a VPS

I went with a VPS — a blank Linux server where I control everything. No Platform-as-a-Service handholding. No managed databases. Just a box, a terminal, and a dream.

In theory, this gives you maximum control. In practice, it means every problem is your problem.

Running Two Servers

cable network

Photo by Taylor Vick on Unsplash

The first challenge was simple but painful: I had two servers to run. The FastAPI backend on one port, the React frontend on another. In development, Vite's proxy handled this seamlessly. In production, I needed to serve the React build and proxy API requests to the Python backend myself.

I used pm2 to manage both processes. pm2 kept them running, restarted them on crash, and handled logs. In front of them, I configured Nginx as a reverse proxy to route incoming traffic, terminate SSL, and serve static assets.

Getting Nginx to properly handle Server-Sent Events (SSE) was its own hurdle. By default, Nginx buffers responses, which completely breaks streaming AI responses. Disabling buffering for the API stream location was crucial:

# /etc/nginx/sites-available/default excerpt
location /api/v1/chat/stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    
    # Disable buffering so SSE chunks stream instantly
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

The Server Configuration Gauntlet

a close up of a network with wires connected to it

Photo by Albert Stoynov on Unsplash

Here's a non-exhaustive list of things I had to figure out:

  • Nginx Reverse proxy — Getting requests from port 80/443 to the right backend service and disabling proxy buffering for streaming routes
  • SSL certificates — HTTPS isn't optional. Getting Let's Encrypt set up and configured properly
  • CORS — The frontend domain needed to be whitelisted in the FastAPI config. I was getting CORS errors for longer than I'd like to admit before realizing I'd configured the wrong origins
  • Environment variables — API keys, database paths, model names. Every config that worked locally needed to be set up on the server
  • Python on the server — Installing Python, setting up the virtual environment, installing dependencies. The stuff that frustrated me locally was even worse over SSH
  • Database — SQLite worked fine locally, but the database file needed proper permissions and path configuration on the server
  • Static assets — Character images and prompt files needed to be accessible. Path issues everywhere

Each of these was solvable. The problem was that none of them worked until all of them worked. You fix CORS, you still get SSL errors. You fix SSL, you get path errors. You fix paths, you get permission errors. It felt like whack-a-mole with no end.

How I Got Through It

a close up of a sidewalk with writing on it

Photo by Jon Tyson on Unsplash

Online resources. Blog posts. Stack Overflow answers. Server fault threads. Documentation pages I'd read five times before they made sense.

There's no clever insight here. No "aha" moment where everything clicked. It was just grinding through each problem, one at a time, until the site loaded without errors.

The moment it finally worked — when I typed my domain into a browser and Yoda responded — was one of the best feelings of the entire project. Not because the code was elegant, but because I'd earned it.

What I Learned About Deployment

Soldier toy holding a long wooden stick

Photo by Mo Darasi on Unsplash

Deployment is a completely different skill from development. Writing the app took me less than two weeks. Deploying it took nearly as long as building the backend from scratch.

The gap between "works on my machine" and "works on a server" is enormous. And it's not something tutorials cover. Every tutorial ends with "now run uvicorn main:app" as if that's the finish line. It's the starting line.

If I were to deploy again, I'd probably use Docker. Containerizing both services would have eliminated half of these problems — consistent environments, no "works on my machine" syndrome, and a single command to spin everything up. But I didn't know Docker well enough at the time, and learning it on top of everything else felt overwhelming.

That's a lesson learned the hard way.

The Server Is Still Running

a group of people in a kitchen preparing food

Photo by Ethan on Unsplash

The site is live. It works. Characters respond, streaming flows, the feedback form sends data to the database. It's not the most robust deployment, and I'm sure there are configurations that would make a DevOps engineer wince. But it's out there, on a real domain, serving real responses to real visitors.

Next up: the final post — what I learned, what I'd do differently, and why I'd choose Rust over Python next time.