Appearance
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 → DeployStep 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.csproj1.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.csproj1.3 Layer responsibilities
| Layer | Responsibility |
|---|---|
Domain | Entities, value objects, domain events, domain exceptions |
Application | CQRS commands/queries (MediatR), DTOs, validator interfaces, service interfaces |
Infrastructure | EF 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 followGetById{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 integrationMicrotec.Domain— base entity, audit fields, domain eventsMicrotec.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.csprojStep 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.NoworDateTime.UtcNowdirectly. InjectIClockServiceinstead:csharppublic 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)
| CPU | Memory |
|---|---|
| 0.25 | 0.5Gi |
| 0.5 | 1Gi |
| 1.0 | 2Gi |
| 2.0 | 4Gi |
| 4.0 | 8Gi |
[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
IMyServicePublicApityped 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
- Merge to
main(triggers dev deployment automatically) - Monitor: Azure DevOps → Pipelines →
deploy-my-service - 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()andUseSharedWeb()called inProgram.cs - [ ]
IClockServiceused instead ofDateTime.Now - [ ] Dockerfile uses
--mount=type=secret,id=nuget_pat - [ ] Entry added to
services-config.jsonwith correct CPU/memory pair - [ ] Gateway route added (if needed)
- [ ] Azure DevOps pipeline YAML created
- [ ] Deployed and health-checked on
dev - [ ]
services-config.jsonchange reviewed by a second engineer
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
dotnet restore fails in Docker | NuGet PAT expired | Regenerate PAT, update pipeline variable NUGET_PAT |
Container stuck in Activating | Misconfigured secret reference | Check KV secret name vs secretRef in config |
InvalidCpuMemoryCombination | Bad CPU/memory pair | Use valid pair from table in Step 5 |
| 502 from Gateway | Route not added or service not running | Verify ocelot config and container status |
401 Unauthorized | Auth middleware not configured | Ensure AddSharedWeb() is called |
Related Runbooks
- Scale a Service — configure autoscaling after initial deployment
- Key Rotation — rotate the connection string secret
- Incident Response — if the new service causes an outage