Hi, I'm
CuongHoang
Full-Stack Developer + AI Builder
I build modern web apps with Next.js, Node/Express and TypeScript on PostgreSQL, with AI features that actually ship — interview grading, CV critique, language tutoring. This whole platform is something I built and run myself.
Fast Delivery
AI-Powered
High Quality
Pages on the site
Commits
About Me
CuongHoang is a full-stack developer who would rather build the thing than go looking for it. This entire platform — the data schema, the API routers, the zero-downtime deploy pipeline — is written and operated by him on his own VPS.
Outside of coding, he writes up the production incidents he has hit and how each was fixed — the part nobody can copy, and the part he learns most from.
Size of the codebase
Not one of these is typed by hand — a script recounts the whole repository before every deploy.
Refreshed on every deploy · 27/08/2026
Latest Articles
Sharing programming knowledge and experience
Latest articles
The most recent articles published on the site.
Skills & About
Technologies and skills I use to build products
Everything running on this site
No percentages. Every entry below can be checked against package.json or the repository tree.
Backend
10Frontend
11Data
5Infrastructure
6AI
6Real-time
3Media & documents
7Other
68 times I broke production, and how each got fixed
Anyone can list technologies. The harder thing to write down is where you got it wrong.
The GIF picker died and chats “disappeared” — re-logging in did nothing.
A deploy that synced code but did NOT rebuild: the container kept serving an old build that never mounted the GIF route. The chats were never lost — a per-viewer delete flag filtered them out.
Diagnose routes with an unauthenticated `curl`: 401 means mounted, 404 means stale build. Deploys now always rebuild and smoke-test the core routes.
Turning on the site-wide dark theme broke the Notes module’s own three-theme switcher.
The `dark` class landed on `<html>`, force-activating every Tailwind `dark:` variant nested inside — including areas with their own palette.
The global dark class got its own name, `dark:` is reserved for the Notes wrapper, and everything else uses theme CSS variables.
Sessions died silently after exactly 24 hours. The cookie was still there, but every authenticated call was rejected.
The token expired in 24h while the cookie lived 7 days — and the refresh path called an endpoint that did not exist.
A real refresh endpoint plus an interceptor that refreshes once and retries. Sessions self-heal; no config change needed.
The 3D playground hung on its loading screen across two sessions, with no error anywhere.
Next.js fixes its static-file list at server start. Rebuilding renamed the JS bundles by content hash, so the running server 404’d files that existed on disk — no JS ran, so nothing surfaced an error.
Changing static files means restarting the server, and killing it BY PORT (Node renames its process, so killing by name misses). Production was never affected — each deploy is a fresh container.
“The database is down, only the frontend works.” Yet Postgres and the backend both reported healthy, and other users were fine.
Not an outage — a per-IP rate limit. One exam page fired 18,297 requests in 10 minutes (~32/s), re-uploading the whole audio recording each time. The root cause was a self-feeding loop: on timeout an effect auto-submits; when the submit fails the catch block MUST clear the “submitted” flag so the manual button still works; once cleared, the effect sees the same condition again and re-submits — as fast as the network allows. The latch released itself at exactly the condition that had tripped it.
A separate “already attempted” flag that is never cleared, split from the manual path’s flag. The rule: any effect that acts on a condition which does NOT clear itself (a timeout, a network error, a status flag) needs its own attempt latch. Trace it by scanning the Redis rate-limit keys to see whether one IP or the whole bucket is drained, then counting endpoints in that IP’s nginx log lines.
Rate limiting looked like it was working, yet a deliberate client could call the API without limit.
The key generator read the FIRST entry of the X-Forwarded-For header. That entry is client-supplied, so changing it on each call yields a fresh counter bucket every time — proven with curl. The site also does not sit behind Cloudflare, so trusting CF-Connecting-IP would be worse still: anyone can set that header.
Switch to the LAST X-Forwarded-For entry — the one nginx appends itself, which the client cannot touch — for both the general and the auth limiter. Regression check: curl with a forged header must still land in the SAME bucket after the fix; a fresh bucket means the hole is back.
A deploy failed with “no space left on device”, and every database-backed endpoint returned 500 — while the home page still returned 200, so at a glance the site looked fine.
The disk hit 100%. Postgres could not write its write-ahead log and got stuck in recovery: “database system is not yet accepting connections”. The space was not where you would look either — this host uses the containerd snapshotter, so the real images live under /var/lib/containerd while /var/lib/docker looks deceptively small.
Reclaim through docker itself (never hand-delete inside containerd, and never pass --volumes — that is the Postgres data), then restart Postgres and it finishes recovery in under a minute. Long term: prune the build cache after every deploy, plus a weekly job that reclaims disk. The diagnostic lesson: a static page returning 200 proves nothing — call an endpoint that actually touches the database.
The deploy stopped at P3009: “migration failed to apply”. No migration could run any more, not even a genuinely new and harmless one.
The database had drifted from the migration history: six older migrations had their DDL applied to production but were NOT recorded in the history table — some rows left half-finished, some never inserted at all. Prisma saw a broken history and blocked everything, exactly as it should.
For EACH migration, confirm in psql that the tables and columns it creates really exist, and only then mark it applied; after that the genuinely new migration runs. Wrongly marking a truly-missing migration skips the DDL you needed and corrupts data silently — which is why the rule is now: a failed migration stops and reports, never auto-resolves, and never gets patched with tricks that force it through.
Services services.provide
Comprehensive technology solutions to turn your ideas into real products
Web Development
End-to-end web applications with modern frameworks
TTFB
< 80ms
LCP
< 1.8s
CLS
< 0.05
Uptime
99.97%
Tech Stack
Guardrail Integrations
Get in touch
Open to work, collaboration, or just a question about something on this site.