Serverraum mit leuchtenden türkisen und orangen Glasfaserkabeln
Zurück zum Blog
.NET AspireKamalDeployment

Vensas.Aspire.Hosting.Kamal: Ein Open-Source-Kamal-Deployment-Ziel für .NET Aspire

Sascha KieferDevOps

Wir deployen vensas.de mit Kamal, seit wir diese Firma gegründet haben, und wir lieben es seitdem. Weil mittlerweile das meiste unserer eigenen Arbeit auf .NET Aspire läuft, haben wir das fehlende Puzzlestück selbst gebaut: eine Aspire-Hosting-Integration, die aus aspire publish ein fertiges Kamal-Deployment macht, quelloffen als Vensas.Aspire.Hosting.Kamal.

Wir deployen diese Website mit Kamal, seit wir vensas gegründet haben. Damals hieß das Werkzeug noch MRSK, die Tinte unter 37signals' Ankündigung war kaum trocken. Gewählt haben wir es, weil es genau das leistet, was ein kleines Team wirklich braucht: einen Container auf einen Server pushen, dabei Zero-Downtime-Rollouts und automatisches TLS bekommen und danach einfach weiterarbeiten, ohne einen Cluster zu betreiben, eine Control Plane zu patchen oder sich in wucherndem YAML zu verlieren. Jahre später deployt vensas.de bei jedem Push auf main immer noch mit kamal deploy, und wir mögen es nach wie vor genauso wie am Anfang.

Ruby schreiben wir dabei trotzdem nicht: Gebaut wird bei uns mit TypeScript und .NET, diese Website eingeschlossen, und das hat nie gestört, weil Kamal ein Deployment-Werkzeug ist und keine Sprachentscheidung. Man installiert das Gem, zeigt damit auf den eigenen Server und die eigene Registry, und ab dann steuert es Docker über SSH, ohne dass man selbst eine einzige Zeile Ruby anfassen muss.

Was sich seitdem verändert hat, ist, wie viel unserer eigenen Arbeit inzwischen über .NET Aspire läuft. Der AppHost beschreibt eine verteilte Anwendung in C#: Service-Referenzen, Umgebungsvariablen, Container-Ressourcen, alles typisiert und schon beim Kompilieren geprüft, statt in YAML-Dateien verstreut. Wohin sich Aspires Deployment-Story entwickelt hat, haben wir in zwei früheren Artikeln aufgeschrieben. Aspire 9 lieferte Orchestrierung, aber keine Deployment-Story für alles außerhalb von Azure. Aspire 13 hat den Großteil dieser Lücke mit einem echten Pipeline-Modell geschlossen: Azure Container Apps, Azure App Service, Kubernetes, AKS und Docker Compose als einziger vollständig unterstützter Pfad ohne Cloud-Abhängigkeit.

Docker Compose bringt einen allerdings nur bis zu einem einzigen Host und nicht weiter. Für "ein paar VPS-Server, eine deploy.yml, Zero-Downtime-Rollouts, automatische Let's-Encrypt-Zertifikate" gibt es weiterhin keine offizielle Aspire-Lösung, obwohl genau das die Infrastruktur ist, für die Kamal gebaut wurde, und die Form, zu der wir bei unseren eigenen Projekten immer wieder greifen. Also haben wir das fehlende Stück selbst gebaut und quelloffen unter MIT veröffentlicht: Vensas.Aspire.Hosting.Kamal.

Was das Package macht

Das Package hängt sich in Aspires Publish-Pipeline ein: ein einziger Aufruf im AppHost, dann aspire publish ausführen, und man bekommt, statt Azure Bicep oder einem Kubernetes-Helm-Chart oder zusätzlich dazu, ein vollständiges Kamal-Deployment: eine config/deploy.yml, generierte Multi-Stage-Dockerfiles pro Projekt und eine .kamal/secrets-Datei, die auf die Secret-Parameter verweist, ohne deren Werte jemals auf die Platte zu schreiben.

dotnet add package Vensas.Aspire.Hosting.Kamal
var builder = DistributedApplication.CreateBuilder(args);

builder.AddKamalEnvironment("kamal")
    .WithServers("203.0.113.10")
    .WithRegistry("ghcr.io", "my-org")
    .WithProxyHostSuffix("example.com");

var postgres = builder.AddPostgres("postgres").WithDataVolume();
var db = postgres.AddDatabase("appdb");

builder.AddProject<Projects.Web>("web")
    .WithExternalHttpEndpoints()
    .WithReference(db)
    .PublishAsKamalService((_, config) =>
    {
        config.Proxy!.Host = "app.example.com";
        config.Proxy.Healthcheck = new() { Path = "/health" };
    });

builder.Build().Run();
aspire publish -o ./out
cd out
export KAMAL_REGISTRY_PASSWORD=... POSTGRES_PASSWORD=...
kamal setup   # beim ersten Mal, danach: kamal deploy

Das ist der gesamte Workflow. Alles, was nach aspire publish kommt, ist Standard-Kamal: dieselben Befehle, dasselbe mentale Modell, ganz egal, ob Aspire die Konfiguration davor generiert hat oder jemand sie von Hand geschrieben hätte.

Wie sich das Aspire-Modell auf Kamal abbildet

AspireKamal
Projekt-Ressource (oder eine Dockerfile-basierte Ressource)Eine App mit eigener deploy.yml (config/deploy.<name>.yml für jedes Projekt nach dem ersten)
Container-Ressource (Postgres, Redis, ...)Ein accessories:-Eintrag in der Konfiguration der primären App
Externer HTTP-Endpunktproxy:-Konfiguration, SSL über Let's Encrypt, app_port aus dem Endpunkt übernommen
Secret-Parameter und Connection Strings, die Secrets enthaltenenv.secret plus ein .kamal/secrets-Eintrag, zur Deploy-Zeit aus der Umgebung des Deployers aufgelöst
Einfache Parameter und Env-Werteenv.clear
WithReference(...) zwischen RessourcenStabile Container-DNS-Namen im gemeinsamen kamal-Docker-Netzwerk

Kamal deployt eine App pro Konfigurationsdatei, ein AppHost mit mehreren Projekten bekommt also eine eigene deploy.yml pro Projekt, alle auf denselben Servern, mit demselben kamal-proxy und demselben Docker-Netzwerk. Container- und Image-Namen bleiben dabei pro Service getrennt, damit nichts kollidiert.

Ein realistischeres Beispiel

Die Beispiele oben sind bewusst schmal gehalten. Was in einem echten AppHost auftaucht, sieht eher so aus, angelehnt an eine Verkabelung aus einem unserer internen Aspire-Projekte: eine API, ein MCP-Server im Hintergrund, der nur mit der API spricht, und ein Frontend, das gar kein .NET-Projekt ist.

if (builder.ExecutionContext.IsPublishMode)
{
    builder.AddKamalEnvironment("kamal")
        .WithServers("203.0.113.10")
        .WithRegistry("ghcr.io", builder.Configuration["KAMAL_REGISTRY_USERNAME"] ?? "CHANGE_ME")
        .WithProxyHostSuffix(hostSuffix);

    apiResource.PublishAsKamalService((_, config) =>
    {
        config.Proxy = new() { Host = $"api.{hostSuffix}", Ssl = true, AppPort = 8080, Healthcheck = new() { Path = "/health" } };
        config.Servers["web"].Proxy = true;
        config.Volumes = ["backend-data:/app/.data"];
    });

    mcpResource.PublishAsKamalService((_, config) =>
    {
        config.Proxy = new() { Host = $"mcp.{hostSuffix}", Ssl = true, AppPort = 8080, Healthcheck = new() { Path = "/health" } };
        config.Servers["web"].Proxy = true;
    });

    builder.AddDockerfile("frontend-image", "../../frontend")
        .PublishAsKamalService((_, config) =>
        {
            config.Proxy = new() { Host = hostSuffix, Ssl = true, Healthcheck = new() { Path = "/" } };
            config.Servers["web"].Proxy = true;
        });
}

An diesem Beispiel lohnen sich ein paar Details, die wir festhalten wollen.

config.Servers["web"].Proxy muss man von Hand auf true setzen. Der Standardwert ist false, wodurch sich die App still und leise nicht bei kamal-proxy anmeldet, selbst wenn der config.Proxy-Block vollständig konfiguriert ist. Es gibt dabei keine Fehlermeldung: Drei Container laufen völlig problemlos, nur hört am Ende nichts auf Port 80 oder 443. Weil PublishAsKamalService das vollständig typisierte KamalDeployConfig in die Hand gibt, lässt sich ein solcher Fehler reparieren, statt hinter einer starren Vorlage zu verschwinden, aber dafür muss man ihn eben selbst finden und richtig setzen.

Daten, die ein Deployment überleben sollen, brauchen ein benanntes Volume. Ein kamal deploy ersetzt den Container, statt ihn an Ort und Stelle neu zu starten, weshalb alles, was auf der eigenen beschreibbaren Schicht des Containers landet, bei uns eine SQLite-Datei für eine kleine Accounts-Tabelle, nach dem nächsten Deploy verschwindet, wenn es nicht stattdessen in einem config.Volumes-Eintrag liegt.

Das Frontend ist nicht immer ein .NET-Projekt. Vensas.Aspire.Hosting.Kamal containerisiert AddProject<T>-Ressourcen automatisch über ein generiertes Multi-Stage-Dockerfile, aber ein statischer React-Build, den nginx ausliefert, braucht sein eigenes Dockerfile, eingebunden über AddDockerfile und PublishAsKamalService, genau wie jede andere Ressource auch.

Secret-Parameter tauchen in den generierten Dateien nicht auf. Einen E-Mail-API-Key modellieren wir als builder.AddParameter("api-key", ..., secret: true), nicht als schlichten String, und der Publisher schreibt dafür eine env.secret-Referenz in die deploy.yml sowie den passenden Eintrag in .kamal/secrets, aufgelöst erst zur Laufzeit von kamal deploy aus der Shell-Umgebung des Deployers. Das generierte out-Verzeichnis irgendwo einzuchecken bringt nichts, weshalb es per .gitignore ausgeschlossen ist.

Alles hängt an IsPublishMode. Während aspire run läuft keine der Kamal-Verkabelungen, erst aspire publish aktiviert sie. Mit Kamal selbst hat das wenig zu tun; es ist schlicht der Weg, um Publish-Konfiguration aus der lokalen Entwicklung herauszuhalten.

Testen, bevor ein echter Server angefasst wird

Einen --dry-run kennt Kamal nicht, den braucht man aber auch nicht, um fast alles außer dem eigentlichen Deployment selbst zu prüfen.

aspire publish -o ./out && cd out

# Schema- und Referenzprüfung: Kamal lädt deploy.yml, löst Image, Rollen, Accessories auf.
# Kamal leitet den Image-Tag aus Git ab, also innerhalb eines Git-Repositories ausführen.
kamal config -c config/deploy.yml

# Secrets-Prüfung: löst .kamal/secrets per Dotenv-Interpolation gegen die echten Env-Variablen auf.
export KAMAL_REGISTRY_PASSWORD=x POSTGRES_PASSWORD=x
kamal secrets print -c config/deploy.yml

# Image-Build-Prüfung, ohne Server: baut das generierte Dockerfile.
docker build -f Dockerfile.<app> <context-aus-deploy.yml>

Für eine vollständige Generalprobe zeigt man mit WithServers(...) einfach auf eine Wegwerf-Linux-VM, die Docker laufen lässt und den eigenen SSH-Key akzeptiert, etwa eine OrbStack- oder Multipass-Box, dazu eine kostenlose GHCR-Registry. Kamal behandelt diese VM wie eine echte Produktionsumgebung, und kamal setup deckt dort genau die Fehler auf, die eine reine Konfigurationsprüfung nicht sieht: einen Health-Check-Pfad, der 404 liefert, ein fehlendes Volume, einen Proxy-Host, der sich nicht auflösen lässt, alles noch, bevor überhaupt ein echter Server angefasst wird.

Was das Package nicht macht

Die Grenzen zählen wir lieber selbst auf, statt sie überraschend auftauchen zu lassen. TLS terminiert bei kamal-proxy, weshalb der Container-zu-Container-Traffic im kamal-Netzwerk über einfaches HTTP läuft und Aspires HTTPS-Service-Discovery-Variablen dadurch wegfallen; denselben Kompromiss geht auch das Docker-Compose-Target ein. Kamal deployt außerdem eine App pro Servergruppe, die sich ein Docker-Netzwerk teilt, das Package zielt also auf die klassische Kamal-Topologie: ein Server oder eine Handvoll, kein verteiltes Cluster. Dazu kommt, dass kamal-proxy Traffic erst weiterleitet, sobald die App den Health Check mit einem echten 200 beantwortet. Aspires ServiceDefaults mappt /health aber nur in der Development-Umgebung, weshalb builder.Services.AddHealthChecks() und app.MapHealthChecks("/health") in Produktion explizit verdrahtet werden müssen, und dieser Endpunkt zudem über einfaches HTTP antworten muss, weil der Proxy ihn abfragt, bevor TLS überhaupt eine Rolle spielt.

Das liegt nicht speziell an diesem Package, sondern ist Kamals eigenes Modell, unverändert übernommen. Wer Kamal schon kennt, wird hier nichts Neues finden; wer nicht, für den fasst diese Liste ganz gut zusammen, was "ein Server, kein Cluster" im Tausch für die Einfachheit kostet.

Ausprobieren

Vensas.Aspire.Hosting.Kamal liegt auf NuGet und auf GitHub, Sample-AppHost und Unit-Tests inklusive. Gebaut haben wir es, weil wir so weiterdeployen wollten, wie wir es schon lange vertrauen, auf Infrastruktur, die uns bereits gehört, für Anwendungen, die wir jetzt mit Aspire bauen. Ist das auch dein Setup, würden wir wirklich gerne hören, wie es sich bei dir schlägt. Issues und Pull Requests sind uns beide gleich willkommen.

Quellen

Brauchst du Unterstützung?

Du setzt .NET Aspire ein und fragst dich, ob Kamal zu deiner Infrastruktur passt, oder brauchst generell einen zweiten Blick auf deine Deployment-Pipeline? Wir haben dieses Package gebaut, weil wir es selbst gebraucht haben, und helfen dir gerne dabei, es oder einen anderen Deployment-Weg zum Laufen zu bringen. Melde dich einfach bei uns.

Kontakt aufnehmen