
Taking a portfolio from localhost to a real production stack, subnets, reverse proxies, CORS, and the six or seven ways it all quietly broke along the way.
I finished my BS in Software Engineering this month. A few weeks later, I created my portfolio, a Next.js app that only run on localhost and decided it deserved better than that. Not just "deployed somewhere." A real backend. A real database. Authentication. Object storage for images. HTTPS that I understood, not HTTPS that just happened to work because a platform did it for me.
I didn't fully know what I was signing up for. Subnets, route tables, internet gateways, reverse proxies, DNS propagation, nameserver changes, CORS, OAuth domains: I'd read about all of it in courses. I had never been the one holding the pager when it broke at 11pm on my own machine. This is the story of that week.
graph LR You[You, reading this] -.-> Site[mansoorsyed.com] Site --> CF[Cloudflare edge] CF --> Vercel[Vercel: Next.js] CF --> VM[Oracle Cloud VM: FastAPI]
That's the destination. Getting there meant fighting through six or seven layers, one at a time, until they finally agreed with each other.
Zoomed all the way out, the finished architecture is simple enough to sketch on a napkin: a frontend, a backend, a database, a bucket for images, an identity provider, and Cloudflare sitting in front of the public facing pieces.
graph TB Visitor[Visitor] --> CF1[Cloudflare] CF1 --> Vercel[Vercel] Vercel --> Next[Next.js frontend] Next -->|api.mansoorsyed.com| CF2[Cloudflare] CF2 --> Caddy[Caddy: reverse proxy + TLS] Caddy --> API[FastAPI] API --> PG[(PostgreSQL)] API --> R2[(Cloudflare R2)] Admin[Administrator] --> FB[Firebase Auth] FB --> Panel[Admin panel] Panel --> API GH[GitHub] --> Vercel GH --> VMNode[Oracle Cloud VM] VMNode -.docker compose.-> API
Every box in that picture used to be a decision I had to make, and every arrow used to be a place where things quietly didn't connect. Here's how it actually came together, in the order I fought through it.
The repo already kept the two apps apart, portfolio/ for Next.js, backend/ for FastAPI, which felt tidy until Vercel tried to build it and got confused about where the actual app lived, one directory too deep. Pointing Vercel's root directory at portfolio/ fixed that in about thirty seconds. The lesson underneath it took longer to land: a monorepo that makes sense to you still has to make sense to whatever's building it.
The other early fix was making the frontend's API address configurable instead of hardcoded, NEXT_PUBLIC_API_BASE_URL, pointed at localhost in development and at the real domain in production. Small change. It's the difference between an app that only works on your laptop and one that can exist in more than one place at a time.
This was the part I'd never done before: an actual server, not a platform abstracting the server away from me.
I provisioned an Ubuntu VM on Oracle Cloud and had to build its networking from nothing, a subnet, an internet gateway, a route table, security rules that actually let traffic in. On paper this is four nouns. In practice, a setup that looks correct in the console can still refuse every request until one of those four pieces is wired to the others correctly, and Oracle's console does not tell you which one you got wrong.
graph LR Internet((Internet)) --> IGW[Internet Gateway] IGW --> RT[Route Table] RT --> Subnet[Public Subnet] Subnet --> SecList[Security List] SecList --> VM[Ubuntu VM]
Once traffic could actually reach the box, I moved the FastAPI app into Docker, running under Docker Compose. The container was never meant to be the public facing thing, it just needed to listen quietly on an internal port and let something else handle the outside world.
That "something else" was Caddy. I gave the API its own subdomain, pointed Caddy at it, and let Caddy own the messy part of HTTPS, certificate issuance, renewal, terminating TLS, so Uvicorn never had to know or care.
Browser -> api.mansoorsyed.com -> Cloudflare -> Caddy -> FastAPI
The first time this worked, I didn't trust the browser to tell me the truth. I went to the terminal instead:
curl https://api.mansoorsyed.com/api/public/home-contentA clean JSON response came back. That was the first moment the project stopped feeling like a pile of separate services and started feeling like one thing.
Moving the domain into Cloudflare gave me control over DNS and the public edge, and Full SSL mode meant traffic was encrypted both to Cloudflare and from Cloudflare to my origin, not just the first half. I made a point of checking this properly instead of trusting the padlock icon in the address bar, because a lock icon only tells you the browser's leg of the trip was encrypted. It says nothing about what happens after Cloudflare hands the request off.
With the plumbing in place, the API could finally do its real job: serve portfolio content without any of it being hardcoded into the frontend.
sequenceDiagram autonumber participant V as Visitor participant N as Next.js participant A as FastAPI participant D as PostgreSQL V->>N: opens the portfolio N->>A: GET /api/public/projects A->>D: query D-->>A: rows A-->>N: JSON N-->>V: rendered page
Home content, project entries, field notes: all of it lives in PostgreSQL now, and only FastAPI ever touches the database directly. The browser never sees a connection string and never needs to know what the schema looks like. That boundary is the whole point. The API isn't just a formality sitting in front of the data, it's the only door into it.
Somewhere in here I remembered that Docker containers are disposable by design, which is exactly what you want for an application process, and exactly what you don't want for someone's uploaded photos. If the container gets rebuilt, anything stored inside it is gone.
So media went into Cloudflare R2 instead, with PostgreSQL holding only the references to what's stored there:
graph LR Admin[Admin uploads] --> API[FastAPI] API --> R2[(Cloudflare R2)] API --> DB[(PostgreSQL: metadata)] DB -.reference.-> R2
Now a container can be destroyed and recreated a hundred times and nobody's headshot disappears.
/apiThe smallest bug cost me the most confused hour. The API was mounted under /api, and for a while the frontend's base URL didn't include that segment, so every request to a perfectly healthy backend came back 404. Nothing was down. I was just knocking on the wrong door, one folder above where the API actually lived.
NEXT_PUBLIC_API_BASE_URL = https://api.mansoorsyed.com/api
One path segment. That's all it was.
The first production build compiled cleanly, TypeScript passed, static pages generated, and then quietly failed to fetch any content:
Error fetching about content: Error: Not Found
Failed to load field notes
Nothing was broken in the way "broken" usually looks. Every individual piece, the frontend, the backend, the database, was healthy on its own. They just didn't yet agree on where each other lived. Once the Vercel root directory and the API base URL were both correct, the build stopped complaining and started rendering real data.
I'd built applications before. I had never had to own the path between code and the person opening it in a browser: every domain, every certificate, every gateway and route table and CORS header standing between "it runs on my machine" and "a stranger can load it right now."
None of those pieces is individually hard. The internet gateway is one setting. The CORS header is one line. The Firebase domain is one field in a console. What makes it hard is that all of them have to agree with each other at the same time, and when one of them doesn't, the failure shows up somewhere completely unrelated to its actual cause: a 404 that's really a missing path segment, a silent fetch that's really a missing Origin header, an OAuth error that's really a config field nobody told me to fill in.
Debugging this system meant learning to ask the question one layer at a time instead of guessing at the whole stack:
graph LR
B[Browser fails] --> DNS{DNS resolves?}
DNS --> CFQ{Cloudflare reaches origin?}
CFQ --> CaddyQ{Caddy forwards it?}
CaddyQ --> APIQ{FastAPI is up?}
APIQ --> DBQ{DB reachable?}
DBQ --> CORSQ{CORS allows it?}
CORSQ --> Fixed[Actually fixed]
The portfolio is live at mansoorsyed.com. The backend runs behind HTTPS on its own domain. The frontend and backend deploy independently of each other. Uploaded media survives container rebuilds. Admin access is authenticated. Production and development no longer share configuration by accident.
It's not a large system, and it was never supposed to be one. It's a small production application that I built, broke in about six distinct ways, and put back together until every layer told the truth about itself.
It's the first time I've owned that whole path, and there's still plenty left I want to improve. But for a first week out of school, I'll take it.