All articles
11 Oct 2026 / 5 min read

Hello, world. Again.

nutellino.it is back after a few years off. How the new site is built: Markdown in git, a static export on Cloudflare Pages, a management panel in my homelab and no database.

# personal# self-hosting# architecture
An old cobwebbed terminal switching back on, linked by a green line to a window on the sky

On 20 December 2015 I wrote the first post on this site: "I'll publish guides, curiosities and anything that strikes me as fun or useful." The last article dates back to 2021, then the blog went quiet.

Now it's back. Same idea, new site. And since it's a tech blog, the first article could only be about how it's built.

The idea: nothing to keep running

The old nutellino.it started on WordPress and ended up on Ghost: an application always running, a database, updates to chase. For a blog I update when I have time, that's a lot to maintain.

This time I wanted the opposite:

  • articles are Markdown files in a git repository, separate from the site's code;
  • the site is static: HTML, CSS and a few JavaScript files, generated by the build and served by Cloudflare Pages;
  • nothing runs in production: no Node, no database, no login panel exposed to the Internet.
mermaid
flowchart TD
    agents["AI agents<br/>via MCP"] -->|drafts only| admin
    me["me"] --> admin["management panel<br/>in my homelab"]
    admin -->|one commit per save| content["nutellinoit-content<br/>git repository"]
    content --> build["build in my homelab<br/>validation + Next.js + search"]
    build --> preview["preview<br/>in my homelab"]
    preview -->|Publish| prod["Cloudflare Pages<br/>nutellino.it"]

The articles

Each article is a Markdown file with a metadata header:

yaml
---
schemaVersion: 1
id: si-riparte
locale: en
slug: si-riparte
title: "Hello, world. Again."
description: "..."
date: "2026-10-11"
tags: ["personale", "self-hosting"]
draft: false
---

Headers are validated against a Zod schema before the build: a malformed date, a duplicate slug or a missing translation make the whole thing fail. I'd rather have a red build than a broken article online.

Articles with draft: true and those dated in the future aren't published. Set the right date and the piece goes out with the next build.

The site

The code is Next.js 16 with output: 'export': at build time it generates an HTML page for every article, in Italian and in English, with separate URLs (/it/... and /en/...), a sitemap, robots.txt and the metadata search engines want.

Search is static too: Pagefind builds an index at build time, and the browser downloads only the pieces it needs when you search.

The result is a folder of static files that Cloudflare serves from its network. No server of mine to update, no port open to my homelab.

Preview and publishing

The build runs in my homelab, in the management panel: it validates the content, generates the site and serves it as a preview. What I see there is exactly the site that will go online.

When I'm happy with it, the Publish button uploads that same folder to Cloudflare Pages. No second build somewhere else, no difference between what I reread and what goes out. If something goes wrong, the content goes back with a git revert, and Cloudflare keeps the history of every deployment anyway.

The next step is moving the repositories to my Gitea, with a pipeline on every push and a preview for every branch.

The management panel and the agents

Writing everything from a terminal is fine, but not always. That's why there's a small management panel that only runs in my homelab: a list of articles, a Markdown editor with a preview rendered by the site's own components, image uploads. Every save is a commit on the content repository, so the history always tells who changed what.

The same panel exposes an API and an MCP server, so AI agents can work on the blog too. With clear rules:

  • each agent has its own token and signs commits with its own name;
  • agents only write and edit drafts;
  • taking an article out of drafts and putting the site online is something only I can do.

Everything in mise and Docker Compose

I don't install anything by hand on my machine: tool versions live in mise.toml, and commands run inside Docker Compose containers.

bash
mise run dev        # development server
mise run validate   # check the articles
mise run build      # static export
mise run admin      # management panel and local preview

Whoever clones the repository (that is, me in six months, having forgotten everything) only needs to run mise install.

The old blog

The old articles weren't entirely lost: I recovered them from the Wayback Machine and from an old Ghost backup, and converted them to Markdown. You'll find them in the archive, with a warning at the top, because some things have aged quite a bit (hello, Xen Server). Most of them were written in Italian: those now come with a machine translation, clearly marked as such.

Old addresses like /2018/09/02/journey-to-kubernetes-parte-1-la-ricetta/ redirect to the new page through Cloudflare redirect rules generated at every build, so links scattered around the web keep working. The backup also brought back almost all the images; only the ones from the most recent articles are missing.

What I built it with

I made the first draft of the site with v0. Then I carried it forward burning through the quota of my ChatGPT Plus plan, and the rest, backend included, I built with Claude.

The architectural idea, the style, the moving logo and what I tell here are all my own: AI wrote a lot of code, but I set the course. I talk more about it in AI and PEBCAK.

No cookies

The site uses no cookies, has no analytics and loads nothing from other domains: the fonts are bundled with the site itself. So there's no banner to accept. The only one seeing IP addresses is Cloudflare, like any hosting provider, to keep the site running and protected.

Hello again! ๐Ÿ™‚