neitola.dev
← Back to writing
PersonalJul 2, 202610 min read

Relaunching neitola.dev: the old site read like a first draft

Three days, three things shipped. On 30 June I open-sourced Riffle, a small SwiftUI package, with the source on GitHub. On 1 July I relaunched Toolia after tearing the 2024 version down to the studs. Today, 2 July, it is this site’s turn.

I did not plan a streak. All three had been sitting at ninety percent for months, and finishing one made the next one itch. Riffle and Toolia both needed a home to link to, and that home was a site I had quietly been embarrassed by for about a year. It was the last of the three, and the most overdue.

There is a plainer reason for the timing too: I am open to work in 2026. That changes what a personal site is for. It stops being a hobby blog with my name on it and becomes the first thing a hiring manager or a potential client sees, and it has to answer one question fast: who is this, and can they do the thing.

The old site read like a first draft

I launched the first neitola.dev on 26 July 2024, already built with Astro. The opening line of the launch post was “That’s one big leap for me, a tiny step for mankind,” a Neil Armstrong joke with the words swapped, and it signed off with a single “Cheers!” It was a page with a blog attached, half-apologizing for existing. It did its job. It also never showed any work.

I have used that exact phrase, “it always felt like a first draft,” about Toolia. It fits here too. A first draft is where you find out what the thing was supposed to be. The 2024 site told me what the real one needed to do.

The problem was easy to see once I looked at the site the way a stranger would. Someone lands on it, reads a post or two, and leaves knowing I like Astro and have opinions. Nothing about the 22-person infrastructure team I lead. Nothing about the two companies I co-founded or the apps and shops that came out of them. The writing was there. The person behind it was not.

Why own a site at all in 2026

Before the rebuild I argued the honest question with myself: not “how should my site look” but “why have one at all.” The case against is good. Everyone I would want to reach is already somewhere else. Recruiters live on LinkedIn, and a post on whatever network is current gets more eyes in a day than my domain gets in a month. The platforms handle the hosting, the CDN, and the TLS certificate I would otherwise forget to renew, and they are free. By that math a personal site is a worse distribution channel that costs more time.

I keep one anyway, because distribution is not the whole game. Everything I publish on someone else’s platform is a tenant improvement on rented land. It looks like mine, but the reach algorithm can change on a Tuesday, the account can be suspended by a false-positive filter, and the whole network can get bought and renamed with my back catalogue along for the ride. LinkedIn and X are great front doors. I would rather they open onto a house I own.

There is a nerdier version of the same argument. Search engines, and increasingly the models people ask instead of searching, are trying to resolve “Kevin Neitola” to one coherent entity: an engineering manager and Head of Infrastructure and Cloud, a two-time founder, someone shipping iOS, web, and cloud work out of Braunschweig, Germany. A site I own, with a real CV and real project case studies, is the one source in that pile I get to write myself. It is the anchor the profiles link back to, not the other way around.

From a blog to a real site

The old site was, functionally, one page: a stack of posts and a short about section. The new one is a proper multi-page site.

  • A home page.
  • A /work section with real case studies: ATVO, the live iRacing broadcast overlay I build under Appgineering; CabinFinland, the Finnish-goods shop from 65 Grad Nord; plus Riffle and Toolia.
  • A /blog you can search and sort, with an RSS feed for anyone still running a reader.
  • A /cv page that reads like a CV, with a PDF you can download.
  • The two pages German law requires, an Impressum and a Datenschutzerklärung, which are more interesting than they sound.

None of that is exotic. It was just missing, and the absence was the whole problem.

The parts I am proud of

A personal site is a small enough system that you can do the disciplined things and still ship it in a weekend. Most of my day job is stopping systems from drifting into contradiction, so I held my own to the same standard. Two patterns did most of that work, and both come down to not repeating myself.

One identity file

Site identity lives in one site.ts: name, role, location, social links, the legal operator details, the SEO defaults. The layout reads it, the head and its meta tags read it, the nav reads it, the footer and the legal pages read it. When I finally paste in a LinkedIn URL, it appears everywhere it should and nowhere it should not, because a social link only renders when its value is set.

That file also fixed a genuine embarrassment. My availability used to be written in two places, and they disagreed: one said “select work,” the other said “open to work.” That is exactly the kind of contradiction a hiring manager notices and you do not. Now there is a single availability field, and both the hero badge on the home page and the status line on the CV read from it. They cannot disagree again, because there is only one of them.

One CV, two artifacts

The CV exists as the /cv web page you can browse and a PDF you can download and drop into an application. Those are the two things that, in every job I have had, drift apart: you update the web version, forget the PDF, and three months later you are emailing someone a CV that contradicts your own site.

So both come from one cv-data.js. It holds the profile, the experience, the skills, and the education. The web page renders it directly. The PDF is built by a script (pnpm cv:pdf) that takes the same data, lays it out as a print page, and prints it to PDF with headless Chrome, embedding the avatar and the self-hosted fonts as base64 so the build needs no network. Same data in, two artifacts out. They cannot say different things, because there is nothing to keep in sync.

Typed content, or the build fails

Content lives in Astro content collections, one for projects and one for posts, and the part I care about is that they are typed. Each collection has a zod schema, and every Markdown file is validated against it at build time. Here is the posts schema, trimmed:

const posts = defineCollection({
  loader: glob({ pattern: '**/[^_]*.md', base: './src/content/posts' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      category: z.string(),
      pubDate: z.coerce.date(),
      readingTime: z.string(),
      excerpt: z.string(),
      cover: image().optional(),
      coverAnimated: z.boolean().optional(),
    }),
});

The payoff is boring, which is the point. I cannot forget a pubDate and quietly break the sort order on the blog index. I cannot fat-finger a cover path and ship a broken image. The failure moves from runtime, where a reader sees it, to build time, where only I do. A little schema up front buys a class of bugs that can never reach the page.

The coverAnimated boolean is there for a specific gotcha. Astro’s <Image> component optimizes images through sharp, and flattening a GIF or an animated WebP to a single frame is part of that optimization. Riffle’s cover is an animated demo, so running it through the optimizer would have killed the motion. The flag routes those covers to a plain <img> instead, so the moving ones keep moving and everything else still gets optimized. It is the kind of edge case you only find by shipping, and putting it in the schema means the next animated cover gets a nudge instead of a silent flatten.

Hosting it myself

The site does not run on Vercel or Netlify. It runs on my own box, deployed through Coolify, an open-source, self-hosted platform-as-a-service: push to the repo, Coolify builds the Astro site and serves it.

The boring reason is that this is a static site with a bit of build-time image work, so I do not need anyone’s edge network or serverless anything. A container on a server I control is plenty. The honest reason is that my title is Head of Infrastructure and Cloud, and paying a managed host to run the one deployment I fully control myself would be a strange look. I can write “I know how to run production infrastructure” on the CV, or I can serve the CV from infrastructure I run. The second is harder to argue with.

The tradeoff is real and I will not pretend otherwise. I own the uptime. I own the TLS renewal. If the box falls over on a Sunday, there is no support tier, there is me. For a portfolio site that is an easy trade, and it is the same trade I make at work every day, smaller and with lower stakes. If I cannot keep a personal site up, you should not trust me with a 22-person team’s worth of production.

Privacy done properly

Running a German site means the legal pages are not optional, and I did not want to bury them in the footer as a nuisance. There is a real Impressum and a real Datenschutzerklärung, written to describe what the site actually does with data, which is very little.

The fonts are the tell. Every font here is self-hosted, so no request quietly leaves for a Google CDN carrying a visitor’s IP address the moment the page loads. A German court has ruled that embedding Google Fonts this way without consent is not compatible with the GDPR, which for a site operated out of Braunschweig is not a hypothetical. Self-hosting means nothing about my visitors leaves my server.

It also lets me kill layout shift properly. The faces used above the fold are preloaded as WOFF2 in the document head. Without that, the browser paints fallback text first, then swaps to the real font when it arrives, and the swap shoves the layout around. That jump is a chunk of your Cumulative Layout Shift score, and it feels cheap even when you cannot name why. Preloading means the first paint is already the right font. The type itself is Bricolage Grotesque for display, Hanken Grotesk for body, and Space Mono for the small monospaced UI bits, which gives the whole thing a dark, editorial, slightly terminal feel that rhymes with the new Toolia on purpose.

Analytics are privacy-first: no third-party trackers, and no cookie banner, because there is nothing to consent to. I would rather know a little less about my visitors than make them click through a wall of checkboxes to read a post.

The details that do not show

The rest is the unglamorous layer, most of it invisible when it works.

  • sharp optimizes images at build time, so a full-resolution photo I drop in gets resized and re-encoded instead of shipping a two-megabyte original to someone’s phone.
  • View Transitions animate between pages, and hover prefetch pairs with them, so moving around feels like an app instead of a series of white-flash reloads.
  • JSON-LD structured data describes the home page as a Person and each post as a BlogPosting, so the entity signal I mentioned earlier is machine-readable rather than implied.
  • A generated favicon set, generated OG images, a sitemap, and the RSS feed round it out. All generated, none hand-maintained.
  • A small rehype plugin rewrites external links in my Markdown to open in a new tab, carry rel="noopener", and pick up a utm_source. When toolia.dev or the Riffle repo gets a click from here, I can see it, without a tracker and without touching the links by hand.

None of these are impressive alone. Together they are the difference between a site that renders and one that feels like someone cared.

What is next

Consolidation was the real goal. Riffle, Toolia, the writing, and the CV finally live in one place that points at itself instead of scattered across a GitHub profile and a half-finished blog. The site is live, the streak is over, and I am going to sleep.

If you are hiring for someone who leads an infrastructure team and still ships code on the weekend, the CV is right there and the work speaks for itself. If you came for the writing, the blog is the same as it ever was, only searchable now.

Thanks for reading, Kev

Thanks for reading, KevDiscuss on 𝕏 →