Azure Container Apps with Multiple Containers

Azure Container Apps with Multiple Containers

In this article, we will look at a real-world transformation of an application where a .NET Web API and Node.js initially ran inside a single Docker container and were later split into two containers within the same Azure Container App.

We will cover the following topics:

  • the initial single-container architecture;
  • the application transformation;
  • infrastructure changes;
  • communication between containers;
  • the final result and changes in resource consumption.

Initial Architecture: One Container

Initially, at the infrastructure level, the application consisted of a single Docker container running two services at the same time:

  • .NET Web API;
  • a Node.js application responsible for Angular Server-Side Rendering.

The goal of this solution was simple: get the application running in a production environment as quickly as possible.

That goal was successfully achieved. However, once the application became stable, it was time to address the technical debt and separate the containers by responsibility.

Since the application required two processes at the same time — .NET and Node.js — a separate shell script was used to start them. This script acted as the entry point of the Docker container and launched both processes.

This approach is perfectly workable, especially during the early stages of a project. Over time, however, it becomes clear that a single container is responsible for several independent processes, while its configuration gradually becomes more complicated.

Application Transformation

Once the decision was made to split the application, it became clear that the common startup script was no longer required. Each container could now start the single process for which it was responsible.

Everything related to the Frontend and Angular SSR was moved into a separate Dockerfile. The Backend container, in turn, became responsible exclusively for the Web API.

As a result, the responsibilities became much clearer:

  • Backend container — .NET Web API;
  • Frontend container — Angular SSR running on Node.js.

The Web API Dockerfile now looks like this:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build

WORKDIR /src

COPY serdg-api/ ./

RUN dotnet restore "SD/SD.csproj"

RUN dotnet publish "SD/SD.csproj" \
    --configuration Release \
    --output /out/api \
    --no-restore \
    /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime

WORKDIR /app

COPY --from=build /out/api ./

EXPOSE 8080

ENTRYPOINT ["dotnet", "SD.dll"]

The Frontend received its own Dockerfile. Its primary purpose is to run Angular Server-Side Rendering:

FROM node:22-bookworm-slim AS build

WORKDIR /src/ui

COPY serdg-ui/package*.json ./

RUN npm ci --legacy-peer-deps

COPY serdg-ui/ ./

RUN npm run build
RUN npm prune --omit=dev --legacy-peer-deps

FROM node:22-bookworm-slim AS runtime

WORKDIR /app/ui

ENV NODE_ENV=production
ENV PORT=4000
ENV HOST=0.0.0.0
ENV API_BASE_URL=http://127.0.0.1:8080

COPY --from=build /src/ui/dist ./dist
COPY --from=build /src/ui/node_modules ./node_modules
COPY --from=build /src/ui/package*.json ./

EXPOSE 4000

ENTRYPOINT ["node", "dist/serdg-ui/server/server.mjs"]

After these changes, the next step is to make sure that both Docker images are built correctly and successfully pushed to Azure Container Registry for further deployment.

Infrastructure Changes

Initially, I expected that splitting the Dockerfiles would represent most of the work and that the application would start working in its new configuration almost immediately.

But once again, the well-known rule proved to be true:

The first 80% of a task is not nearly as difficult as the second 80%.

While modifying the Dockerfiles took only a few hours, configuring the deployment and infrastructure required several more.

Several tasks had to be solved:

  • add a second container to Azure Container App and configure the required settings for each container;
  • update the Backend and Frontend so that each component handled only its own area of responsibility;
  • update incoming HTTP traffic routing;
  • configure communication between the Frontend and Backend containers.

One of the particularly convenient features of Azure Container Apps is the ability to run multiple containers within a single Container App.

This approach works well for components that are tightly coupled and are expected to run together.

At the same time, each container has its own configuration:

  • Docker image;
  • environment variables;
  • CPU settings;
  • memory allocation;
  • startup command.

At the same time, the containers share a common lifecycle. They belong to the same Azure Container App revision, start together, and scale as a single application.

For the current project, this is completely acceptable: the Frontend has very little value without the Backend, so independent scaling of these two components is not currently required.

There is another important advantage: containers inside the same Container App share the same network environment and can communicate with each other through localhost.

This is why the Frontend container can access the API using:

ENV API_BASE_URL=http://127.0.0.1:8080

The Backend does not need to be exposed externally.

Changing the Backend Responsibilities

After splitting the containers, the responsibilities of the applications themselves also changed.

Previously, the .NET application not only provided the API but also participated in serving the Frontend.

For example, the following fallback was used:

app.MapFallbackToFile("index.html");

Once the Frontend moved into its own container, this logic was no longer required.

The Backend now becomes exactly what it is supposed to be: a Backend. Its responsibilities are providing the API, working with data, serving backend resources, and handling the rest of the server-side logic.

This is probably one of the main benefits of the refactoring: each component is now responsible only for its own area.

Frontend as the Single Entry Point

After the containers were separated, all external HTTP requests started arriving at the Node.js application first.

The Frontend container is now the entry point for Azure Container Apps ingress.

Regular user requests are handled by Angular SSR. Requests to the API and certain server-side resources, however, must be forwarded to the second container.

This required a small update to server.ts.

The list of routes that should be proxied to the Backend looks like this:

const proxiedRequestPaths = [
  '/api',
  '/documents',
  '/images',
  '/mnt',
];

/**
 * Proxy API-related requests to the API container.
 */
for (const requestPath of proxiedRequestPaths) {
  app.use(requestPath, proxyToApi);
}

The resulting request flow now looks approximately like this:

Internet
   │
   ▼
Azure Container Apps Ingress
   │
   ▼
Node.js / Angular SSR :4000
   │
   ├── Angular pages
   │
   └── /api, /documents, /images, /mnt
           │
           ▼
      .NET Web API :8080

As a result, external ingress is required only for the Frontend container.

The Backend container is available to the Frontend through the internal network of the Azure Container App and is not exposed directly to the internet.

Result

After the refactoring, the architecture became noticeably cleaner.

Instead of one container running two independent processes, there are now two containers with clearly defined responsibilities:

Azure Container App
│
├── Frontend container
│   └── Node.js + Angular SSR :4000
│
└── Backend container
    └── .NET Web API :8080

This made it possible to remove the auxiliary script that started both processes, simplify the Dockerfiles, and make the overall application architecture much clearer.

At the same time, both containers still remain part of the same Azure Container App and can communicate closely with each other, which fits the requirements of the current project well.

In addition to the architectural improvements, the refactoring also produced a practical benefit: memory and CPU consumption decreased noticeably. The difference in resource usage can be clearly seen in the screenshot below.

Azure Container App CPU and memory usage before and after splitting the application into multiple containers
CPU and memory usage after moving from a single container to a multi-container architecture.

Perhaps just as importantly, the architecture now reflects the actual responsibilities of the individual components much better.

And, of course, there is one more benefit that comes with any successfully completed refactoring: the satisfaction of knowing that there is now a little less technical debt in the project.