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
.mddentrocontent/posts/e rilanciahugo builddentro 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 vuolehttp://localhost:1313/) e la produzione (che vuolehttp://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.
