Skip to content

Runbook: Deploy a New Microservice

This runbook walks through every step required to add a new microservice to the Microtec ERP platform — from creating the .NET project to a live deployment on Azure Container Apps.

Estimated time: 2–4 hours for first deployment to dev
Audience: Backend engineers, DevOps
Prerequisites: NuGet PAT configured, Azure DevOps access, Docker Desktop running


Overview

.NET Project → Dockerfile → services-config.json → Gateway routes → ADO Pipeline → Deploy

Step 1 — Create the .NET 8 Project (Clean Architecture)

1.1 Scaffold the solution structure

bash
cd Platforms/Src/AppsPortal   # or InfrastructureServices/ for infra services

# Create solution
dotnet new sln -n MyService

# Create projects following the 4-layer Clean Architecture convention
dotnet new classlib -n MyService.Domain        -f net8.0
dotnet new classlib -n MyService.Application   -f net8.0
dotnet new classlib -n MyService.Infrastructure -f net8.0
dotnet new webapi   -n MyService.Apis          -f net8.0

# Add to solution
dotnet sln add MyService.Domain/MyService.Domain.csproj
dotnet sln add MyService.Application/MyService.Application.csproj
dotnet sln add MyService.Infrastructure/MyService.Infrastructure.csproj
dotnet sln add MyService.Apis/MyService.Apis.csproj

1.2 Add project references

bash
dotnet add MyService.Application/MyService.Application.csproj reference \
    MyService.Domain/MyService.Domain.csproj

dotnet add MyService.Infrastructure/MyService.Infrastructure.csproj reference \
    MyService.Application/MyService.Application.csproj

dotnet add MyService.Apis/MyService.Apis.csproj reference \
    MyService.Infrastructure/MyService.Infrastructure.csproj

1.3 Layer responsibilities

LayerResponsibility
DomainEntities, value objects, domain events, domain exceptions
ApplicationCQRS commands/queries (MediatR), DTOs, validator interfaces, service interfaces
InfrastructureEF Core DbContext, repository implementations, external HTTP clients
Apis (Presentation)Controllers, middleware, DI registration, Program.cs

Naming convention: Command handlers follow Add{Entity}Command, Edit{Entity}Command, Delete{Entity}Command. Query handlers follow GetById{Entity}Query, GetAll{Entity}Query, GetDropdown{Entity}Query, Export{Entity}Query.


Step 2 — Add Microtec NuGet Packages

[ACTION] Configure the Azure Artifacts feed before this step. See Platforms/Solution Items/NUGET-SETUP.md.

Minimum — Web hosting bootstrap

xml
<!-- MyService.Apis/MyService.Apis.csproj -->
<PackageReference Include="Microtec.Web.Hosting" Version="*" />

Microtec.Web.Hosting pulls in:

  • Microtec.Web.Core — auth middleware, tenant resolution, Keycloak integration
  • Microtec.Domain — base entity, audit fields, domain events
  • Microtec.Persistence — EF Core base DbContext, generic repository, unit of work
  • Serilog + OpenTelemetry sinks pre-configured

Optional packages

xml
<!-- For CQRS-only shared library projects -->
<PackageReference Include="Microtec.Contracts" Version="*" />

<!-- For reporting (PDF/Excel) -->
<PackageReference Include="Microtec.Reporting" Version="*" />

<!-- For consuming other service APIs -->
<PackageReference Include="Microtec.PublicApi.AppsPortal" Version="*" />

Restore

bash
cd Platforms
dotnet restore MyService.Apis/MyService.Apis.csproj

Step 3 — Implement Program.cs

csharp
// MyService.Apis/Program.cs
using Microtec.Web.Hosting;

var builder = WebApplication.CreateBuilder(args);

// [ACTION] Always call AddSharedWeb() — registers auth, tenant, health checks,
// Serilog, OpenTelemetry, CORS, and Swagger in one call.
builder.AddSharedWeb();

// Register your own services
builder.Services.AddApplication();       // MediatR + validators
builder.Services.AddInfrastructure(builder.Configuration);  // EF Core + repos

var app = builder.Build();

// [ACTION] Always call UseSharedWeb() — middleware pipeline in correct order
app.UseSharedWeb();

app.MapControllers();
app.Run();

Key service registrations

Use marker interfaces so AddSharedWeb() auto-discovers them via assembly scanning:

csharp
// Scoped service (per-request)
public class MyService : IMyService, IScopedService { }

// Transient service
public class MyFactory : IMyFactory, ITransientService { }

// Singleton service
public class MyCache : IMyCache, ISingletonService { }

[WARNING] Never use DateTime.Now or DateTime.UtcNow directly. Inject IClockService instead:

csharp
public class MyHandler(IClockService clock) { 
    var now = clock.Now;
}

Step 4 — Create the Dockerfile

dockerfile
# MyService.Apis/Dockerfile
# Stage 1: Restore (uses NuGet PAT secret)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src

# Copy solution and project files first for layer caching
COPY ["MyService.Apis/MyService.Apis.csproj",          "MyService.Apis/"]
COPY ["MyService.Application/MyService.Application.csproj", "MyService.Application/"]
COPY ["MyService.Infrastructure/MyService.Infrastructure.csproj", "MyService.Infrastructure/"]
COPY ["MyService.Domain/MyService.Domain.csproj",       "MyService.Domain/"]

# [ACTION] --mount=type=secret is REQUIRED for private NuGet feed authentication.
# The PAT is injected at build time by the ADO pipeline; it never lands in the image.
RUN --mount=type=secret,id=nuget_pat \
    NUGET_PAT=$(cat /run/secrets/nuget_pat) \
    dotnet restore "MyService.Apis/MyService.Apis.csproj" \
    -s "https://pkgs.dev.azure.com/microtec/_packaging/Microtec/nuget/v3/index.json" \
    --configfile /dev/null \
    /p:NuGetFeedUsername=any \
    /p:NuGetFeedPassword=${NUGET_PAT}

# Stage 2: Build
FROM restore AS build
COPY . .
RUN dotnet build "MyService.Apis/MyService.Apis.csproj" -c Release --no-restore

# Stage 3: Publish
FROM build AS publish
RUN dotnet publish "MyService.Apis/MyService.Apis.csproj" \
    -c Release --no-build -o /app/publish

# Stage 4: Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
EXPOSE 8080
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyService.Apis.dll"]

[VERIFY] Run docker build --secret id=nuget_pat,env=NUGET_PAT . locally before pushing.


Step 5 — Register in services-config.json

File: Devops/azure/config/container-backend/services-config.json

jsonc
{
  "services": [
    // ... existing services ...
    {
      "name": "my-service",           // Used as container app name suffix
      "image": "my-service-apis",     // ACR image name (without tag)
      "cpu": 0.5,                     // vCPU cores (0.25 / 0.5 / 1.0 / 2.0)
      "memory": "1Gi",                // Memory (must match valid ACA SKU pairs)
      "networkProfile": "private",    // "public" = Public CAE, "private" = Private CAE
      "port": 8080,
      "minReplicas": 1,
      "maxReplicas": 5,
      "env": [
        {
          "name": "ConnectionStrings__DefaultConnection",
          "secretRef": "defaultconnection"   // References KV secret name
        },
        {
          "name": "ASPNETCORE_ENVIRONMENT",
          "value": "Production"
        }
      ],
      "secrets": [
        {
          "name": "defaultconnection",
          "keyVaultUrl": "https://{kvName}.vault.azure.net/secrets/DefaultConnection"
        }
      ]
    }
  ]
}

Valid CPU/Memory combinations (Azure Container Apps)

CPUMemory
0.250.5Gi
0.51Gi
1.02Gi
2.04Gi
4.08Gi

[WARNING] Mismatched CPU/memory pairs cause deployment failure with an unhelpful error. Always verify against the table above.


Step 6 — Add Gateway Routes (if externally accessible)

File: Platforms/Src/Gateway/Gateway.Apis/ocelot.{env}.json

jsonc
{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/{everything}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "mic-erp-be-{env}-my-service",   // CAE internal FQDN
          "Port": 8080
        }
      ],
      "UpstreamPathTemplate": "/my-service/{everything}",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE", "PATCH" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    }
  ]
}

Internal services that are only called service-to-service do not need Gateway routes. Use the internal FQDN directly via IMyServicePublicApi typed HTTP client.


Step 7 — Create the Azure DevOps Pipeline

Create Devops/azure/pipelines/my-service/deploy-my-service.yml:

yaml
trigger:
  branches:
    include:
      - main
      - stage
      - PreProd
      - production
  paths:
    include:
      - Platforms/Src/AppsPortal/MyService/**

parameters:
  - name: environment
    type: string
    default: dev
    values: [dev, stage, preprod, uat, production]
  - name: imageTag
    type: string
    default: $(Build.BuildId)

variables:
  - template: ../../config/container-backend/variables.yml
    parameters:
      environment: ${{ parameters.environment }}

stages:
  - stage: Build
    jobs:
      - template: ../../templates/containerBackend/build/build-docker.yml
        parameters:
          serviceName: my-service
          dockerfilePath: Platforms/Src/AppsPortal/MyService/MyService.Apis/Dockerfile
          imageTag: ${{ parameters.imageTag }}

  - stage: Deploy
    dependsOn: Build
    jobs:
      - template: ../../templates/containerBackend/deploy/deploy-container.yml
        parameters:
          serviceName: my-service
          imageTag: ${{ parameters.imageTag }}
          environment: ${{ parameters.environment }}

Step 8 — Test the Deployment

8.1 Fast deploy to dev (skip build, use latest image)

bash
# [ACTION] Use for rapid iteration during development
az containerapp update \
  --name "mic-erp-be-dev-my-service" \
  --resource-group "mic-erp-be-dev-containers-rg" \
  --image "micerpbedevacr.azurecr.io/my-service-apis:latest"

8.2 Full deploy via pipeline

  1. Merge to main (triggers dev deployment automatically)
  2. Monitor: Azure DevOps → Pipelines → deploy-my-service
  3. Check logs: az containerapp logs show --name mic-erp-be-dev-my-service --resource-group mic-erp-be-dev-containers-rg --follow

8.3 Verify health

bash
# [VERIFY] Container app is running
az containerapp show \
  --name "mic-erp-be-dev-my-service" \
  --resource-group "mic-erp-be-dev-containers-rg" \
  --query "properties.runningStatus" -o tsv
# Expected output: Running

# [VERIFY] Health endpoint responds
curl -s https://mic-erp-be-dev-gateway.{domain}/my-service/health | jq .
# Expected: {"status":"Healthy"}

8.4 Full deploy to stage

After dev is verified, merge to stage branch or manually trigger the pipeline with environment=stage.


Checklist

  • [ ] .NET project created with 4-layer Clean Architecture
  • [ ] Microtec.Web.Hosting NuGet package added
  • [ ] AddSharedWeb() and UseSharedWeb() called in Program.cs
  • [ ] IClockService used instead of DateTime.Now
  • [ ] Dockerfile uses --mount=type=secret,id=nuget_pat
  • [ ] Entry added to services-config.json with correct CPU/memory pair
  • [ ] Gateway route added (if needed)
  • [ ] Azure DevOps pipeline YAML created
  • [ ] Deployed and health-checked on dev
  • [ ] services-config.json change reviewed by a second engineer

Troubleshooting

SymptomLikely CauseFix
dotnet restore fails in DockerNuGet PAT expiredRegenerate PAT, update pipeline variable NUGET_PAT
Container stuck in ActivatingMisconfigured secret referenceCheck KV secret name vs secretRef in config
InvalidCpuMemoryCombinationBad CPU/memory pairUse valid pair from table in Step 5
502 from GatewayRoute not added or service not runningVerify ocelot config and container status
401 UnauthorizedAuth middleware not configuredEnsure AddSharedWeb() is called

Internal Documentation — Microtec Platform Team