Get Started
AppHost layer

CoconutSharp.Aspire.Hosting

Opinionated application and deployment layer on top of .NET Aspire. Run locally with Aspire, publish with CoconutSharp.

.NET CLI dotnet add package CoconutSharp.Aspire.Hosting
PackageReference <PackageReference Include="CoconutSharp.Aspire.Hosting" Version="1.0.1" />
What it does

Aspire Hosting at a glance.

Run and publish configuration kept apart, so local development never carries cloud settings.

Free-form publish environments, each with its own subscription, resource group, region and identity.

Deploys directly to Azure App Service, with no container registry required.

Managed identity, external connection strings and Key Vault secrets as one identity model.

Straight from the package

README

The document below is the README shipped inside CoconutSharp.Aspire.Hosting 1.0.1, rendered as published.

CoconutSharp Aspire

An opinionated application and deployment layer on top of .NET Aspire.

CoconutSharpApplication
        ↓
.NET Aspire
        ↓
CoconutSharp Aspire extensions
        ↓
Run locally with Aspire
Publish with CoconutSharp

What it is

CoconutSharp Aspire is a thin, composable layer over Aspire's application builder. It preserves Aspire's application model, resource model, service discovery, local orchestration, dashboard and APIs — CoconutSharpApplication.CreateBuilder("MyApp", args) returns a full IDistributedApplicationBuilder, so every normal Aspire API keeps working unchanged.

On top of that it adds:

  • A clean Run vs Publish separation (OnRun / OnPublish).
  • Publish environments with free-form names, each carrying its own infrastructure profile (subscription, resource group, region, resource naming, identity).
  • Local defaults: emulators with persistent development data, automatically.
  • A first-class identity model (managed identity, external connection strings, Key Vault secrets).
  • Existing Azure resource consumption per environment.
  • First-class CloudLogin and CDM integrations for Angry Monkey applications.
  • A MAUI client integration for secure mobile access.

Why it exists

Angry Monkey applications need one application model that:

  • runs locally with zero cloud dependencies,
  • deploys to Staging on the existing infrastructure with the existing connection strings,
  • deploys to Production with managed identity,

without duplicating configuration, without leaking secrets into source, and without giving up any part of Aspire.

Relationship with Aspire

CoconutSharp does not fork Aspire, does not re-model resources, and does not replace service discovery, orchestration, the dashboard, or publishing. Everything CoconutSharp does is expressed through native Aspire mechanisms:

CoconutSharp API Aspire mechanism underneath
OnRun(...) ExecutionContext.IsRunMode
OnPublish(...) ExecutionContext.IsPublishMode
local storage defaults RunAsEmulator + data volume + persistent container lifetime
local Cosmos defaults RunAsPreviewEmulator + data volume + persistent container lifetime
env.UseResourceGroup(...) / UseLocation(...) AddAzureEnvironment + WithResourceGroup / WithLocation (and the Azure:* configuration keys)
generated names ({app}-{logical-name}-{aspire-id}) and WithResourceName(...) an InfrastructureResolver in AzureProvisioningOptions.ProvisioningBuildOptions — Azure.Provisioning's own naming pipeline
builder.AddManagedIdentity(...) AddAzureUserAssignedIdentity (one resource per name)
env.UseConnectionString(...) secret parameter (AddParameterFromConfiguration) + AddConnectionString + connection-string redirection + ExcludeFromManifest
env.UseManagedIdentity(...) Aspire's default identity/role-assignment model, AddAzureUserAssignedIdentity, WithAzureUserAssignedIdentity
publish.UseAzureAppService(...) AddAzureAppServiceEnvironment + WithComputeEnvironment + PublishAsAzureAppServiceWebsite
env.UseSlot(...) WithDeploymentSlot on the App Service environment
publish.UseAzureContainerApp(...) AddAzureContainerAppEnvironment + PublishAsAzureContainerApp

Run vs Publish

Run (aspire run)
    Aspire handles local execution.
    CoconutSharp provides sensible local defaults (emulators, persistent dev data).
    OnPublish configuration is never applied.

Publish (aspire publish / aspire deploy)
    CoconutSharp handles deployment configuration per environment.
    Aspire remains the underlying application and deployment model.
    OnRun configuration is never applied.

Local execution is not an environment. Dev is a real cloud environment, exactly like Staging and Production — never a name for aspire run.

Select the environment when publishing — Aspire's native CLI option is the primary way:

aspire publish -e Staging

Also supported: the --coconut-environment <name> AppHost argument (aspire publish -- --coconut-environment Staging, wins over -e), the CoconutSharp:Environment configuration key (environment variable CoconutSharp__Environment), and CoconutSharpOptions.PublishEnvironment in code. If environments are declared but none is selected, publishing fails with a clear error. (Note: the Aspire CLI defaults -e to Production when omitted.)

Environments

Environment names are free-form and none is reserved. An application declares whatever names it uses — Dev, Test, UAT, Staging, Live, prod-eu-west, anything — and CoconutSharp attaches no behavior to any particular name. Matching is case-insensitive.

Local execution (aspire run) is never an environment.

Environment profiles

Infrastructure-wide defaults belong to the environment, not to every resource:

builder.OnPublish(publish =>
{
    publish.Environment("UAT", env => env
        .UseSubscription("00000000-0000-0000-0000-000000000000")
        .UseResourceGroup("my-app-uat")
        .UseLocation("westeurope")
        .UseManagedIdentity("my-app-uat-identity"));

    publish.Environment("Live", env => env
        .UseResourceGroup("my-app")
        .UseLocation("westeurope")
        .UseManagedIdentity("my-app-live-identity"));
});

Only the selected environment's profile is ever created, so nothing from another environment can reach the output.

Anything shared by every environment is declared once, on the publish context itself, and an environment overrides only what genuinely differs:

builder.OnPublish(publish =>
{
    publish.UseSubscription("00000000-0000-0000-0000-000000000000");
    publish.UseLocation(AzureLocation.WestEurope);
    publish.UseAppServicePlan("SharedPlan", isLinux: false, resourceGroup: "plans-rg");

    publish.Environment("UAT",  env => env.UseResourceGroup("my-app-uat"));
    publish.Environment("Live", env => env.UseResourceGroup("my-app"));
});

Every environment-level setting has an application-level counterpart: UseSubscription, UseResourceGroup, UseLocation, PromptForLocation, UseAppServicePlan, UseManagedIdentity.

The environment always wins, and the order of the two blocks does not matter — write the shared defaults before or after the environments and the result is identical. UseLocation and PromptForLocation count as the same choice, so an environment that prompts for its region is not overwritten by a default one.

Environments can be named by an enum instead of a string, so the set is declared once and a typo is a compile error rather than an environment that silently never matches:

public enum MyEnvironment { Dev, UAT, Live }

publish.Environment(MyEnvironment.UAT, env => env.UseResourceGroup("my-app-uat"));

api.OnPublish(publish => publish.Environment(MyEnvironment.Live, env => env.UseProductionSlot()));

The enum member's name is the environment name, so it must match what -e and the launcher use — which they will, since the launcher reads the list from the AppHost.

Regions

A region can be given as text, as a well-known value, or asked for at publish time:

env.UseLocation("westeurope");                       // text
env.UseLocation(AzureLocation.WestEurope);           // well-known value, discoverable in IntelliSense
env.PromptForLocation(CoconutAzureLocations.Europe); // dropdown, asked for when publishing

Azure regions are not a C# enum in the Azure SDK — AzureLocation is a struct with a well-known value per region, which keeps every region usable while still listing them in IntelliSense. The text form is validated against the regions the SDK knows, so a typo fails immediately with a suggestion ('euwest' is not an Azure region … Did you mean westus, ukwest, westeurope?) instead of failing later in Azure. Pass validate: false for a region newer than the referenced SDK.

PromptForLocation models the region as an Aspire parameter with an InputType.Choice input, so the publish pipeline asks for it with a dropdown and remembers the answer in the deployment state.

Which values can be dropdowns? Anything resolved after the application model is built — regions, resource groups, subscriptions, connection strings — via Aspire parameters with Choice inputs. The environment itself can be a dropdown too, through CoconutSharp's deferred selection (CoconutSharpOptions.PromptForEnvironment, below): environment-scoped configuration is recorded instead of executed, and a pipeline step prompts for the environment before applying it.

Resource naming

The application name is required — a positional parameter on CreateBuilder, so a publish can never reach Azure.Provisioning without one. It is the stem of every generated Azure resource name:

var builder = CoconutSharpApplication.CreateBuilder("Contoso", args);

Resources are declared with logical (Aspire) names; the generated Azure name is {app}-{logical-name}-{aspire-id}, lowercase:

var cosmos = builder.AddAzureCosmosDB("cosmos");   // → contoso-cosmos-x7k2p

The logical name is the identifier inside the AppHost — references and service discovery keep using it; the resource name is what the resource is actually called in Azure.

The Aspire ID is five lowercase base-36 characters derived deterministically from the application and environment names: stable across publishes (deployments stay idempotent), and different per environment (globally unique names never collide across environments). Its usage is resource-aware, not universal:

  • Globally unique resources keep it — managed identity contoso-mi-x7k2p, Key Vault contoso-vault-x7k2p, Cosmos contoso-cosmos-x7k2p, storage contosostoragex7k2p.
  • App Service sites don't need it: Azure already keeps their hostname unique, so a site is contoso-web and its slot contoso-web-staging. When an ID does apply to a slotted name, the slot goes before the ID: contoso-web-staging-x7k2p.

Generated names are automatically sanitized and truncated to each Azure resource type's naming restrictions (a storage account drops the hyphens; truncation always preserves the ID).

An exact name replaces the generated one:

storage.WithResourceName("myappstorage");                  // exact Azure name
storage.WithResourceName("myappstorage", appendId: true);  // exact name + Aspire ID

// or per environment:
storage.OnPublish(publish => publish
    .Environment("Live", env => env.WithResourceName("myappstorage")));

Per-environment application name

An environment can adjust the application name itself, which then feeds every generated name for that environment:

builder.OnPublish(publish =>
{
    // Contoso + "dev" -> every generated name starts with "contoso-dev-"
    publish.Environment("Dev", env => env.UseAppNameSuffix("dev"));

    // A prefix works the same way, prepended instead:
    publish.Environment("UAT", env => env.UseAppNamePrefix("acme"));

    // Or replace it outright:
    publish.Environment("Staging", env => env.UseAppName("contoso-staging"));
});

var cosmos = builder.AddAzureCosmosDB("cosmos");
//   Dev     -> contoso-dev-cosmos-x7k2p
//   UAT     -> acme-contoso-cosmos-x7k2p
//   Staging -> contoso-staging-cosmos-x7k2p

UseAppNamePrefix/UseAppNameSuffix insert the separating dash for you; only the selected environment's call ever runs, so this never affects another environment's names.

Create or reuse

Every resource is create-or-reuse: if the named resource already exists it is used, otherwise it is created — there is no separate "existing" declaration to keep in sync with reality. The one way to bypass provisioning entirely is UseConnectionString(...), which points the environment at externally supplied infrastructure by secret connection string.

Azure App Service publish targets

publish.UseAzureAppService(...) puts a project on Azure App Service:

var api = builder
    .AddProject<Projects.Api>("api")
    .OnPublish(publish =>
    {
        publish.UseAzureAppService("my-api");        // optional explicit site name
        publish.Environment("UAT",  env => env.UseSlot("uat"));
        publish.Environment("Live", env => env.UseProductionSlot());
    });

One App Service environment per application, shared by every project. The first call to UseAzureAppService(...) creates the App Service environment resource (app-service-env); every other project that also calls UseAzureAppService(...) reuses that same environment instead of provisioning its own. Projects can still mix hosting targets freely — one project on App Service, another on UseAzureContainerApp(...) — CoconutSharp just keeps one shared instance per target type.

CoconutSharp never creates the App Service Plan or its Container Registry. Both must already exist — reference them explicitly:

publish.Environment("UAT", env => env
    .UseExistingAppServiceEnvironment("my-plan", "myacrregistry", resourceGroup: "my-shared-rg"));

Call this from the same environment-profile block that sets the resource group and location (before any project's UseAzureAppService(...) — the environment is created lazily on first use, so the reference must be in place first). Omit it entirely to be asked instead: the plan and registry names become ordinary Aspire parameters with no default, so an interactive publish prompts for them, and a non-interactive one fails naming exactly which configuration key is missing (Parameters:coconut-app-service-plan / Parameters:coconut-app-service-registry) — answerable via dotnet user-secrets set, an environment variable, or the CLI's own parameter argument, same as any other unresolved Aspire parameter.

The CoconutSharp.Aspire.Launcher launcher answers that prompt for you. Before the final "deploy?" confirmation, a real deploy lists the App Service Plans and Container Registries that already exist in the current subscription (az appservice plan list / az acr list) and lets the operator pick one instead of typing a name:

Existing Azure App Service Plans in the current subscription:

  1) my-plan  [Linux, westeurope, resource group my-shared-rg]

Select 1-1 (blank to leave it to Aspire's own prompt):

The pick only fills in a parameter that would otherwise be unresolved — an explicit UseExistingAppServiceEnvironment(...) value in the AppHost is never overridden. Nothing to pick from (Azure CLI unavailable, not signed in, or the subscription genuinely has none) silently skips the question; set CoconutSharpLauncherOptions.SelectAppServiceResources = false to disable it outright.

Azure Container Registry names are a global DNS namespace (<name>.azurecr.io) — not scoped to your subscription. If two unrelated environments (or two people testing independently) both let CoconutSharp's naming policy pick the same auto-generated name, whichever deploys second gets AlreadyInUse. Existing-only references sidestep this entirely: you name a real registry you already control.

Slots are how an environment picks its App Service destination. A deployment slot is an implementation detail of App Service, not a CoconutSharp environment — Dev, UAT, Live stay the abstraction the rest of the model (resource group, naming, identity) is built around:

  • env.UseSlot("uat") deploys that environment to a named slot on the shared App Service environment (env.WithDeploymentSlot underneath).
  • env.UseProductionSlot() deploys that environment to the site itself — no slot is created; the production slot is the App Service site.

Switching where a publish lands is switching the environment, exactly as in Run vs Publish — nothing else changes per App Service target:

aspire publish -e UAT      # deploys to the "uat" slot on the shared app-service-env
aspire publish -e Live     # deploys to the site itself (production slot)

Because the slot, resource group, region, resource naming and identity are all recorded per environment on the same env => env.... callback, selecting the environment is the single switch that moves the whole deployment — slot included — from one target to another. Nothing needs to be edited between publishes; only the -e <name> (or --coconut-environment, the CoconutSharp:Environment configuration key, CoconutSharpOptions.PublishEnvironment, or the PromptForEnvironment dropdown below) changes.

Azure App Service without containers

UseAzureAppService(...) above is always container-based — Aspire's own Azure App Service integration builds a Linux container image and runs it via App Service Site Containers, with no supported way to opt out. Some environments genuinely cannot use that (an existing Windows App Service Plan, for instance, cannot run Site Containers at all). UseAppService(...) is a completely independent target for exactly that case: dotnet publish + zip-deploy onto the site, with no Docker and no Container Registry anywhere.

publish.Environment("Staging", env => env
    .UseResourceGroup("MyApp")
    .UseAppServicePlan("SharedPlan", isLinux: false, resourceGroup: "plans-rg"));

var api = builder.AddProject<Projects.Api>("api")
    .OnPublish(publish =>
    {
        publish.UseAppService("MyApiSite");
        publish.Environment("Staging", env => env.UseSlot("staging"));
    });
  • The Plan follows the same rule as the container path: CoconutSharp never creates it — every environment must reference one that already exists. isLinux is required (not inferred): Windows and Linux App Service spell the same runtime stack differently, and CoconutSharp cannot determine which it is at AppHost-build time without a live Azure call. A plan shared from a central resource group is the normal case, hence the separate resourceGroup:.
  • An existing site is never wholesale rewritten. This target deploys through the Azure CLI rather than ARM/Bicep, deliberately: an ARM deployment of Microsoft.Web/sites sends a full PUT, so a template describing only the properties CoconutSharp models resets the ones it does not — custom domain bindings, certificates, slot configuration. Those are never touched. What CoconutSharp does own — application settings and the site configuration below — is brought in line with the AppHost on every deploy. A site that does not exist yet is created on the referenced Plan. Which of the two will happen is shown in the pre-deploy summary before you confirm.
  • Slots work the same as on the container target. env.UseSlot("staging") deploys that environment into the site's named slot (az webapp deploy --slot), env.UseProductionSlot() into the site itself. On the classic target this no longer touches Aspire's shared App Service environment at all — which matters, because creating that environment is what pulls in a Container Registry.
  • Deploy-only. The publish + zip-deploy step is anchored to deploy, so aspire run and a plain aspire publish never push code anywhere.

A project on the classic target and a project on the container-based target can coexist in the same application; each is entirely independent.

Configuration, slots and swaps

Everything the AppHost declares as an environment variable — Aspire's own WithEnvironment, CoconutSharp's CloudLogin/CDM wiring, env.WithEnvironmentVariable(...), connection strings — is projected onto the site as App Service application settings. The container target gets this for free (Aspire bakes it into the generated Bicep); the classic target applies it through the Azure CLI.

Before the confirmation, the launcher shows exactly what would change:

Configuration changes

  MyApiSite (staging slot)
    add     CloudLogin:Authority
    update  Cosmos:ConnectionString
    update  Worker process: 32-bit -> 64-bit
    update  Runtime stack: v8.0 -> v10.0
    update  WebSockets: Off -> On
    remove  OLD_MANUAL_SETTING
    (2 Azure-managed, left alone)

Every line says outright whether the setting is being added, updated or removed, grouped in that order so the deletions read last. Otherwise deliberately terse: the preview is meant to be scanned in a glance, not read as a report — a setting's exact name (or, for site configuration, its old and new value) is what the operator needs to approve a change, and nothing more per line earns its keep.

and then asks what to do — update settings and deploy, deploy only, update settings only, settings then swap, or swap only. Configuration and code are separable because they change and fail independently; a swap is a release decision rather than a build one. When nothing differs it says so, and a settings-only run takes seconds rather than minutes.

  • Application-setting values are never shown, logged, or written to disk. Settings routinely carry connection strings, so the AppHost sends the launcher only a SHA-256 fingerprint of each value — enough to tell "same" from "different" and nothing more. Error output redacts them too. Site-configuration values are shown in full, deliberately: a worker bitness or a TLS floor is not a credential, and a fingerprint would tell you nothing about a change you are approving.
  • Settings are slot-sticky by default (Azure's "deployment slot setting"), so a swap leaves them behind. A slot's configuration describes that slot — a staging connection string riding a swap into production is exactly the accident this prevents. Opt out per setting with env.WithSwappableSettings("Name") for values that identify the application rather than the slot.
  • Only what differs is written, since an unnecessary settings write restarts the site.
  • A setting the AppHost does not declare is removed, so what ends up on the site is what the AppHost says is on it. Three exceptions, all visible in the preview rather than silent:
    • settings Azure owns (WEBSITE_*, SCM_*, APPINSIGHTS_*, DIAGNOSTICS_*, and the rest) — the platform injects these itself, or the zip-deploy moments later does, so removing one either breaks the site or is undone within the same deploy;
    • settings named in env.PreserveSettings("NAME", ...) — the escape hatch for a value that genuinely belongs to something else and is deliberately not modelled in the AppHost;
    • settings the AppHost declares but could not resolve (below) — "no value to write" is not the same as "should not exist", and the site keeps what it has.
  • Values only a running application could supply are skipped, with a warning naming the setting — a reference to another resource's endpoint has no meaning at deploy time. The site keeps whatever it already has for them. Give it a literal for that environment with WithEnvironmentVariable(...) if it should be set.

Site configuration

Beyond application settings, CoconutSharp manages the site's own configuration. Every property resolves through four tiers, highest first:

Tier Source What it settles
1 AppHost, set explicitly anything, including properties CoconutSharp otherwise ignores
2 The project runtime stack (from its target framework); WebSockets and session affinity when its hosting model requires them — an interactive Blazor Server or SignalR host keeps per-client state on the instance serving it, so neither is a preference
3 CoconutSharp defaults 64-bit worker; Always On off
4 Untouched everything else — FTPS, TLS floor, HTTP/2, HTTPS-only, health check
publish.UseAppService("MyApiSite")     // runtime derived from the project's <TargetFramework>
       .UseAlwaysOn()                  // override a CoconutSharp default
       .UseFtpsState(CoconutFtpsStates.FtpsOnly);   // opt a tier-4 property into management

publish.Environment("Staging", env => env.Use32BitWorker());   // per environment

Tier 4 is genuinely invisible: CoconutSharp does not read, compare, report or write those properties, so whatever Azure defaults them to survives a deploy untouched. Setting one in the AppHost promotes it to tier 1, and it is managed from then on.

The runtime version no longer has to be repeated in the AppHost — the project's <TargetFramework> already states it. Pass runtimeVersion: explicitly to override, or when the project's target framework is not what the site should run.

The CoconutSharp publish target

Instead of mapping every project by hand, the application can declare CoconutSharp itself as the publish target — an application-level deployment target based on Azure App Service (it has nothing to do with Azure Container Apps):

builder.OnPublish(publish => publish.UseAzureAppServiceAsDefault());
  • aspire run is untouched. The target only exists during publish/deploy; locally the application is a completely normal Aspire application.
  • Configured resources are mapped automatically. Every compute resource without an explicit hosting platform is mapped to the CoconutSharp target (the shared app-service-env). A project that declares its own target — UseAzureAppService(...), UseAzureContainerApp(...) or native Aspire WithComputeEnvironment(...) — always keeps it, so hybrid applications keep working.
  • Or selected at publish time. With publish.UseAzureAppServiceAsDefault(o => o.PromptForResourceSelection = true) the publish pipeline shows a checkbox list of the not-explicitly-mapped compute resources; deselected resources are left out of that deployment. Non-interactive sessions deploy everything.

Publishing from Visual Studio

Visual Studio's built-in Aspire flow only offers Azure Container Apps for Aspire; the CoconutSharp target ships as publish profiles instead (a first-class entry beside the built-in one would require a Visual Studio extension). Add one profile per environment under Properties/PublishProfiles/ — the Publish window's profile list then is the environment picker:

Properties/PublishProfiles/CoconutSharp-Test.pubxml
Properties/PublishProfiles/CoconutSharp-UAT.pubxml
Properties/PublishProfiles/CoconutSharp-Live.pubxml
<Project>
  <PropertyGroup>
    <PublishProtocol>FileSystem</PublishProtocol>
    <PublishUrl>obj\coconutsharp-publish\</PublishUrl>

    <CoconutSharpTarget>true</CoconutSharpTarget>
    <CoconutEnvironment>UAT</CoconutEnvironment>
    <CoconutSharpAspireCommand>deploy</CoconutSharpAspireCommand>
  </PropertyGroup>
</Project>

Selecting CoconutSharp-UAT and publishing hands the AppHost to the Aspire CLI (aspire deploy -e UAT), which runs the CoconutSharp pipeline: resource mapping, then App Service deployment. The same profiles work from the command line:

dotnet publish -p:PublishProfile=CoconutSharp-UAT
Property Purpose
CoconutEnvironment Required. The environment to deploy (-e).
CoconutSharpAspireCommand deploy (default) or publish — artifacts only, no Azure.
CoconutSharpDryRun true lists the pipeline steps without executing them.
CoconutSharpOutputPath Artifact/deployment output path (-o).
CoconutSharpInteractive true allows CLI prompts (off by default — see below).
CoconutSharpNoBuild true (default) reuses the build MSBuild just did.
CoconutSharpAspireExe Aspire CLI path. Default aspire.

Trying it without deploying anything. Two safe modes, both worth running before a first real deployment:

dotnet publish -p:PublishProfile=CoconutSharp-UAT -p:CoconutSharpDryRun=true

lists the pipeline steps (you should see coconut-target among them), and

dotnet publish -p:PublishProfile=CoconutSharp-UAT -p:CoconutSharpAspireCommand=publish

runs the real pipeline but stops at generated Bicep — the web.bicep it writes should contain Microsoft.Web/sites, never Microsoft.App/containerApps.

Profile publishing is deliberately non-interactive. MSBuild redirects the console, so the CLI cannot render prompts; the target passes --non-interactive rather than letting a prompt hang the build. That is why the environment lives in the profile, and why PromptForResourceSelection belongs to the terminal workflow (aspire deploy) rather than the Publish window — from Visual Studio, resources are mapped automatically.

The MSBuild plumbing ships in the NuGet package (buildTransitive), so package consumers get the CoconutSharp publish target automatically.

App Service serves external endpoints only. A project deployed to the CoconutSharp target must expose one — builder.AddProject<...>("web").WithExternalHttpEndpoints(). Without it the pipeline fails with "The endpoint 'http' on resource 'web' is not external." This is an Azure App Service constraint, not a CoconutSharp one.

The CoconutSharp launcher

A publish profile cannot ask questions — MSBuild captures the console, so the target passes --non-interactive. The launcher exists for the interactive case: a small console application that asks what to do, then hands off to the Aspire CLI.

return await CoconutSharpLauncher.RunAsync(args);

That is the whole launcher, and nothing is passed to it.

The AppHost comes from the project file. A launcher says which application it publishes by referencing it, the way it would reference anything else:

<ItemGroup>
  <ProjectReference Include="..\MyApp.AppHost\MyApp.AppHost.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>

The launcher's MSBuild targets find that reference at build time — an AppHost is recognized from its project file, so nothing marks it as one — and record where it is in the launcher's own assembly, which is where RunAsync reads it back. A path string in code says the same thing until somebody moves a folder, at which point it says it at run time, in a console, to whoever was trying to deploy; a reference is checked by the build and follows a rename on its own.

ReferenceOutputAssembly="false" is required, and the build says so if it is missing. The launcher drives the AppHost through the Aspire CLI as a separate process and never loads its assembly — that separate process with an inherited console is exactly what keeps the interactive prompts working. Referencing the output would pull the whole Aspire hosting graph into a console that deliberately has no Aspire dependency.

The targets ship in the NuGet package (build/buildTransitive), so a PackageReference to CoconutSharp.Aspire.Launcher gets them automatically. A launcher that takes the launcher as a project reference imports them by hand instead, since build/*.targets only flow from a package:

<Import Project="..\..\src\CoconutSharp.Aspire.Launcher\build\CoconutSharp.Aspire.Launcher.targets" />

Two AppHost references make the launcher ask which one, rather than guess: name it with options.AppHostProjectName, or keep the others out with CoconutSharpAppHost="false" metadata on the reference. options.AppHostPath still overrides everything, and --apphost <path> overrides it for a single run.

The environments come from the AppHost — the launcher runs it once with CoconutSharp's own argument, which makes it report the environments it declares and exit without building or running anything. There is no second list to keep in sync, and no way for the two to disagree. Set options.Environments explicitly only to offer a subset.

Running it opens a menu straight away:

Coconut Sharp

  Account            [email protected]
  Subscription       My Subscription
                     7ab27abd-…  (current az login)
  ------------------------------------------------------------

  Up/Down to choose, Enter to confirm.

> Run locally (Aspire)
  Azure sign out — signed in as [email protected]
  Reading environments from the AppHost…

The menu opens before it knows everything. Asking the AppHost which environments it declares means building and running it, which is several seconds the operator would otherwise spend looking at a blank screen. So the launcher starts that in the background and draws the menu with what it already has: the local run, the Azure session action, and one dimmed row standing in for the environments. When the AppHost answers, that row is replaced in place by the environments, and the menu carries on:

> Run locally (Aspire)
  Azure sign out — signed in as [email protected]
  Deploy to Test
  Deploy to UAT
  Deploy to Live

The Azure row is always there, because every row below it needs a signed-in Azure CLI and nothing else in the launcher can fix that. Signed out, it reads Azure sign in — nobody is signed in and runs az login on this console — browser and device-code flows render and can be answered, exactly as they do during a real deploy. Signed in, it offers az logout. Neither is a launch: the menu reopens afterwards with the new account, in the header and in the row.

Who is signed in is read once, before the menu, and shown in the header from the first screen — so a launcher opened on a signed-out machine says so rather than failing several steps later, when something finally asks Azure for a resource.

An AppHost that cannot be read is a dimmed row saying so, not a dead launcher: a local run and the Azure session actions do not need the environment list. Naming an environment outright (-e UAT) does need it, and still waits and fails with the reason.

It skips the menu when told what to do, so it works in CI:

coconut-publish -e UAT --yes
Argument Effect
-e / --environment <name> Skip the menu and use this environment.
--local Run the AppHost locally under Aspire.
--artifacts Generate deployment artifacts instead of deploying.
--dry-run List the pipeline steps; change nothing.
-o / --output <path> Artifact output path.
-y / --yes Skip the deployment confirmation.

Anything the launcher does not recognise is forwarded to the Aspire CLI unchanged, so it never becomes a bottleneck on CLI features. A real deploy is confirmed before it runs; a dry run and a local run are not, because they change nothing.

The launcher deliberately does not reference Aspire or the AppHost. It decides what to run; the Aspire CLI hosts the application and the pipeline. That is what keeps the prompts working, and it is why the same library can back a Visual Studio extension later — see the extension design.

The pre-deploy summary

Before a real deploy runs (never for a dry run, artifacts-only run, or local run), the launcher shows exactly what is about to happen:

Deployment plan — Production

  Subscription   : My Subscription [00000000-0000-0000-0000-000000000000] (ambient — current az login)
  Resource group : contoso
  Region         : uksouth

  Resource                       Kind                       Status
  -----------------------------  -------------------------  ------------------------------------
  cosmos -> contosoproperties    AzureCosmosDB              ALREADY DEPLOYED (will update)
  login -> contoso-login         Project                    NEW
  contoso-mi-x7k2p         AzureUserAssignedIdentity  ALREADY DEPLOYED (will update)

Deploy to 'Production'? This changes Azure infrastructure. [y/N]:

Every row is sourced from somewhere that can actually be trusted, never guessed:

  • Resource group, region, and each resource's classification come straight from CoconutSharp's own model (UseConnectionStringEXTERNAL, everything else is CoconutSharp-managed) via an artifacts-only aspire publish the launcher runs on your behalf — never aspire deploy, so building the summary never touches Azure by itself.
  • Each resource's exact deployed name is read back out of the Bicep that same publish generates, rather than recomputed — CoconutSharp's naming policy only produces the name it hands to Azure.Provisioning; the resource type can sanitize it further (a storage account silently drops hyphens), so the generated template is the only fully reliable source.
  • Subscription and live existence (NEW vs. ALREADY DEPLOYED) come from the Azure CLI (az account show, az group show, az resource list) when it is installed and logged in. Every one of these calls degrades to UNKNOWN on failure — no CLI, not logged in, no network, timeout — rather than blocking the deploy or guessing.

Set options.ShowDeploymentSummary = false to skip it (it costs an extra aspire publish round-trip); otherwise it runs automatically, including with --yes for CI, so the log still records exactly what was about to happen.

Choosing the environment with a dropdown

var builder = CoconutSharpApplication.CreateBuilder(args, options => options.PromptForEnvironment = true);

With PromptForEnvironment, an interactive aspire publish shows the declared environments as a dropdown instead of requiring -e up front. When -e was passed it is preselected; the CLI only forwards an environment when -e is given, so a plain aspire publish simply highlights the first declared environment:

Select environment
  Environment: [ Staging ▾ Production ]

How it works: environment-scoped configuration (Environment(name, …) callbacks) is recorded instead of executed, and a coconut-environment pipeline step — ordered before parameter processing and Azure resource preparation — prompts for the environment and then applies exactly the selected environment's configuration. The published output is identical to passing -e explicitly (verified byte-for-byte).

Nothing changes where a prompt is impossible or unwanted:

  • --non-interactive sessions (CI) use the CLI-resolved environment exactly as before, and fail with the list of choices when none was passed or it matches no declared environment - so CI must keep passing -e <name> (with --non-interactive), exactly as it did before.
  • An explicitly chosen environment — CoconutSharpOptions.PublishEnvironment, --coconut-environment, or the CoconutSharp:Environment configuration key — is never prompted over.
  • Local runs are unaffected; prompting is publish-only.

Identity

Identity is first class and configurable per environment — at the environment level, applied to every compute resource that does not declare its own:

builder.OnPublish(publish =>
{
    publish.Environment("Dev",  env => env.UseManagedIdentity("my-app-dev-identity"));
    publish.Environment("Live", env => env.UseManagedIdentity("my-app-live-identity"));
});

or per resource:

api.OnPublish(publish => publish.Environment("Live", env => env.UseManagedIdentity("api-identity")));

One identity per name

builder.AddManagedIdentity();                  // generated name: {app}-mi-{aspire-id}
builder.AddManagedIdentity("my-app-identity"); // exact name; created when missing, reused when present

AddManagedIdentity returns the same resource for the same name however often it is called, and an explicitly named identity is deployed under exactly that name — so publishing an environment repeatedly reuses one identity and never produces duplicates.

Strategies

Supported strategies (CoconutIdentity):

  • Managed identity — preferred for production. Without a name, Aspire's default model applies (each compute resource gets an identity with the role assignments its referenced resources need). With a name, a shared user-assigned identity is attached.
  • Connection stringenv.UseConnectionString("Some:Config:Key"). The value comes from secure external configuration (user secrets, pipeline variables) at publish time; it is never stored in source. The Azure resource is not provisioned in that environment.
  • Key Vault secretenv.WithEnvironmentSecret("CDM:Storage:ConnectionString", vault, "name"). The app reaches the vault with its identity; the secret value never appears in configuration.
  • Azure credential — endpoint-only configuration; the Azure SDK credential chain authenticates.

Client applications never receive storage keys, Cosmos keys or other server credentials.

Azure resources

Aspire's own integrations are the API — AddAzureStorage, AddAzureCosmosDB, AddAzureKeyVault, AddAzureServiceBus, … CoconutSharp adds behavior, not new resource types:

  • Locally, storage runs as Azurite and Cosmos DB as the local emulator automatically, with named volumes and persistent container lifetimes, so restarting the AppHost keeps your data. Override per resource with OnRun(run => run.UsePersistentStorage("name") / UseEphemeralStorage() / DisableLocalDefaults()), or app-wide via CoconutSharpOptions.
  • Per environment, choose WithResourceName(...) (exact Azure name, create-or-reuse), UseConnectionString(...) (externally-supplied value) or UseManagedIdentity().
  • The generic strategies (WithResourceName, UseConnectionString, UseManagedIdentity, WithEnvironmentSecret) work with any Aspire Azure provisioning resource — Service Bus, Event Hubs, Redis, PostgreSQL, Key Vault, … — because they compose Aspire's own naming and parameter facilities.

Where components keep their data

CoconutSharp does not decide this, and holds no mapping for any component. A component that needs a database or a file store ships its own Aspire integration package, and an AppHost points it at an account the way it points any Aspire resource at another — WithReference, paired with WaitFor:

var cosmos = builder.AddAzureCosmosDB("cosmos");
var storage = builder.AddAzureStorage("storage");

var login = builder.AddCloudLogin<Projects.My_Login>("login");

login
    .WithReference(cosmos)
    .WithReference(storage)
    .WaitFor(cosmos)
    .WaitFor(storage);

Those overloads belong to the component's package, not to this one. Each does exactly what a plain Aspire reference does and then additionally maps the resource onto the configuration keys that component binds — so an account a service is never pointed at is an account it never uses, and nothing is adopted or created on its behalf.

WaitFor is Aspire's own, and it is not optional locally: a storage value is assembled from the emulator's allocated endpoints, which exist only once the emulator is running.

What CoconutSharp still owns is what an account resolves to per environment — WithResourceName, UseConnectionString, UseManagedIdentity. Those apply to plain Aspire connection-string references, which is all an integration package emits, so a component obeys a strategy it knows nothing about. That is what lets these packages stay independent of CoconutSharp entirely.

Hold the result of AddCloudLogin / AddCDM in a var. Each returns its own builder type, which is what carries that package's WithReference overloads; typing the variable as IResourceBuilder<ProjectResource> hides them again.

CloudLogin

// The application's own CloudLogin server project (Cosmos:* and Storage:* configuration):
var login = builder.AddCloudLogin<Projects.My_Login>("login");

// …or an already-deployed CloudLogin server:
var login = builder.AddCloudLogin("login", "https://login.example.com");

// Connect any project to it (sets LoginUrl + a normal Aspire reference):
web.WithCloudLogin(login);

CoconutSharp configures and connects the existing CloudLogin components; it never duplicates CloudLogin's authentication implementation.

CDM

var portal = builder.AddCDM<Projects.My_Portal>("portal", settings => settings.BaseName = "MyApp");

// WithServiceAccess adds the backend channel CDM's external data provider reads CloudLogin-owned
// records over. Its own call rather than a flag on WithReference: the key it grants bypasses user
// identity on CloudLogin/Service/*, so it is granted only to servers that need it.
portal.WithReference(login).WithServiceAccess(login).WaitFor(login);
portal.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);

CDM reads CDM:Cosmos:* and CDM:Storage:*, and its own Aspire package writes them. CDM is an application dependency, not a generic Azure resource: CoconutSharp deploys what the model contains, whoever wired it; CDM's own data access logic is untouched.

MAUI

CoconutSharp.Aspire.Client.Maui connects a MAUI client securely:

MAUI → CloudLogin authentication → access token → API → managed identity → Azure resources
builder.AddCoconutSharpClient(options =>
{
    options.CallbackScheme = "myapp";

    options.AddEnvironment(CoconutClientEnvironments.Local, "https://localhost:7157", env =>
    {
        env.ApiUrl = "https://localhost:7074";
        env.PortalUrl = "https://localhost:51671";
    });

    options.AddEnvironment(CoconutClientEnvironments.Production, "https://login.example.com", env =>
    {
        env.ApiUrl = "https://app.example.com";
        env.PortalUrl = "https://portal.example.com";
    });

    options.ActiveEnvironmentName = CoconutClientEnvironments.Production;
});

Authentication (system-browser sign-in, callback interception, secure token storage, refresh, logout) is provided by the existing CloudLogin.Maui integration — CoconutSharp supplies the environment-selected endpoints and wires it up. The device never receives Azure credentials.

Minimal example

Illustrative — exact APIs follow the implementation in src/CoconutSharp.Aspire.Hosting.

var builder = CoconutSharpApplication.CreateBuilder("MyApp", args);

// Environment names are yours to choose; each carries its own infrastructure profile.
builder.OnPublish(publish =>
{
    publish.Environment("UAT", env => env
        .UseResourceGroup("my-app-uat")
        .UseManagedIdentity("my-app-uat-identity"));

    publish.Environment("Live", env => env
        .UseResourceGroup("my-app")
        .UseManagedIdentity("my-app-live-identity"));
});

// Logical names: the active environment decides the deployed Azure name.
var storage = builder.AddAzureStorage("storage");
var cosmos  = builder.AddAzureCosmosDB("cosmos");

// Each service is pointed at what it uses, and waits for it.
var cloudLogin = builder.AddCloudLogin<Projects.Login>("login");
cloudLogin.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);

var cdm = builder.AddCDM<Projects.Portal>("portal");
cdm.WithReference(cloudLogin).WithServiceAccess(cloudLogin).WaitFor(cloudLogin);
cdm.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);

var api = builder
    .AddProject<Projects.Api>("api")
    .WithReference(cosmos)
    .WithCloudLogin(cloudLogin)
    .OnPublish(publish =>
    {
        publish.UseAzureAppService("my-api");
        publish.Environment("UAT",  env => env.UseSlot("uat"));
        publish.Environment("Live", env => env.UseProductionSlot());
    });

var web = builder
    .AddProject<Projects.Web>("web")
    .WithReference(api);

builder.Build().Run();

Packages

Package Purpose
CoconutSharp.Aspire.Hosting The hosting layer (AppHost side): environments, naming, identity, Azure targets
CoconutSharp.Aspire.Launcher The interactive console launcher (aspire run/deploy menu, account confirmation)
CoconutSharp.Aspire.Client Client-side environment/endpoint model
CoconutSharp.Aspire.Client.Maui MAUI integration (CloudLogin + environments)

Built and verified against Aspire 13.5.1 on .NET 10.

CoconutSharp composes existing Angry Monkey components declaratively; it does not reimplement them. Their own repositories are the source of truth for what they do and how to configure them directly:

  • CloudLogin - authentication, account and profile management. CloudLogin.Aspire.Hosting is what AddCloudLogin above wires into the application model; see its Aspire integration section for the parts CoconutSharp does not change.
  • CDM (Cloud Data Management) - the entity/form/view engine behind AddCDM. CDM.Aspire.Hosting documents the same configuration keys this package writes.