Créer une API REST en C# avec .NET 10 : REST, microservices et déploiement Docker


Salut les amateurs de self-hosting ! À force d’empiler des services dans le homelab, on finit toujours par avoir besoin de son propre petit backend : un dashboard maison qui agrège l’état de vos machines, une automatisation qui doit exposer des données, un service interne que les autres viennent interroger. On peut bricoler ça à coups de scripts shell et de cron… mais ça devient vite illisible et fragile.

La vraie réponse, c’est une API REST. Et pour ça, C# avec .NET 10 est un excellent choix : le langage est moderne et agréable, le framework est rapide, et surtout le résultat se compile en un conteneur minuscule, sans root, qu’on pose en deux minutes derrière Traefik. Dans cet article, on part de zéro : le concept REST, les microservices, du code prêt à copier, les bonnes pratiques, une comparaison honnête avec Java et les autres, puis le déploiement Docker chez moi.

C’est quoi « REST », au juste ?

REST (Representational State Transfer) n’est pas une techno ni une bibliothèque : c’est un style d’architecture pour des API qui parlent HTTP. Une API est « RESTful » quand elle respecte quelques principes simples.

Tout est ressource, identifiée par une URL. Une ressource, c’est un nom, pas une action : /machines, /machines/42, jamais /getMachine ni /deleteMachine. L’URL désigne la chose, le verbe HTTP dit ce qu’on en fait.

Les verbes HTTP portent le sens de l’opération :

  • GET : lire une ressource. Sûr (ne modifie rien) et idempotent.
  • POST : créer une ressource. Non idempotent (deux POST = deux créations).
  • PUT : remplacer une ressource entière. Idempotent.
  • PATCH : modifier partiellement une ressource.
  • DELETE : supprimer. Idempotent.

Les codes de statut HTTP racontent ce qui s’est passé. C’est la grammaire de REST, et c’est souvent ce qu’on bâcle :

  • 200 OK, 201 Created (avec un en-tête Location vers la nouvelle ressource), 204 No Content (succès sans corps, typique d’un DELETE).
  • 400 Bad Request (requête malformée), 404 Not Found, 409 Conflict, 422 Unprocessable Entity (validation échouée).
  • 500 Internal Server Error quand c’est vous qui avez planté.

L’API est sans état (stateless). Chaque requête se suffit à elle-même : le serveur ne « se souvient » de rien entre deux appels. C’est ce qui permet de scaler horizontalement sans douleur — n’importe quelle instance peut répondre à n’importe quelle requête.

Les données voyagent dans une représentation, le plus souvent du JSON. Le client et le serveur s’échangent des représentations de l’état des ressources : voilà d’où vient le nom.

À savoir. Beaucoup d’API se disent « REST » alors qu’elles ne sont que du « JSON sur HTTP ». Ce n’est pas grave : la pureté REST absolue (avec l’hypermédia / HATEOAS, où chaque réponse contient les liens vers les actions possibles) est rarement implémentée en entier, et c’est très bien comme ça. Visez le respect des verbes, des URL-ressources et des codes de statut : c’est 90 % du bénéfice pour 10 % de l’effort.

Et les microservices, alors ?

On confond souvent « REST » et « microservices », mais ce sont deux choses différentes. REST décrit comment deux composants se parlent. Les microservices décrivent comment on découpe une application.

L’idée : au lieu d’un gros bloc unique (un monolithe) qui fait tout, on découpe en petits services indépendants, chacun responsable d’un domaine métier et de ses propres données, qui communiquent par le réseau (REST/HTTP, gRPC, ou une file de messages).

Les avantages réels :

  • Déploiement et montée en charge indépendants : on met à jour ou on duplique un seul service sans toucher au reste.
  • Isolation des pannes : si le service « stats » tombe, le service « auth » continue.
  • Diversité technologique : un service en C#, un autre en Go, pourquoi pas.

Mais soyons honnêtes sur le revers de la médaille, parce qu’on vous vend rarement la facture :

  • Vous troquez la complexité d’un gros code contre la complexité d’un système distribué : latence réseau, pannes partielles, cohérence des données qui devient « éventuelle ».
  • L’observabilité (logs, traces, métriques répartis sur dix services) devient un métier à part entière.
  • L’ops explose : dix services, ce sont dix déploiements, dix configs, dix surfaces d’attaque.

Mon avis de terrain. Pour un homelab, et même pour beaucoup de projets pros, un monolithe bien rangé bat un nuage de microservices mal maîtrisés. Commencez par une seule API propre. Vous découperez en services le jour où un besoin concret l’exige (scaling d’une partie précise, équipe qui grossit), pas par effet de mode. C# se prête très bien aux deux approches, donc rien ne vous enferme.

Ce qui suit marche aussi bien pour un petit monolithe que pour une brique de microservice : c’est la même mécanique.

Le projet : une API d’inventaire pour le homelab

On va construire une API qui gère un inventaire de machines (nom, IP, rôle). C’est minimal mais ça couvre tout le CRUD REST : lister, lire, créer, modifier, supprimer.

On utilise les Minimal API d’ASP.NET Core : depuis .NET 6 elles sont matures, et c’est la façon la plus concise d’écrire une API en C#. On crée le projet :

dotnet new webapi -n HomelabApi
cd HomelabApi

Le fichier projet (HomelabApi.csproj) cible .NET 10, la version LTS actuelle (supportée jusqu’en novembre 2028) :

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Les modèles (DTO)

En C# moderne, on décrit les objets transportés avec des records : immuables, concis, parfaits pour des DTO. On sépare volontairement l’objet qu’on lit de celui qu’on reçoit en création/modification (on ne laisse jamais le client choisir l’Id).

public record Machine(Guid Id, string Nom, string Ip, string Role);
public record MachineCreate(string Nom, string Ip, string Role);
public record MachineUpdate(string Nom, string Ip, string Role);

Le stockage

Pour rester focalisé sur l’API, on utilise un stockage en mémoire derrière une interface. En vrai vous brancherez une base (EF Core + PostgreSQL, par exemple), mais l’interface vous permettra d’échanger l’implémentation sans toucher aux endpoints.

public interface IMachineStore
{
    IReadOnlyList<Machine> GetAll();
    Machine? Get(Guid id);
    Machine Add(MachineCreate input);
    Machine? Update(Guid id, MachineUpdate input);
    bool Delete(Guid id);
}

public class InMemoryMachineStore : IMachineStore
{
    private readonly Dictionary<Guid, Machine> _data = new();

    public IReadOnlyList<Machine> GetAll() => _data.Values.ToList();

    public Machine? Get(Guid id) => _data.TryGetValue(id, out var m) ? m : null;

    public Machine Add(MachineCreate input)
    {
        var machine = new Machine(Guid.NewGuid(), input.Nom, input.Ip, input.Role);
        _data[machine.Id] = machine;
        return machine;
    }

    public Machine? Update(Guid id, MachineUpdate input)
    {
        if (!_data.ContainsKey(id)) return null;
        var machine = new Machine(id, input.Nom, input.Ip, input.Role);
        _data[id] = machine;
        return machine;
    }

    public bool Delete(Guid id) => _data.Remove(id);
}

Attention, piège discret. Ce Dictionary en mémoire n’est pas thread-safe : sous plusieurs requêtes simultanées, vous pouvez corrompre l’état. C’est acceptable pour un exemple, pas pour de la prod. En vrai, utilisez une vraie base de données (qui gère la concurrence pour vous) ou, à défaut, un ConcurrentDictionary.

Le cœur : Program.cs

C’est ici que tout se branche. Notez l’usage de TypedResults et des types de retour Results<...> : ils rendent chaque endpoint auto-documenté (le bon code de statut est explicite dans la signature).

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IMachineStore, InMemoryMachineStore>();
builder.Services.AddProblemDetails();   // erreurs au format standard RFC 9457
builder.Services.AddOpenApi();          // génère le document OpenAPI

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();                   // expose /openapi/v1.json en dev
}

// Sonde de santé : indispensable pour Docker et Traefik
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

// On regroupe et on versionne les routes : /v1/machines
var machines = app.MapGroup("/v1/machines");

machines.MapGet("/", (IMachineStore store) =>
    TypedResults.Ok(store.GetAll()));

machines.MapGet("/{id:guid}",
    Results<Ok<Machine>, NotFound> (Guid id, IMachineStore store) =>
        store.Get(id) is Machine m ? TypedResults.Ok(m) : TypedResults.NotFound());

machines.MapPost("/",
    Results<Created<Machine>, ValidationProblem> (MachineCreate input, IMachineStore store) =>
    {
        var errors = new Dictionary<string, string[]>();
        if (string.IsNullOrWhiteSpace(input.Nom))
            errors["nom"] = ["Le nom est obligatoire."];
        if (string.IsNullOrWhiteSpace(input.Ip))
            errors["ip"] = ["L'adresse IP est obligatoire."];
        if (errors.Count > 0)
            return TypedResults.ValidationProblem(errors);

        var machine = store.Add(input);
        return TypedResults.Created($"/v1/machines/{machine.Id}", machine);
    });

machines.MapPut("/{id:guid}",
    Results<Ok<Machine>, NotFound> (Guid id, MachineUpdate input, IMachineStore store) =>
        store.Update(id, input) is Machine m ? TypedResults.Ok(m) : TypedResults.NotFound());

machines.MapDelete("/{id:guid}",
    Results<NoContent, NotFound> (Guid id, IMachineStore store) =>
        store.Delete(id) ? TypedResults.NoContent() : TypedResults.NotFound());

app.Run();

On lance, et c’est en ligne :

dotnet run
# ou, avec rechargement à chaud pendant le développement :
dotnet watch run

Un POST sur /v1/machines renvoie un 201 Created avec l’en-tête Location, un GET sur un id inconnu renvoie un 404, un DELETE renvoie 204. Toute la grammaire REST est respectée, en une cinquantaine de lignes.

Piège réel, et il surprend tout le monde. Depuis .NET 9, le template webapi n’embarque plus Swagger UI par défaut. AddOpenApi() / MapOpenApi() génèrent bien le document JSON (/openapi/v1.json), mais aucune interface graphique : si vous tapez /swagger, vous tombez sur un 404 et vous croyez avoir tout cassé. La parade moderne, c’est d’ajouter Scalar (léger et joli) :

dotnet add package Scalar.AspNetCore
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();   // interface sur /scalar/v1
}

Les bonnes pratiques de code

Le squelette ci-dessus marche, mais voici ce qui sépare une API jetable d’une API qu’on garde des années.

Validez systématiquement les entrées. Ne faites jamais confiance au client. Ci-dessus on valide à la main, ce qui est explicite et toujours fiable. Pour aller plus loin, .NET 10 a intégré la validation par annotations ([Required], [Range], etc.) directement dans les Minimal API ; pour des règles complexes, FluentValidation reste la référence.

Renvoyez les bons codes et un format d’erreur standard. AddProblemDetails() fait que vos erreurs sortent au format ProblemDetails (RFC 9457) : un JSON normalisé (type, title, status, detail) que tous les clients savent lire. Bien plus propre qu’un message texte improvisé.

Async de bout en bout. Dès que vous touchez une base ou le réseau, utilisez async/await (async Task<Results<...>>). C# a une des ergonomies asynchrones les plus abouties qui existent : ne la gâchez pas avec des appels bloquants.

Versionnez votre API. Le préfixe /v1/ dès le départ vous évite de casser tous vos clients le jour où le contrat évolue. Ça ne coûte rien maintenant, ça sauve plus tard.

Ne jamais exposer ses entités de base directement. Passez toujours par des DTO (nos records). Sinon, le moindre changement de schéma de base fuite dans votre API publique, et vous risquez d’exposer des champs sensibles par accident.

Paginez les collections. Un GET /machines qui renvoie 50 000 lignes finira par tuer votre service. Prévoyez ?page=1&size=50 dès qu’une liste peut grossir.

Configuration et secrets hors du code. Lisez la config via appsettings.json et surtout les variables d’environnement (parfaites pour Docker). Aucun mot de passe, aucune clé en dur dans le dépôt Git.

Loggez de façon structurée et ajoutez une sonde de santé. ILogger<T> est injecté partout. Et notre endpoint /health n’est pas décoratif : Docker et Traefik s’en servent pour savoir si le conteneur est vivant.

Sécurité. Mettez une authentification (JWT Bearer, ou OIDC via un Authentik / Keycloak auto-hébergé), ne renvoyez jamais de stack trace en production, et n’exposez que ce qui doit l’être.

C# face à Java (et aux autres) : la comparaison honnête

Soyons factuels, parce que c’est un terrain miné par les guerres de chapelle.

C# et Java sont deux excellents choix, et de loin les plus proches l’un de l’autre : typage statique fort, machine virtuelle avec compilation JIT, ramasse-miettes, écosystèmes énormes, performances de premier ordre. Si quelqu’un vous dit que l’un est « objectivement » supérieur, méfiez-vous : la vérité est plus nuancée.

Là où C# / .NET marque des points concrets (et c’est pour ça que je le préfère pour un homelab) :

  • Ergonomie et concision. Les Minimal API ci-dessus tiennent en quelques lignes ; l’équivalent Spring Boot est plus verbeux et cérémonieux. C# a aussi introduit async/await, LINQ, les records et le pattern matching tôt et de façon très soignée.
  • Un outillage unifié. Un seul SDK, une seule CLI (dotnet), pas le maquis Maven/Gradle ni la jungle des distributions JDK.
  • Performance web. ASP.NET Core se classe régulièrement tout en haut des benchmarks, devant la plupart des stacks Java classiques.
  • Conteneurs. Le SDK produit des images chiseled minuscules et sans root, sans même écrire de Dockerfile (on y vient).

Là où Java garde de vrais atouts, en toute honnêteté :

  • Écosystème et maturité : l’univers JVM est gigantesque, Spring est d’une richesse rare, et l’offre de bibliothèques métier reste souvent plus large.
  • Concurrence : depuis Java 21 (LTS), les virtual threads (projet Loom) ont remis Java au niveau sur les charges massivement concurrentes, et Java 25 (LTS actuelle) stabilise la concurrence structurée.
  • Présence en entreprise et hébergement : Java est partout, et le restera.

Le verdict factuel, donc : pas de « gagnant » universel. Pour un backend d’entreprise très intégré à un écosystème JVM existant, Java est souvent le choix raisonnable. Pour un service léger, rapide à écrire et à conteneuriser dans un homelab, C# / .NET est, à mon goût, plus agréable et plus direct — mais c’est une préférence argumentée, pas une loi de la physique.

Et les autres ? En une phrase chacun :

  • Node.js (Express / Fastify / NestJS) : démarrage ultra-rapide à écrire, écosystème npm immense, mais le typage (même avec TypeScript) et la robustesse demandent plus de discipline.
  • Python / FastAPI : imbattable en productivité et pour tout ce qui touche au machine learning, un peu moins performant en pur débit.
  • Go : binaires statiques minuscules, concurrence native, parfait pour des microservices ; le langage est volontairement austère, ce qui plaît ou rebute.

Conteneuriser et déployer derrière Traefik

C’est là que .NET brille pour le self-hosting. Deux options.

Option 1 : le Dockerfile multi-étapes (build puis runtime, pour une image finale légère). On utilise l’image chiseled : ultra-minimale, sans shell, et qui tourne en utilisateur non-root par défaut.

# Étape de build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app

# Étape d'exécution (image minimale, sans root)
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "HomelabApi.dll"]

Option 2 : sans Dockerfile du tout. Le SDK .NET sait construire l’image OCI lui-même :

dotnet publish -c Release /t:PublishContainer -p:ContainerImageTag=latest

Piège réel numéro un sur le déploiement. Depuis .NET 8, un conteneur ASP.NET Core écoute sur le port 8080, plus sur le 80 comme avant. Si vous laissez server.port=80 dans vos labels Traefik, vous récoltez un magnifique 502 Bad Gateway sans rien comprendre. La règle : pointez Traefik vers le 8080, ou forcez le port avec la variable ASPNETCORE_HTTP_PORTS=8080.

Piège réel numéro deux, sur les images chiseled. Comme le conteneur tourne en non-root, il ne peut pas écrire où bon lui semble. Si votre app écrit des fichiers (logs, cache, base SQLite), montez un volume dont le propriétaire correspond à l’utilisateur du conteneur, ou écrivez dans /tmp. Sinon : Permission denied au démarrage.

Le déploiement chez moi, sur Nasti (mon NAS sous OpenMediaVault), suit exactement le pattern décrit dans mon article sur Docker : aucun port publié, c’est Traefik qui route en interne via le réseau traefik-public, et lui seul écoute sur 80/443.

services:
  homelab-api:
    image: gitea.dormishian.eu/haplo/homelab-api:latest
    container_name: homelab-api
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.homelab-api.rule=Host(`api.dormishian.eu`)"
      - "traefik.http.routers.homelab-api.entrypoints=websecure"
      - "traefik.http.routers.homelab-api.tls.certresolver=letsencrypt"
      - "traefik.http.services.homelab-api.loadbalancer.server.port=8080"

networks:
  traefik-public:
    external: true

Un docker compose up -d, et votre API est en HTTPS sur https://api.dormishian.eu, certificat Let’s Encrypt géré tout seul, sans qu’aucun port ne traîne à l’air libre. Si les notions de réseau external, de labels Traefik ou de restart policy vous semblent floues, tout est détaillé dans Comprendre Docker de A à Z.

Mémo des commandes utiles

dotnet new webapi -n HomelabApi      # nouveau projet API
dotnet run                            # lancer en local
dotnet watch run                      # lancer avec rechargement à chaud
dotnet add package <nom>              # ajouter une dépendance NuGet
dotnet publish -c Release             # publier pour la prod
dotnet publish -t:PublishContainer    # construire l'image OCI sans Dockerfile
dotnet test                           # lancer les tests

Conclusion

Écrire une API REST propre en C# n’a rien d’intimidant : des ressources identifiées par des URL, les bons verbes et codes de statut HTTP, des DTO immuables, de la validation, et le tout empaqueté dans un conteneur minuscule sans root. Les Minimal API de .NET 10 vous donnent un service lisible en quelques dizaines de lignes, qui se glisse derrière Traefik en deux minutes. Quant au débat C# contre Java : les deux sont d’excellents outils, et le choix tient plus à votre contexte qu’à une prétendue supériorité de l’un sur l’autre — même si, pour le confort en homelab, C# a ma préférence.

Pour aller plus loin

Le terrain de jeu est vaste une fois cette première brique posée :

  • Persistance : brancher EF Core + PostgreSQL à la place du stockage en mémoire, avec migrations versionnées.
  • Authentification : protéger l’API avec du JWT ou de l’OIDC via un Authentik ou un Keycloak auto-hébergé.
  • Communication entre services : gRPC pour le service-à-service performant, ou une file de messages (RabbitMQ + MassTransit) pour découpler vos microservices.
  • Observabilité : OpenTelemetry pour tracer ce qui se passe quand vos services se parlent.
  • CI/CD : faire construire et pousser l’image automatiquement par Gitea Actions à chaque commit, directement vers votre registre privé.

Mais ça, ce sont d’autres articles.