We have deployed this website with Kamal since the day we founded vensas. Back then it was still called MRSK, the paint on 37signals' announcement barely dry. We picked it because it does what a small team actually needs: push a container to a server, get zero-downtime rollouts and automatic TLS, and get on with your day, without running a cluster, patching a control plane, or drowning in YAML sprawl. Years later, vensas.de still deploys with kamal deploy on every push to main, and we like it just as much as we did back then.
We don't write Ruby for any of this, though. TypeScript and .NET are what we actually build with, this site included, and that has never mattered, because Kamal is a deployment tool, not a language choice: install the gem, point it at your server and registry, and from there it drives Docker over SSH for you, no Ruby required on your end.
What has changed is how much of our own work now goes through .NET Aspire. Its AppHost is a genuinely good way to describe a distributed application in C#: service references, environment variables, and container resources, typed and checked at compile time instead of scattered across YAML files. We covered where its deployment story has been heading in two earlier posts. Aspire 9 shipped orchestration without a deployment story for anything outside Azure. Aspire 13 closed most of that gap with a real pipeline model: Azure Container Apps, Azure App Service, Kubernetes, AKS, and Docker Compose as the one fully supported path without a cloud dependency.
Docker Compose only gets you to a single host, though, and stops there. There is still no official Aspire story for "a couple of VPS boxes, one deploy.yml, zero-downtime rollouts, automatic Let's Encrypt certificates", even though that's exactly the shape of infrastructure Kamal was built for, and the shape we keep reaching for on our own projects. So we built the missing piece ourselves and open sourced it: Vensas.Aspire.Hosting.Kamal.
What it does
The package hooks into Aspire's publishing pipeline: one call in your AppHost, then run aspire publish, and instead of (or alongside) Azure Bicep or a Kubernetes Helm chart, you get a complete Kamal deployment: a config/deploy.yml, generated multi-stage Dockerfiles per project, and a .kamal/secrets file that references your secret parameters without ever writing their values to disk.
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 # first time; afterwards: kamal deploy
That's the whole workflow. Everything past aspire publish is standard Kamal: the same commands, the same mental model, whether or not Aspire generated the config in front of it.
How the Aspire model maps to Kamal
| Aspire | Kamal |
|---|---|
| Project resource (or a Dockerfile-backed resource) | An app with its own deploy.yml (config/deploy.<name>.yml for every project after the first) |
| Container resource (Postgres, Redis, ...) | An accessories: entry on the primary app's config |
| External HTTP endpoint | proxy: config, SSL via Let's Encrypt, app_port taken from the endpoint |
| Secret parameters and connection strings that contain secrets | env.secret plus a .kamal/secrets entry, resolved from the deployer's own environment at deploy time |
| Plain parameters and env values | env.clear |
WithReference(...) between resources | Stable container DNS names on the shared kamal Docker network |
Kamal deploys one app per config file, so a multi-project AppHost gets one deploy.yml per project. They share the same servers, the same kamal-proxy, and the same Docker network; container and image names are namespaced per service so nothing collides.
A more realistic example
The samples above are deliberately minimal. What actually shows up in a real AppHost looks closer to this, based on wiring we did for one of our internal Aspire projects: an API, a background MCP server that only talks to the API, and a frontend that isn't a .NET project at all.
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;
});
}
A few things worth pointing out:
config.Servers["web"].Proxy has to be set explicitly to true. It defaults to false, which quietly skips registering the app with kamal-proxy, even though the top-level config.Proxy block is fully configured. Nothing throws an error. Three containers run fine; nothing listens on port 80 or 443. PublishAsKamalService hands you the full typed KamalDeployConfig, so a bug like this is one you can go in and fix rather than one buried behind a rigid template. The tradeoff is that you own getting it right.
Data that has to survive a deploy needs a named volume. A kamal deploy replaces the container; it doesn't restart it in place. Anything written to the container's own writable layer, an SQLite file for a small accounts table in our case, disappears on the next deploy unless it lives on a config.Volumes entry instead.
The frontend isn't always a .NET project. Vensas.Aspire.Hosting.Kamal auto-containerizes AddProject<T> resources with a generated multi-stage Dockerfile, but a static React build served by nginx needs its own Dockerfile, wired in via AddDockerfile and PublishAsKamalService the same way as any other resource.
Secret parameters stay out of the generated files. We model things like an email API key as builder.AddParameter("api-key", ..., secret: true), not a plain string. The publisher writes an env.secret reference into deploy.yml and the matching entry into .kamal/secrets, resolved from the deployer's shell environment only when kamal deploy actually runs. Checking the generated out/ directory into anything is unnecessary, which is why it's gitignored.
Everything is guarded by IsPublishMode. None of the Kamal wiring runs during aspire run; it only takes effect on aspire publish. That has nothing to do with Kamal specifically. It's just how you keep publish-only configuration from leaking into local development.
Testing before you touch a real server
Kamal has no --dry-run, but you don't need one to validate almost everything short of an actual deploy:
aspire publish -o ./out && cd out
# Schema and reference check: Kamal loads deploy.yml, resolves the image, roles, accessories.
# Kamal derives the image tag from git, so run this inside a git repository.
kamal config -c config/deploy.yml
# Secrets check: resolves .kamal/secrets with dotenv interpolation against your real env vars.
export KAMAL_REGISTRY_PASSWORD=x POSTGRES_PASSWORD=x
kamal secrets print -c config/deploy.yml
# Image build check, no server involved: builds the generated Dockerfile.
docker build -f Dockerfile.<app> <context-from-deploy.yml>
For a full rehearsal, point WithServers(...) at a throwaway Linux VM that runs Docker and accepts your SSH key. An OrbStack or Multipass box works fine, with a free GHCR repository as the registry. Kamal treats that VM the same way it treats production. Running kamal setup there catches the class of mistake config validation can't: a health check path that 404s, a missing volume, a proxy host that doesn't resolve. All of that shows up before any of it touches a real server.
What it doesn't do
We'd rather list the edges than let you find them by surprise. TLS terminates at kamal-proxy, so container-to-container traffic inside the kamal network is plain HTTP. Aspire's HTTPS service-discovery variables get dropped as a result, the same trade-off the Docker Compose target makes. Kamal also deploys one app per host group sharing a single Docker network, so this targets the classic Kamal topology: one server, or a handful, not a distributed cluster. And kamal-proxy only routes traffic once your app answers its health check with a 200. You need a real one. Aspire's ServiceDefaults only maps /health in the Development environment, so production needs builder.Services.AddHealthChecks() and app.MapHealthChecks("/health") wired in explicitly. That endpoint has to answer over plain HTTP, too, since the proxy probes it before TLS is anywhere involved.
None of that is specific to this package. It's Kamal's model, carried through faithfully. If you already know Kamal, nothing here will surprise you; if you don't, that list is a fair summary of what "server, not cluster" costs you in exchange for the simplicity.
Try it
Vensas.Aspire.Hosting.Kamal is on NuGet and on GitHub under the MIT license, sample AppHost and unit tests included. We built it because we wanted to keep deploying the way we already trust, on infrastructure we already own, for applications we now build with Aspire. If that's your setup too, we'd genuinely like to hear how it holds up. Issues and pull requests both welcome.
