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
sitevolume; - admin (port 8001) is the ASP.NET Core API that also serves the
already-built Angular SPA, writes the
.mdfiles intocontent/posts/and re-runshugo buildinto 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 wantshttp://localhost:1313/) and production (which wantshttp://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.
