Rebuilding this site in an evening
For years turevskiy.com was a single dark page: my name, a job title, five links. It loaded fast and said nothing.
Meanwhile the same server was hosting three apps I had actually shipped — a vocabulary trainer, a focus timer, a multiplayer game that runs in the browser — and the homepage linked to none of them. Someone landing on the site had no way to know any of it existed. That is the kind of thing you stop seeing after the hundredth visit to your own domain.
So I rebuilt it. These are the decisions worth writing down.
The CV was eleven months stale
cv/resume.pdf was dated 5 June. The version I would actually send someone was on my laptop. That gap is embarrassing in a specific way: anyone who found the site through search got an out-of-date picture of me while I was busy sending the current one by email.
The fix was boring — regenerate, upload, and point the old URL at the new file so existing bookmarks silently get the current version rather than a 404 or a stale download. The old copy is still on disk under a dated name. Nothing that was ever public should simply vanish.
Three languages, not four
The site now exists in English, German and Ukrainian. English is the default, German because I live and work in Germany, Ukrainian because it is my first language and because the audience is real.
I considered Hebrew — I have worked in Israel, and my CV lists it. I left it out, and the reason is not political. I rate myself a beginner in Hebrew, which means I could not proofread my own professional copy. Publishing text you cannot check is worse than not publishing it. If I target Israeli employers later, I will add it with a native speaker reading it first.
Each language gets its own path, hreflang annotations, and a switcher that does not open new tabs. Everything else on the page does, so that clicking a project does not cost you the page you were reading.
Analytics without a cookie banner
I wanted to know whether anyone reads this, and specifically which language of my CV gets downloaded. The reflex is to drop in a JavaScript tracker.
I used nginx's own access logs instead, with GoAccess generating a static report hourly. No JavaScript, no cookies, no consent banner, nothing to block — and, critically, it counts PDF downloads, which a client-side tracker cannot see at all. The file never executes any of my code; the request simply lands in a log.
Two things went wrong immediately, both instructive. First, every site on the box shared one access log, and the combined log format carries no host field — so the numbers mixed five unrelated domains together with no way to separate them. Second, of roughly 129,000 requests over nine days, 8,000 were an uptime monitor and another 24,000 were crawlers, plus a steady drizzle of probes for /wp-admin, /.env and /phpmyadmin that belongs to every public IP address.
Had I just read the headline number I would have believed my personal site gets 19,000 visits to its front page. It does not. Per-site logging and an explicit filter now, and the honest consequence is that my statistics start from today.
No chatbot
I build grounded assistants for a living. The obvious move is to put one on the site that answers questions about my CV.
I decided against it. A public language-model endpoint on a personal site is a bill and a prompt-injection surface, and this is the one page where my credibility has to be unimpeachable — the page where someone checks whether I am worth a conversation. The failure mode is not that it costs money. It is that somebody screenshots my own site saying something stupid.
There is a better version of the same instinct, which is to describe the work plainly in a section about it, including the part that actually matters: every figure the real assistant reports is computed server-side from role-scoped endpoints, never generated by the model, and every device action it offers passes through a confirmation step. In a supervised-autonomy fleet that boundary is not a caveat on the design. It is the design.
The bug
I made the project cards clickable by wrapping each one in an anchor. Inside each card were two more anchors, to the App Store and Google Play.
Nested anchors are invalid HTML. The browser does not error — it silently un-nests them, which pushed the store links out of their cards and into the grid as siblings. The layout collapsed into a row of empty boxes with orphaned buttons floating between them, and because I was reading the markup rather than the rendering, it took a screenshot to see it.
The fix is the standard stretched-link pattern: the card is an <article>, the title holds the only real link, and a pseudo-element on it covers the whole card. Sibling links sit above that with z-index, so they keep working. One container, one primary target, no nesting.
Written with a machine, by me
I used an AI assistant throughout — drafting copy, generating three language variants, writing the build scripts, and reviewing the rendered output.
I am not labelling the pages as AI-made. Nobody credits their editor or their compiler, and on a page whose entire job is credibility, a badge like that invites the question of what else was generated: the claims, the commit counts, the years. Every number on the site is checkable, and that is the thing I want load-bearing.
What I will say, because it is the actual skill, is that the judgment about what to hand over and what to read line by line is the whole job now. The machine wrote the nested anchors. It also caught them, and then fixed them properly rather than hacking around the symptom. Both of those are worth knowing about.