This blog is the second thing I built in order to write the first thing on it. The site is generated by Hugo with the PaperMod theme — fast, static, zero runtime dependencies. But publishing an article from the terminal is a ritual that discourages: open the file, fix up the front matter, hugo server for the preview, docker compose up -d --build, pray.

So I built its admin panel: a web app that runs on my ASUS Ascent GX10, protected by a login, with a Markdown editor, image upload and a Publish button that does everything on its own. This post tells how it is built and why I chose each piece.

The architecture in twenty seconds

Two containers, one shared volume, no database:

flowchart LR
    B[Navigatore] -->|:80| N[nginx<br/>blog statico]
    B -->|:8001| A[ASP.NET Core 10<br/>CMS + Angular SPA]
    A -->|scrive .md| R[(repo Hugo<br/>content/posts)]
    A -->|hugo build| V[(volume site)]
    V -->|montato ro| N
  • blog (nginx, port 80) serves the static files from the site volume;
  • admin (port 8001) is the ASP.NET Core API that also serves the already-built Angular SPA, writes the .md files into content/posts/ and re-runs hugo build into the volume.

The “database” is the repo’s Markdown files: the same ones I use from the terminal, the same ones Git sees. If the CMS disappeared tomorrow, nobody — least of all the content — would notice. This is what convinces me most about the Hugo + file system pairing: the CMS is a client, not the owner of the content.

API: one project, four slices

The heart of it is BlogAdmin.Api, .NET 10, a handful of files. Instead of the usual layered projects (Controllers/Services/Repositories) I used the feature slice pattern: each feature is a self-contained folder that declares its own routes and nothing more.

Features/
  Auth/AuthSlice.cs        # login, JWT
  Posts/PostsSlice.cs      # CRUD on the .md files
  Media/MediaSlice.cs      # image uploads into static/images/
  Rebuild/RebuildSlice.cs  # manual hugo build
Shared/
  HugoFile.cs              # front matter parsing/writing
  BlogEnvironment.cs       # paths from the config file

Each slice implements a micro-interface and registers itself under the /api group; Program.cs knows no details of any feature:

builder.Services.AddSingleton<IFeatureSlice, AuthSlice>();
builder.Services.AddSingleton<IFeatureSlice, PostsSlice>();
builder.Services.AddSingleton<IFeatureSlice, MediaSlice>();
builder.Services.AddSingleton<IFeatureSlice, RebuildSlice>();

var api = app.MapGroup("/api").WithTags("gx-blog-admin");
foreach (var slice in app.Services.GetServices<IFeatureSlice>())
    slice.Register(api);

The blog routes are pure Minimal APIs — GET/POST/PUT/PATCH/DELETE on /api/posts — with JWT authentication (login with a password taken from an environment variable, secret kept out of the repo) and Problem Details for errors. No Entity Framework, no migrations: saving a post means serializing the YAML front matter and writing a file.

The most delicate piece is precisely HugoFile.cs: Hugo is demanding about front matter (dates, lists, types), so parsing and rewriting go through a single point, tested against real files. It is the kind of boring code that nevertheless holds everything up.

UI: Angular 20 with signal stores

The front end is Angular 20 with Angular Material and — a choice that proved spot on — NgRx Signals for state. Three signal stores (auth, posts, plus the typed API client) handle everything; the two real pages are:

  • the post list, with draft/published, one-click toggle and delete;
  • the editor, a split view of Markdown + live preview, with the front matter fields (title, description, categories, tags) as a form and the body in a giant textarea.

The pages are standalone components, the routes are protected by a guard and an interceptor that attaches the JWT. With signals and the new control flow (@if, @for) the template reads like code, not like a separate language. For a two-screen panel that is more than enough, and the fact that the Angular build lands inside the API’s wwwroot — one container to deploy, zero CORS in production — alone justifies the choice.

.NET Aspire so development doesn’t drive you mad

The part I enjoy most: local development is orchestrated by .NET Aspire. One command and the API, UI and dashboard come up:

cd admin-dotnet && dotnet run --project src/BlogAdmin.AppHost
# dashboard on http://localhost:15100 — API on :5080, Angular on :4200

The AppHost launches the API and uses an AddJavaScriptApp that runs npm start for the UI: correlated logs, automatic restarts, and a dashboard showing the state of both processes. Two details that made me sweat (aarch64 does that): the right template is Aspire.ProjectTemplates, and for the API endpoint on the fixed port 5080 you need IsProxied = false, otherwise the DCP complains.

Deploy: multi-stage Dockerfile and an honest entrypoint

The admin Dockerfile has three stages: build of the Angular UI, build of the .NET API, final image with the ASP.NET runtime, Hugo inside and the SPA in wwwroot. Before starting the API, entrypoint.sh runs a production hugo build into the shared volume — so if I edited content via Git while the container was off, the site is up to date as soon as it wakes up.

docker compose up -d --build   # that's it, publishing included

Both containers have restart: unless-stopped and Docker starts at boot: the GX10 can be powered off and back on, and the blog comes back up by itself.

Lessons (and traps) collected along the way

  • Files as API: making the data model coincide with the Markdown files eliminated entire layers of code. The CMS has no history because Git does.
  • Absolute baseURL, always: with a relative value PaperMod generates broken canonical/og links; and between hugo server (which wants http://localhost:1313/) and production (which wants http://gx10-8532/) you need to pass it via environment variable, not hardcode it.
  • buildFuture: false bites: a post dated a few hours in the future is “published” for the CMS but invisible on the site. Classic cause of panic.
  • Shared volume ≠ magic: changes via Git trigger no rebuild; hence the idea of the build in the entrypoint.
  • A Flask prototype validated the idea in half a day before I rewrote it in .NET: sometimes you need it to understand what you don’t need.

What now

The definitive workflow: from the browser I write and publish, from the terminal I script and version, and Hugo underneath does just one thing brilliantly — turning Markdown into a static site that nginx serves at zero cost.

Next step on the roadmap: detach the content from the code repo (a dedicated volume), so the blog survives even a repository reset. But that, perhaps, is already the next version.