Deploying .NET Apps to Azure Container Apps with One Command, azd up

If you have already containerised your .NET apps and Azure Functions, the next question is how to get them running on Azure Container Apps without hand writing every Bicep file yourself. Azure Developer CLI, known as azd, has evolved a lot since it launched in May 2023. It now generates the Bicep templates for you and lets you provision and deploy an entire solution with one command, azd up.

This walkthrough uses a sample solution with three apps, a Blazor frontend, an ASP.NET Core Web API backend, and an Azure Functions backend, and shows how azd takes all three from your laptop to Azure Container Apps in a single pass.

Before you start

You need a few things installed and working before you follow along. Make sure Docker Desktop is actually running in the background, since azd builds container images locally before pushing them to your Azure Container Registry, and it needs a live Docker daemon to do that.

  • .NET SDK 8.0 or later
  • Visual Studio or Visual Studio Code with the C# Dev Kit extension
  • Azure Developer CLI (azd)
  • Azure Functions Core Tools
  • Docker Desktop, running

The sample solution

The sample repository has three projects working together: a Blazor app as the frontend, an ASP.NET Core Web API app as one backend, and an Azure Functions app as a second backend. The Blazor app calls both backends to render a weather page, one for the weather data and one for a greeting message.

Running the three apps locally first

Before touching Azure at all, confirm the solution works on your machine. Open three terminal windows, one per app, since the Web API, the Functions app and the Blazor frontend all need to be running together for the frontend to actually fetch anything.

# Terminal 1 - ASP.NET Core Web API
dotnet run --project ./ApiApp
 
# Terminal 2 - Azure Functions
cd ./FuncApp
dotnet clean && func start
 
# Terminal 3 - Blazor app
dotnet run --project ./WebApp

Running dotnet clean before func start avoids a stale build artefact from an earlier SDK run interfering with the Functions host, a small but common gotcha. Once all three terminals start without errors, open https://localhost:5001 in your browser to see the Blazor app, then go to https://localhost:5001/weather and confirm it shows weather data from the Web API along with a greeting from the Function app.

The Blazor app running locally, pulling data from both the Web API and the Function app
The Blazor app running locally, pulling data from both the Web API and the Function app

Once this page loads correctly on your machine, you are ready to move to azd. Log in first with azd auth login, since azd needs an authenticated session before it can provision anything in your subscription.

azd init, setting up the project the first time

azd init scans your repository and works out what it is looking at. Run it from the root of the solution.

azd init

You will be asked how to initialise the project. Pick Use code in the current directory so azd scans your folder structure instead of asking you to start from a template.

Choosing Use code in the current directory
Choosing Use code in the current directory

azd correctly detects all three apps and tells you it plans to use Azure Container Apps as the hosting target. Choose Confirm and continue initializing my app to proceed.

azd detecting the three apps and confirming Azure Container Apps as the target
azd detecting the three apps and confirming Azure Container Apps as the target

The Functions app then asks for a target port, and this is where people often pause wondering what value to give it. Enter 80, since Container Apps ingress expects traffic on port 80 internally regardless of what port your Functions host listens on locally.

Entering the target port for the Functions app
Entering the target port for the Functions app

Finally, azd asks for an environment name. This is just a label it uses to keep configurations separate if you later add dev, test or prod environments, so any meaningful name works.

Entering the environment name
Entering the environment name

Once this finishes, azd has created a .azure directory to hold environment specific settings, an infra directory with the actual Bicep templates, a next-steps.md file, and an azure.yaml file that ties your three apps back to their infra definitions.

Directories and files generated by azd init
Directories and files generated by azd init

Open the infra directory and you will find a full set of Bicep modules already written, covering the container app environment, the container registry, each app’s own container app resource, and the supporting managed identity and networking pieces.

Bicep files generated under the infra directory
Bicep files generated under the infra directory

This is where the real time saving shows up. Writing this Bicep by hand for three apps, each needing its own container app resource, managed identity and registry access, easily takes a few hours the first time you do it. That said, treat this generated Bicep as a working starting point rather than a final production template, since you should still review scaling rules, ingress restrictions and secret handling before this goes anywhere near a production subscription.

azd up, provisioning and deployment in one step

With the Bicep in place, deployment comes down to a single command.

azd up

azd asks which subscription and location to provision into. Pick the ones you want and let it run, it provisions every resource defined in the Bicep and then builds and pushes all three container images in the same pass.

Choosing the subscription and location for provisioning
Choosing the subscription and location for provisioning

When the command finishes, it prints out the URLs for your deployed apps.

Deployment completed with the app URLs printed
Deployment completed with the app URLs printed

Click through to the web app URL and go to /weather. Instead of the working page you saw locally, you get an error.

Error on the web app after the first deployment
Error on the web app after the first deployment

This happens because the three apps in Azure Container Apps do not know each other’s addresses yet. Locally they were all on localhost with fixed ports, but in Azure each app gets its own generated URL, and nothing in the generated Bicep passes those URLs between the apps. You need to wire that up yourself.

Fixing service discovery in the generated Bicep

Open infra/main.bicep and find the module block for the web app. Add two new parameters that pass in the API and Function app URIs.

module webApp './app/WebApp.bicep' = {
  name: 'WebApp'
  params: {
    ...
    // Add these two lines
    apiAppEndpoint: apiApp.outputs.uri
    funcAppEndpoint: funcApp.outputs.uri
  }
  scope: rg
}

apiApp.outputs.uri and funcApp.outputs.uri are already exposed by the Bicep modules azd generated for those two apps. You are just reading values that already exist and forwarding them into the web app’s module.

Now open infra/app/WebApp.bicep, the module you just referenced, and declare the two parameters you are passing in.

...
@secure()
param appDefinition object
 
// Add these two lines
param apiAppEndpoint string
param funcAppEndpoint string
...

Bicep fails to compile if you pass a parameter into a module that has not declared it, so this step is not optional. With both parameters declared, the last change is to turn them into environment variables the Blazor app can actually read at runtime.

// Before
var env = map(filter(appSettingsArray, i => i.?secret == null), i => {
  name: i.name
  value: i.value
})
 
// After
var env = union(map(filter(appSettingsArray, i => i.?secret == null), i => {
  name: i.name
  value: i.value
}), [
  {
    name: 'API_ENDPOINT_URL'
    value: apiAppEndpoint
  }
  {
    name: 'FUNC_ENDPOINT_URL'
    value: funcAppEndpoint
  }
])

The union function here combines the existing filtered environment variable array with two new entries, API_ENDPOINT_URL and FUNC_ENDPOINT_URL. The Blazor app reads these two environment variables at startup to build the HTTP client base addresses it uses to call the Web API and the Function app.

Save both Bicep files and run azd up again. It picks up the Bicep changes and updates the running container apps without a full teardown, so it is quicker than the first deployment. Go back to the web app URL and open /weather once more, this time it shows both the weather data and the greeting correctly.

The web app working correctly in Azure Container Apps after the Bicep fix
The web app working correctly in Azure Container Apps after the Bicep fix

Where this approach fits and where it does not

This one command flow is genuinely useful for demos, proof of concepts and small internal tools where you want something running on Azure in a few minutes. The trade off is that the generated Bicep only handles the basics, and anything involving multiple apps that need to talk to each other still needs manual wiring, as you just saw with service discovery.

If you are building something with more than two or three services that need to discover each other, or if this is heading toward production, look at .NET Aspire instead. Aspire handles service discovery between projects automatically at the application level, so you do not end up manually passing URIs through Bicep parameters every time you add a new service reference.

Wrapping up

azd up takes care of the repetitive parts of getting .NET apps and Azure Functions onto Azure Container Apps, and the generated Bicep is a solid starting point for a new solution. The one thing it does not do for you is wire up service discovery between multiple apps in the same solution, and that small manual step is worth understanding rather than treating as boilerplate to copy without thinking about it.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading