Questo blog è la seconda cosa che ho costruito per scriverci sopra la prima. Il sito lo genera Hugo con il tema PaperMod — veloce, statico, zero dipendenze a runtime. Ma da terminale pubblicare un articolo è un rituale che scoraggia: aprire il file, sistemare il front matter, hugo server per l’anteprima, docker compose up -d --build, pregare.

Così ho costruito il suo pannello di amministrazione: una webapp che gira sul mio ASUS Ascent GX10, protetta da login, con editor Markdown, upload immagini e un bottone Pubblica che fa tutto da solo. Questo post racconta com’è fatta e perché ho scelto ciascuno pezzo.

L’architettura in venti secondi

Due container, un volume condiviso, nessuna 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, porta 80) serve i file statici dal volume site;
  • admin (porta 8001) è l’API ASP.NET Core che serve anche la SPA Angular già compilata, scrive i .md dentro content/posts/ e rilancia hugo build dentro il volume.

Il “database” sono i file Markdown del repo: gli stessi che uso da terminale, gli stessi che vede Git. Se il CMS domani sparisse, del CMS non importerebbe niente a nessuno. Questa è la cosa che mi convince di più dell’abbinata Hugo + file system: il CMS è un client, non il proprietario dei contenuti.

API: un progetto, quattro fette

Il cuore è BlogAdmin.Api, .NET 10, una manciata di file. Invece dei soliti progetti a strati (Controllers/Services/Repositories) ho usato il pattern feature slice: ogni funzionalità è una cartella autonoma che dichiara le sue rotte e basta.

Features/
  Auth/AuthSlice.cs        # login, JWT
  Posts/PostsSlice.cs      # CRUD sui .md
  Media/MediaSlice.cs      # upload immagini in static/images/
  Rebuild/RebuildSlice.cs  # hugo build manuale
Shared/
  HugoFile.cs              # parsing/scrittura front matter
  BlogEnvironment.cs       # path dal file di configurazione

Ogni fetta implementa una micro-interfaccia e si registra da sola nel gruppo /api; il Program.cs non conosce i dettagli di nessuna 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);

Le rotte del blog sono Minimal API pure — GET/POST/PUT/PATCH/DELETE su /api/posts — con autenticazione JWT (login con password presa da variabile d’ambiente, secret fuori dal repo) e Problem Details per gli errori. Niente Entity Framework, niente migrazioni: scrivere un post significa serializzare il front matter YAML e salvare un file.

Il pezzo più delicato è proprio HugoFile.cs: Hugo è esigente sul front matter (date, elenchi, tipi), quindi parsing e riscrittura passano da un unico punto, testato contro i file reali. È il genere di codice noioso che però tiene in piedi tutto.

UI: Angular 20 con gli signal store

Il front end è Angular 20 con Angular Material e — scelta che si è rivelata azzeccata — NgRx Signals per lo stato. Tre signal store (auth, posts, più il client API tipizzato) gestiscono tutto; le due pagine vere sono:

  • la lista dei post, con bozza/pubblicato, toggle con un click e cancella;
  • l’editor, split view Markdown + anteprima live, con i campi del front matter (titolo, descrizione, categorie, tag) come form e il corpo del testo in una textarea gigante.

Le pagine sono component standalone, le rotte sono protette da una guard e da un interceptor che allega il JWT. Con le signal e i nuovi controllo di flusso (@if, @for) il template sembra codice, non un linguaggio a parte. Per un pannello con due schermate è più che sufficiente, e il fatto che il build Angular finisca dentro wwwroot dell’API — un solo container da deployare, zero CORS in produzione — vale da solo la scelta.

.NET Aspire per non impazzire in sviluppo

La parte che mi diverte di più: lo sviluppo locale è orchesttrato da .NET Aspire. Un solo comando e si alzono API, UI e dashboard:

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

L’AppHost lancia l’API e fa un AddJavaScriptApp che esegue npm start per l’UI: log correlati, riavvii automatici, e la dashboard che mostra lo stato di entrambi i processi. Due dettagli che mi hanno fatto sudare (aarch64 docet): il template giusto è Aspire.ProjectTemplates, e per l’endpoint dell’API sulla porta fissa 5080 serve IsProxied = false, altrimenti il DCP si lamenta.

Deploy: Dockerfile multi-stage e un entrypoint onesto

Il Dockerfile dell’amministrazione è in tre stadi: build della UI Angular, build dell’API .NET, immagine finale con il runtime ASP.NET, Hugo dentro e la SPA in wwwroot. L’entrypoint.sh prima di avviare l’API fa una hugo build di produzione nel volume condiviso — così, se ho modificato contenuti via Git mentre il container era spento, all’accensione il sito è comunque aggiornato.

docker compose up -d --build   # tutto qui, anche per pubblicare

I due container hanno restart: unless-stopped e Docker parte al boot: il GX10 può essere spento e riacceso, il blog torna su da solo.

Lezioni (e trappole) raccolte per strada

  • File come API: far coincidere il modello di dati con i file Markdown ha eliminato strati interi di codice. Il CMS non ha storico perché ce l’ha Git.
  • baseURL assoluto, sempre: con un valore relativo PaperMod genera link canonical/og sballati; e tra hugo server (che vuole http://localhost:1313/) e la produzione (che vuole http://gx10-8532/) serve passarlo via variabile, non hardcoded.
  • buildFuture: false morde: un post datato qualche ora nel futuro è “pubblicato” per il CMS ma invisibile sul sito. Classica causa di panico.
  • Volume condiviso ≠ magia: le modifiche via Git non innescano nessuna rebuild; da qui l’idea della build nell’entrypoint.
  • Un prototipo in Flask ha validato l’idea in mezza giornata prima di riscriverlo in .NET: a volte serve per capire cosa non serve.

E adesso

Il flusso di lavoro definitivo: da browser scrivo e pubblico, da terminale scripto e versiono, e Hugo sotto fa solo una cosa benissimo — trasformare Markdown in un sito statico che nginx serve a costo zero.

Prossimo passo dalla roadmap: staccare i contenuti dal repo del codice (volume dedicato), così il blog resta in piedi anche a un reset del repository. Ma questa, eventualmente, è già la prossima versione.