This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
How I think about shipping a site to production: what “deployment” actually is once you break it apart, when the decision should be made (earlier than most people make it), the platform trade-offs that actually bite, what the industry reaches for by default, and the concrete setup behind this portfolio and its Angular sibling.
“Deploy it” sounds atomic, but it's five separate responsibilities stacked together: build (turn source into an artifact), host (put that artifact somewhere that runs), serve (answer HTTP — static files, SSR, or serverless functions), route (DNS + TLS pointing a domain at the host), and observe (know when it breaks). Most deployment pain comes from treating these as one thing. A senior developer names them separately, because the right platform is the one whose defaults match the shape of those five jobs for your specific app.
The first question isn't “Vercel or AWS?” — it's “what does this app need at request time?” That answer picks the platform, not the other way around.
This portfolio is Next.js with per-request rendering (export const dynamic = "force-dynamic" on the home route so a logged-in hub is never cached and served to a guest). That single fact — SSR on the hot path — is why it belongs on a platform with first-class serverless SSR rather than a static bucket.
Deployment is an architecture decision wearing an ops costume. Deciding late forces expensive retrofits — a feature that reads the filesystem at request time is free on a VM and impossible on edge functions; auth that assumes a warm process fights cold starts; a WebSocket feature is trivial on a container and a second system on serverless. The cheap move is to know the runtime target before committing to patterns that only work on a different one. The corollary: deploy on day one. A hello-world in production from the first commit means every subsequent change ships through a path you already trust, and “works on my machine” never gets to accumulate.
Roughly three tiers, trading control for convenience:
The trade-offs that actually cost you later, in rough order of how often they bite: cost at scale (PaaS bandwidth/function pricing is convenient until it isn't), cold starts (serverless latency on the first hit — usually fine, occasionally a dealbreaker), vendor lock-in (edge/SSR primitives that don't port), and statelessness (no local disk, no in-memory cache you can trust across invocations). None of these are reasons to avoid a PaaS — they're reasons to know which one you're signing up for.
Defaults in 2026, by app shape: React/Next and other JS frameworks lean on Vercel / Netlify / Cloudflare because the framework and the host are co-designed. Backends and full-stack apps that want a database nearby lean on Railway / Render / Fly.io for push-to-deploy-a-container simplicity, or AWS/GCP serverless (Lambda, Cloud Run) when they already live in a cloud. Larger orgs standardize on containers on Kubernetes for uniformity across many services. Underneath almost all of it, the same two ideas are near-universal now: Git-driven deploys (push a branch, get a deploy) and immutable preview environments per pull request. Those two conventions matter more than the specific vendor.
I keep a clean split: CI proves the change is safe (lint, typecheck, unit + e2e — this repo runs 620+ unit and e2e cases on every push and PR), and the platform does the deploy. The two connect at one point: a failing check blocks the production deploy. That boundary is what makes shipping boring — the interesting work happens in review and CI, and promotion to production is a non-event. Preview deploys per PR make review concrete: you click the branch's URL and see the change running before it merges.
Two things separate a deploy you trust from one you cross your fingers over. Rollback has to be one click — immutable deploys mean the previous good version is still sitting there to promote; if recovery means “rebuild and redeploy,” you don't really have rollback. And you have to find out before your users tell you: this app collects real-user Core Web Vitals via sendBeacon, aggregated as P75, so regressions show up as data instead of complaints. Shipping is easy; knowing you shipped something bad, and undoing it fast, is the part that's worth engineering.
This section is the front end. The backend half — Railway, a Postgres that used to be reachable from the open internet, and what it took to move it onto a private network — is its own write-up at Taking the database off the public internet.
This portfolio is Next.js on Vercel, region iad1, fronted by Cloudflare for DNS and CDN, at paulsumido.com. GitHub Actions runs the full suite on every push and PR and gates the deploy; Vercel builds from Git and keeps every deployment for instant rollback. Its Angular sibling reuses the exact same spine with different primitives — Angular 21 SSR, Vercel's angular framework preset wrapping the Express handler as a serverless function, the CNAME living in the same Cloudflare zone — shipping to angular.paulsumido.com. Same five jobs, same Git-driven gate, different runtime shape. That's the whole point: pick the platform from the app's shape, then make the pipeline identical everywhere.
I spent an evening this week shipping security fixes across the API and this site, and the same mistake caught me three separate times. It is always the same shape: check something next to the claim, see green, treat the claim as proven. The thing next to the claim is always faster to check. That is exactly why it gets checked.
The expensive one: I encrypted the stored Google OAuth tokens in the production database, having confirmed the pull request was merged. Merged is not deployed. The running build was five releases old and had no decryption code, so for a while the database held credentials the application could not read. Nothing user-facing broke, because that integration had been dormant for months — which is luck, not process.
Then, checking whether the fix had gone out, I read /api/health and saw version: 2.3.2 against a package.json on 4.6.x. I assumed a failed deploy and went looking for a fault that did not exist. The version was a string literal someone had typed in and never touched again. It had been wrong for five releases, and it is the first field anyone reads to answer “did this ship” — so it reported failure on every deploy that succeeded. A field that lies is worse than no field.
What actually answered the question was behaviour. That release removed two endpoints and added one, so three curls settled it: the removed routes returning 404 and the new one returning 401 can only happen if the new code is running. No amount of reading dashboards proves that; one request does.
The rule I wrote down afterwards, because I clearly needed it in writing: confirm what is live before writing to it. A migration, a backfill, an encryption pass — any of them against an environment whose running version is unconfirmed is how a correct change becomes an outage. And more generally, before saying something works, name the signal you actually looked at and ask what it would miss. If the answer is “the thing I am claiming”, it is the wrong signal.
Two smaller versions of the same trap, from the same evening. A test summary reading PASS FAIL(0) while the process exited 1 — the exit code is the run, the summary is a tool’s parse of the run, and when they disagree the exit code wins. And a change that resolved a file path relative to __dirname, which passed every test against the sources and would have broken in the build, because src and dist are not the same place. I only caught that one by building it and running the compiled output.
Update — August 15, 2026
Every deployed tab was showing Vercel’s logo. Locally it showed mine, which is exactly why it survived since the first commit. There are two icons and I had only ever looked at one: icon.tsx renders the mark and Next injects a link tag for it, while favicon.ico is served at /favicon.ico and browsers request that path on their own whatever the link tag says. That file was create-next-app’s black triangle, committed at initialization and never opened again.
It is one mark at two sizes now, generated from what the icon route actually renders rather than drawn a second time, so the two cannot drift. The test hashes the framework default and fails if it ever returns, because size alone would not have caught this — a wrong icon can be any size, and the whole failure was that nobody thought to open the file. I proved the fix the only way that counts here: built the production bundle, served it, and fetched /favicon.ico off the running server. Source-level green would have proved nothing, since the bug lived entirely in what the artifact serves.
The other half of deployment is waking up. The API scales to zero, so a cold boot sits on a real user’s critical path, and nothing measured it. The frontend just got a gzipped first-load budget, and the tempting move was to copy it across — but that service ships no browser bundle, so a bundle budget there would be a number that can go red without anything being wrong, which is worse than no check at all.
What it got instead is a production dependency weight gate, stated plainly in its own comments as a proxy for cold start rather than cold start itself. Boot wall-clock was rejected as a gate on measurement, not taste: 268 to 294 milliseconds across seven runs on an idle laptop, which on a shared runner would flake, and a guard that flakes gets deleted. The measurement immediately paid for itself — production dependencies weigh 443MB across 331 packages, and a single package shipping every platform’s binaries is 335MB of that. Three quarters of the cold start is one dependency carrying builds this service will never run.
Both of these are the same lesson wearing different clothes. What runs locally is not what deploys, and the only honest check is the one that asks the deployed artifact.
Update — August 16, 2026
The entry above ends on a number: 335MB of the API’s 443MB production install was one package shipping every platform’s binaries. Acting on it cut the install to 170.5MB on CI, down 61.5 percent, by swapping ffprobe-static’s six-platform tarball for an installer package that resolves a single platform build through optional dependencies. The committed budget came down with it, 500MB to 210MB, because a budget left at the old ceiling after a win that size has quietly stopped measuring anything.
The interesting part is what the swap uncovered, which had nothing to do with size. The replacement chmods its binary in a postinstall, and pnpm 10 refuses build scripts unless the package is named in onlyBuiltDependencies. Without that the binary lands unexecutable and spawns as permission denied. Chasing it revealed the same mechanism had already silently disabled ffmpeg-static, whose postinstall downloads the actual ffmpeg binary and had never run — not locally, not in the Docker image, which never installs ffmpeg either. Every video upload has been failing at the thumbnail step, in production, for as long as that pnpm version has been in use.
Nothing caught it because no test had ever executed either binary. The new test drives the real video path against a committed fixture, and it failed with spawn ffmpeg ENOENT before the fix — which is to say the first honest test of that path reproduced the production bug on the first run. It is also verified from the compiled output rather than from source, and inside a real linux/amd64 container rather than on my Mac, because the whole failure was about which binary exists where.
I went looking for bytes and found a broken feature. That is the argument for measuring things you think you already understand: the size metric was never the point, it was the excuse to look.