← all posts

Azure

How I Containerised My API and Deployed It to Azure

A multi-stage Dockerfile, Docker Compose with a health-checked SQL Server, and the Sports Betting API running on Azure Container Apps with Azure SQL behind it.

How I Containerised My API and Deployed It to Azure

The Sports Betting API is the first project I took all the way from running on my machine to actually being reachable on the internet. Docker for the packaging, Azure Container Apps for the hosting, Azure SQL Database behind it.

The Dockerfile is two stages, not one

The naive version of a .NET Dockerfile uses the SDK image and stops there. That works, and it ships an image with the entire .NET SDK inside it, which is far larger than what's needed to just run the thing.

So the build happens in one stage and the result gets copied into a smaller one:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# ... restore and publish ...
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "SportsBetting.API.dll"]

The final image is based on the ASP.NET runtime and contains only the published output. The SDK stage does its job and gets thrown away.

Copying the project files before the source

This is the detail that isn't obvious and that I got wrong the first time. Every .csproj gets copied first, then dotnet restore runs, and only then does the rest of the source get copied in:

COPY SportsBetting.sln ./
COPY src/Backend/SportsBetting.API/SportsBetting.API.csproj src/Backend/SportsBetting.API/
COPY src/Backend/SportsBetting.Application/SportsBetting.Application.csproj src/Backend/SportsBetting.Application/
# ... the rest of the projects ...

RUN dotnet restore
COPY . .

Docker caches each layer and reuses it if nothing that layer depends on has changed. Because the restore only depends on the project files, editing a C# file doesn't invalidate it, and the packages don't get downloaded again. Copying everything up front and then restoring means every single source change re-downloads every package.

It's a small reordering that turns most rebuilds from slow into nearly instant.

Compose, so the database starts with the app

Locally the API needs SQL Server, and starting them separately in the right order gets old fast. Compose handles both, and the useful part is making the API wait until the database is genuinely ready rather than just started:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    healthcheck:
      test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "..." -Q "SELECT 1" -C
      interval: 10s
      retries: 5
      start_period: 30s

  api:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      sqlserver:
        condition: service_healthy

depends_on on its own only waits for the container to start, and a SQL Server container accepts connections a good while after that. Pairing it with a health check that actually runs a query is what makes the difference between the API starting cleanly and it crashing on a connection it made too early.

Azure Container Apps

For the deployment I went with Azure Container Apps rather than a VM or App Service. It runs a container image directly, which is exactly what I already had, and it scales to zero when nothing is hitting it.

Scaling to zero has a visible consequence: the first request after an idle period takes a few seconds while a container spins up. For a study project that's a fine trade, since it costs nothing while it sits idle. For something with real users I'd keep a minimum of one replica warm instead.

The database is Azure SQL Database, the managed option, so I'm not maintaining a SQL Server instance myself. Configuration and the connection string come from environment variables in the Container App, the same mechanism Compose uses locally, which means the image itself is identical in both places.

What actually made this click

The thing I understood only after doing it: the container is the deployable unit, and the environment is just configuration around it. The same image I build and run locally is the one running in Azure, with different environment variables pointed at different infrastructure. No separate build for production, no code that behaves differently depending on where it's running.

That's the part that made the whole Docker idea land for me. Not "it packages the app," but that the thing I tested locally is literally the thing running in the cloud.

What I'd do next

A GitHub Actions workflow that builds the image and deploys it on a push to main, so deploying isn't something I do by hand. That's the obvious gap between this and how it would work on a real team.