Parts 1 and 2 made one AppHost the source of truth for every connection your functions trigger on, locally. The question for Part 3 is what happens to that graph when you deploy it: which AppHost resources become real Azure infrastructure, and which trigger connections you still have to wire by hand. There are two toolchains that can do the deploy now, so the first decision is which one. This article uses azd, generates the Bicep, and reads it line by line to find the boundary where the generator stops and you take over.
From local to Azure
The AppHost is already a resource graph. Deployment reads that graph and turns it into Azure infrastructure; it is not a second pipeline you write and keep in sync with the first. That is the whole premise: the same AddAzureServiceBus("messaging") that started an emulator container in Part 2 provisions a real namespace at publish, and the same WithReference that resolved a local connection string emits an Azure role assignment and an identity-based environment variable.
Two toolchains turn that graph into Azure today:
-
azd(Azure Developer CLI): the mature, CI-friendly path. It detects the AppHost, generates Bicep from the resource graph, provisions, and deploys. -
aspire deploy(built on the neweraspire publish/aspire dopipeline): what Microsoft now recommends as the default for new Aspire projects. The aspire.dev guidance is explicit thatazd"is still supported with Aspire for existing workflows, but it is no longer the recommended default deployment path."
This article uses azd because it is the path with stable CI/CD integration, federated identity, and a documented pipeline generator, which is what most teams shipping today are on. The infrastructure both toolchains generate is the same; the commands differ. Three commands form the azd spine: azd init, azd provision, azd deploy. Knowing what each does, and where the split between them matters, is the rest of the deploy story.
The AZD workflow
azd init run from the solution directory scans the tree and detects the AppHost project. It keys off the Aspire AppHost SDK markers in the .csproj, not a folder name, and reports the detected service before writing anything:
$ azd init
? How do you want to initialize your app? Use code in the current directory
Scanning app code in current directory
(✓) Done: Scanning app code in current directory
Detected services:
.NET (Aspire)
Detected in: ./AspireDemo.AppHost/AspireDemo.AppHost.csproj
azd will generate the files necessary to host your app on Azure.
Generating files to run your app on Azure:
(✓) Done: Generating ./azure.yaml
(✓) Done: Generating ./next-steps.md
SUCCESS: Your app is ready for the cloud!
What it writes is small. An azure.yaml at the root maps the AppHost to Azure, plus a .azure/<env>/ folder holding per-environment config and an .env file:
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: aspire-demo
services:
app:
language: dotnet
project: ./AspireDemo.AppHost/AspireDemo.AppHost.csproj
host: containerapp
host: containerapp records the target family; it does not by itself decide the infrastructure. azd reads the compute target from the AppHost, which is why the AddAzureContainerAppEnvironment("aca-env") line added in Part 3's sample matters (more on that below). With azure.yaml in place, two commands carry the deploy, and they are deliberately separate:
-
azd provisioncreates and configures the Azure resources from the generated infrastructure. It pushes no application code. Run it and you get a resource group, a Container Apps environment, a registry, the backing services, identities, and role assignments, all empty of your images. Against a real subscription, the Part 3 sample provisioned its ten resources in 6 minutes 25 seconds, the Container Apps environment alone taking 3 minutes 24 of those. -
azd deploybuilds your projects, pushes the images to the registry, and creates new Container Apps revisions wired to the provisioned resources. It creates no infrastructure. Same sample: 7 minutes 32 seconds to build the three Functions images plus the Redis container locally, push them, and bring the four apps up.
azd up runs both in one pass (package then provision then deploy) and is the right call when you are iterating locally and do not care about the seam. In CI/CD the seam is the point: provision changes infrastructure on a slow, reviewed cadence; deploy ships code many times a day. Splitting them lets infra changes gate behind approval while app deploys stay fast.
One honest caveat the split carries: when only infrastructure changes, azd provision updates the resources but does not refresh connection values in the already-running apps. You run azd deploy again to pick those up. The Azure docs put it plainly: when in doubt, use azd up.
What Aspire generates
This is the part worth slowing down on, because the generated infrastructure is the contract between your AppHost and Azure, and reading it tells you exactly what you own. The artifact is Bicep. (Aspire also emits an aspire-manifest.json, but it is now a deprecated compatibility format kept for azd interop, not the thing to center your mental model on. The Bicep is the truth.)
Generated from the Part 3 AppHost, the tree is one file per resource with a main.bicep that stitches them together:
main.bicep targetScope = 'subscription'; creates the RG, calls one module per resource
aca-env/aca-env.bicep managed identity, ACR + AcrPull, Log Analytics, the managed environment
aca-env-acr/aca-env-acr.bicep the container registry
host-storage/host-storage.bicep AzureWebJobsStorage account (shared by all three functions)
app-storage/app-storage.bicep the receipts storage account
messaging/messaging.bicep the Service Bus namespace + orders queue
cache/cache.bicep Redis (published as a container)
orders-http/orders-http.bicep the HTTP-trigger function app (+ identity, + roles modules)
orders-queue/orders-queue.bicep the queue-trigger function app (+ identity, + roles modules)
orders-sb/orders-sb.bicep the Service Bus function app (+ identity, + three roles modules)
main.bicep targets the subscription scope, creates the resource group, and calls every module with one module block apiece. The shape is mechanical, by design: every AppHost resource has a one-to-one Bicep module, and the wiring between them is explicit parameters and outputs, not magic.
The environment module is where Aspire sets up the platform every app runs on. aca-env.bicep provisions a user-assigned managed identity, an Azure Container Registry with an AcrPull role assignment for that identity, a Log Analytics workspace, the Container Apps managed environment itself (Consumption profile), and the Aspire dashboard component:
resource aca_env 'Microsoft.App/managedEnvironments@2025-07-01' = {
name: take('acaenv${uniqueString(resourceGroup().id)}', 24)
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: aca_env_law.properties.customerId
sharedKey: aca_env_law.listKeys().primarySharedKey
}
}
workloadProfiles: [
{ name: 'consumption', workloadProfileType: 'Consumption' }
]
}
}
Each function project becomes a Microsoft.App/containerApps resource, and the single most important detail in the tree is the last line:
resource orders_sb 'Microsoft.App/containerApps@2025-10-02-preview' = {
name: 'orders-sb'
// ...
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${orders_sb_identity_outputs_id}': { }
'${aca_env_outputs_azure_container_registry_managed_identity_id}': { }
}
}
kind: 'functionapp'
}
kind: 'functionapp' is not cosmetic. It is the Functions-optimized Container Apps variant, and it is what lets the platform derive KEDA autoscaler rules from your trigger attributes instead of you writing scale rules. A plain container app does not get that. Aspire emits kind: 'functionapp' on all three apps because all three are AddAzureFunctionsProject, not AddProject. The deployed apps confirm it: the three Functions apps report functionapp, the Redis container app reports nothing. One trap if you go check this yourself: ARM only returns kind from api-version 2025-07-01 onward. Query a container app with 2024-03-01 and the field is silently absent, which looks exactly like Aspire forgot to set it.
The connections from Part 2 land as identity-based environment variables, not connection strings. The Service Bus app's container carries exactly what its three WithReference calls declared:
env: [
{ name: 'messaging__fullyQualifiedNamespace', value: messaging_outputs_servicebusendpoint }
{ name: 'receipts__blobServiceUri', value: app_storage_outputs_blobendpoint }
{ name: 'AzureWebJobsStorage__blobServiceUri', value: host_storage_outputs_blobendpoint }
// ...table, queue, dataLake service URIs for host storage...
{ name: 'AZURE_CLIENT_ID', value: orders_sb_identity_outputs_clientid }
{ name: 'AZURE_TOKEN_CREDENTIALS', value: 'ManagedIdentityCredential' }
]
No secrets for Service Bus or Storage; the app authenticates as its user-assigned identity, and AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential pins the credential type so the SDK does not waste startup probing the others. Redis is the exception in this sample: because it is a containerized Redis rather than a managed Azure resource, its connection comes in as an ACA secret (connectionstrings--cache), not an identity.
For every referenced resource, Aspire generates a role assignment module. The Service Bus app gets three: AzureServiceBusDataOwner on the namespace, and Blob plus the rest of the Data Contributor set on the storage accounts it touches. Read the host-storage role module closely; it tells you the default access level:
// host-storage roles for orders-sb: three Data Contributor assignments, nothing more
resource host_storage_StorageBlobDataContributor // ba92f5b4-...
resource host_storage_StorageTableDataContributor // 0a9a7e1f-...
resource host_storage_StorageQueueDataContributor // 974c5e8b-...
Three Data Contributor roles. No Storage Account Contributor (that is what .WithHostStorage(...) drops), and no Blob Data Owner. For most function workloads Data Contributor is enough; if your function needs to manage access policies or container ACLs, the default is too weak and you will widen it yourself.
Where the generated happy path stops: wiring Functions triggers
Everything above wired itself because this sample stays inside the supported set. That set is exactly four integrations: Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, and Azure Service Bus. Hand any of those four to a Functions project with WithReference, and Aspire wires the connection, the identity, the role assignment, and the KEDA scaler. The Functions integration has been GA since 13.1, so this is dependable behavior rather than a moving target.
The boundary has sharp edges even on the happy path, and four of them show up in the Bicep you just read.
The connection name is a string you have to match by hand. On the AppHost:
builder.AddAzureFunctionsProject<Projects.OrderProcessor_ServiceBus>("orders-sb")
.WithHostStorage(hostStorage)
.WithReference(messaging, "messaging")
.WithReference(receipts, "receipts")
.WithReference(cache);
On the trigger:
[Function(nameof(ConfirmOrder))]
[BlobOutput("receipts/{OrderId}.json", Connection = "receipts")]
public async Task<Order?> ConfirmOrder(
[ServiceBusTrigger("orders", Connection = "messaging")] OrderMessage message,
CancellationToken cancellationToken)
"messaging" and "receipts" appear in both places, and they have to agree. The environment variable Aspire emits is messaging__fullyQualifiedNamespace; the trigger's Connection = "messaging" is what reads it. Misspell one side and nothing throws at deploy time. The function just never fires, because the runtime looks for a connection named after the trigger and finds none.
Matched correctly, the chain holds up live, not just on paper. A message posted to the deployed orders queue fired ConfirmOrder under the app's user-assigned identity and wrote the receipt blob, with no connection string anywhere in its environment, running on exactly the AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential setup from the generated Bicep:
2026-06-12T08:40:09.84 info: Function.ConfirmOrder.User[0] Confirm order w24-live-001
2026-06-12T08:40:10.92 warn: Azure.Core 404 The specified container does not exist (ContainerNotFound)
2026-06-12T08:40:11.35 info: Function.ConfirmOrder[2] Executed 'Functions.ConfirmOrder' (Succeeded, Id=71d7af99-4755-44a9-9f22-d81017f1f180, Duration=2211ms)
Two details in that log are worth knowing before you reproduce it. The 404 in the middle is not the connection failing: [BlobOutput] logs the miss and then creates the receipts container itself on first write. And the test setup needs data-plane roles you might not expect. The generated namespace and storage accounts disable key-based access (disableLocalAuth, allowSharedKeyAccess: false), so even an Owner on the subscription cannot post a test message or list the output blobs; sending the message took a temporary Azure Service Bus Data Sender assignment, and reading the receipt a Storage Blob Data Reader one. Plan both into however you smoke-test.
External HTTP ingress is off by default. Every app in the generated Bicep, including the HTTP-trigger one, has ingress: { external: false }:
ingress: {
external: false
targetPort: 8080
transport: 'http'
}
The HTTP function is reachable inside the Container Apps environment, not from the internet, because the AppHost never called .WithExternalHttpEndpoints(). If you expected to curl your HTTP trigger after deploy, this is why you cannot. The live deploy spells it out in the hostnames: all four apps come back with *.internal.* FQDNs, such as orders-http.internal.nicesand-421ce770.westeurope.azurecontainerapps.io, and the only external URL in the whole deployment belongs to the Aspire dashboard.
No scale-to-zero by default. Every app has scale: { minReplicas: 1 }. The function apps do not idle down to zero; you pay for at least one replica each, always. KEDA still scales them up from triggers, but the floor stays at one. There is no rules: block in the Bicep either; the platform derives the scaler from kind: 'functionapp'. The deployed apps confirm the floor: all four run with minReplicas: 1 from the moment deploy finishes.
Step outside the four supported integrations and the wiring stops entirely:
-
Anything else needs
WithEnvironmentplus anIsPublishModebranch.WithReferenceexposes config to client integrations but not to triggers and bindings outside the four. For those you write the environment variable yourself, usually with a publish-mode branch that appends the__serviceUrisuffix for identity-based connections, because the local and published shapes differ. -
Existing or connection-string-only resources are not picked up. A resource added with
AddConnectionString(...), or theIsPublishMode ? AddAzureServiceBus(...) : AddConnectionString(...)pattern the Service Bus docs suggest, is not wired into a Functions trigger. This is microsoft/aspire #6465, closednot_planned. If you were hoping to point a trigger at a pre-existing namespace, that is the gap to plan around. - HTTP-trigger access keys are not managed. Aspire does not create or rotate Functions access keys. An HTTP trigger that defaults to requiring a key has no key provisioned; you either set the auth level to anonymous or wire Key Vault secrets yourself.
-
A plain
[BlobTrigger]does not autoscale on ACA. Only the Event Grid-based blob trigger source scales; the polling blob trigger does not. If you need a blob-driven function to scale, switch it to the Event Grid source or accept a fixed replica count.
That list is the real payoff. The four-integration happy path is genuinely good, and this sample rides it end to end. The moment your architecture needs a fifth connection type, an existing resource, a public HTTP endpoint, or scale-to-zero, you are writing the wiring, and knowing that before you deploy saves you a silent failure.
Owning the infrastructure
The instinct when you hit a gap is to open the generated Bicep and edit it. Resist that. Hand-edits to generated Bicep are overwritten the next time the generator runs, on both toolchains. There are two correct ways to own your infrastructure, and editing-then-regenerating is neither.
Customise in C# first. Most of what you would reach into Bicep for has an AppHost API, and a C# change regenerates deterministically every time. The external-ingress and min-replica gaps from the last section both close in the AppHost:
builder.AddAzureFunctionsProject<Projects.OrderProcessor_Http>("orders-http")
.WithHostStorage(hostStorage)
.WithExternalHttpEndpoints() // flips ingress external: true
.PublishAsAzureContainerApp((infra, app) =>
app.Template.Scale.MinReplicas = 0); // allow scale-to-zero
ConfigureInfrastructure reaches the backing resources the same way, for firewall rules, network ACLs, or a private endpoint added with infra.Add(new PrivateEndpoint(...)). The enterprise networking story is largely C#-supported, not Bicep-only: a VNet on a new environment via WithDelegatedSubnet, existing resources via AsExisting (same subscription), a custom Log Analytics workspace via WithAzureLogAnalyticsWorkspace. The gaps that still fall back to hand-authored Bicep are narrow: reconfiguring the VNet or volumes on an existing ACA environment, cross-subscription existing references, and the AcrPull role assignment when you bring your own registry pull identity.
One change is mandatory rather than optional, and the Part 3 sample already has it:
builder.AddAzureContainerAppEnvironment("aca-env");
Aspire 9.4 removed the hybrid mode where azd silently owned the Container Apps environment for you. Without this line, aspire publish and azd have no compute target to publish into. Older tutorials that call PublishAsAzureContainerApp() with no environment declared predate that change and no longer work as written.
A management-group tag policy put the C#-first advice through a live test. The subscription this article deployed to denies any deployment whose resources are missing four tags (cost-center, owner, environment, project) with values from allowed lists, and the first azd provision failed on exactly that. The temptation at that moment is to edit tags into ten generated modules. The fix that holds is one C# class. An InfrastructureResolver participates in Azure.Provisioning's Bicep generation and visits every construct, which means it reaches resources that have no first-class customisation hook of their own, like the per-function identities:
using Azure.Provisioning;
using Azure.Provisioning.Primitives;
internal sealed class AzureTagResolver : InfrastructureResolver
{
private static readonly Dictionary<string, string> RequiredTags = new()
{
["cost-center"] = "cc-1234",
["owner"] = "platform-team",
["environment"] = "dev",
["project"] = "aspire-demo",
};
public override void ResolveProperties(ProvisionableConstruct construct, ProvisioningBuildOptions options)
{
base.ResolveProperties(construct, options);
// Not every construct exposes Tags (role assignments don't), hence the probe.
if (construct is not ProvisionableResource resource ||
resource.GetType().GetProperty("Tags")?.GetValue(resource) is not BicepDictionary<string> tags)
{
return;
}
// Tags bound to a bicep expression reject item assignment; those resources
// get a literal rebind in ConfigureInfrastructure instead (below).
var bicepValue = (IBicepValue)tags;
if (bicepValue.IsOutput || bicepValue.Expression is not null || bicepValue.Kind == BicepValueKind.Expression)
{
return;
}
foreach (var (key, value) in RequiredTags)
{
// ResolveProperties runs until the construct graph stabilises;
// re-assigning an existing entry keeps it dirty and never converges.
if (!tags.ContainsKey(key))
{
tags[key] = value;
}
}
}
}
Registered once on the AppHost builder, it stamps every taggable resource in every module and survives every regeneration:
builder.Services.Configure<AzureProvisioningOptions>(options =>
options.ProvisioningBuildOptions.InfrastructureResolvers.Insert(0, new AzureTagResolver()));
The two guard clauses in the resolver each cost real debugging time. The idempotency one first: ResolveProperties runs repeatedly until the construct graph stops changing, and unconditionally re-assigning a tag keeps the graph dirty, so generation never converges. The error you get for that is misleading: azd infra gen fails with apphost-manifest.json: no such file or directory, because generation never got far enough to write the manifest. The expression guard second: the Container Apps environment module routes its resources' tags through a module-level tags parameter, and assigning into a Tags dictionary bound to an expression throws Cannot assign to Tags, the dictionary is an expression or output only. The resolver skips those resources, and a ConfigureInfrastructure callback rebinds their Tags to a literal instead:
builder.AddAzureContainerAppEnvironment("aca-env")
.ConfigureInfrastructure(infra =>
{
var resources = infra.GetProvisionableResources().ToList();
var taggable = resources.OfType<ContainerAppManagedEnvironment>().Cast<ProvisionableResource>()
.Concat(resources.OfType<OperationalInsightsWorkspace>())
.Concat(resources.OfType<UserAssignedIdentity>());
foreach (var resource in taggable)
{
resource.GetType().GetProperty("Tags")?.SetValue(resource, new BicepDictionary<string>
{
["cost-center"] = "cc-1234",
// ...the same four tags
});
}
});
After those two fixes, every resource the policy evaluates carried the four tags: ten infrastructure resources and four container apps, zero edits to generated files. The detour also mapped where C# ownership ends. The resource group is declared in azd's own main.bicep, which no AppHost API reaches; tag it by hand and the next azd infra gen deletes the edit. (Role assignments carry no tags at all; ARM does not support tags on them.) If your policy evaluates resource groups too, that is a policy exemption conversation, or a reason for the lane below.
Or generate once, then own it. When you have a customization with no C# API, the supported escape hatch is to run the generator a single time and stop:
$ azd infra gen
Generating infrastructure
Analyzing Aspire Application (this might take a moment...)
That writes the infra/ tree into your repo. From there you commit it, manage it by hand, and stop regenerating. The compose-generate docs bless this workflow. The failure mode to avoid is the middle ground: editing the generated Bicep and continuing to run the generator, which wipes your edits on the next pass. Pick one lane: customise in C# and keep regenerating, or generate once and own the files.
GitHub Actions CI/CD
azd pipeline config wires the repository to Azure and drops a workflow into .github/workflows/. It creates an app registration, assigns a role, configures federated identity, and pushes the generated azure-dev.yml. OIDC is the default, which is the detail that matters: there is no client secret stored anywhere. The identity values land as repository variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID), not as the legacy AZURE_CREDENTIALS JSON secret.
The generated workflow is closer to production-ready than its reputation suggests. It does not run azd up. It already runs provision and deploy as separate steps:
permissions:
id-token: write
contents: read
# ...
- name: Install azd
uses: Azure/setup-azd@v2
- name: Install .NET for Aspire
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- name: Log in with Azure (Federated Credentials)
run: azd auth login --client-id "$AZURE_CLIENT_ID" --federated-credential-provider github --tenant-id "$AZURE_TENANT_ID"
- name: Provision
run: azd provision --no-prompt
- name: Deploy
run: azd deploy --no-prompt
What the default does not do is gate or separate the cadence. Both steps run in the same job, on every push, unreviewed. The advisable edit is to split by cadence and approval: move azd provision into its own job gated behind a GitHub Environment with required reviewers (infra changes are rare and want a human), and keep azd deploy as the frequent job on app commits. Each job needs its own id-token: write and its own azd auth login, because OIDC tokens and azd login state do not cross job boundaries. This is a hand edit, not a flag, and it ties straight back to the provision/deploy split from earlier, now enforced by the pipeline.
For configuration, azd stores per-environment values in .azure/<env>/.env and projects them into pipeline variables. Custom variables and secrets go under a pipeline: block in azure.yaml, each matching an environment key, and you rerun azd pipeline config after editing. Key Vault references via azd env set-secret (akvs://...) keep rotation out of the pipeline when stored as variables; secured Bicep parameters with no default get bundled into a single AZD_INITIAL_ENVIRONMENT_CONFIG secret on the provision step.
Closing
The boundary is the whole story. Aspire generates real, readable Bicep that wires identities, roles, and connections for the four integrations it supports, and it stops at a set of edges you can name: connection-name matching, external ingress, scale-to-zero, and everything outside those four integrations. Read the generated infrastructure once and you know which side of that line each piece of your app is on.
That closes Series 3. Part 1 made the AppHost the source of truth, Part 2 declared the Azure services as resources, and Part 3 deployed the graph and found where the generator stops.
Do you let Aspire generate your Azure infrastructure and customise it in C#, or do you generate once and own the Bicep by hand from there?
Top comments (0)