Shipping It
Ship the orders API as a non-root container to Kubernetes through a CI pipeline, with migrations, graceful shutdown and a rollout checklist.
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer -p:ContainerRepository=orders-api \
-p:ContainerFamily=noble-chiseled -p:ContainerImageTags=$GITHUB_SHA
kubectl rollout status deployment/orders-api --timeout=5mShipping the project means four artefacts that change together: a container image, Kubernetes manifests, a migration bundle and a pipeline that builds, tests and releases them in the right order. The goal is that a release is boring: nobody notices it, no request fails, and rolling back is one command.
The image
A multi-stage Dockerfile restores in the mcr.microsoft.com/dotnet/sdk:10.0 image, copying the project files first so the restore layer is cached until a package changes, then publishes into mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled. Chiseled images have no shell and no package manager, so there is far less to patch and to exploit. The API needs no ICU because it formats nothing by culture; an app that does uses -noble-chiseled-extra. Since .NET 8 the images listen on 8080 (ASPNETCORE_HTTP_PORTS) and include the non-root user app, UID 1654. dotnet publish /t:PublishContainer builds an equivalent image without a Dockerfile; choose it when you have no extra files or OS packages to add.
The manifests
A Deployment with at least three replicas, maxUnavailable: 0 so capacity never drops during a rollout, CPU and memory requests (the .NET GC sizes its heaps from the container's memory limit), startup, liveness and readiness probes on the two health endpoints from the observability lesson, and configuration from a ConfigMap plus a Secret for the connection string. A PodDisruptionBudget keeps node drains from taking all replicas at once.
Graceful shutdown
When a pod is deleted, Kubernetes removes it from the Service's endpoints and sends SIGTERM at roughly the same time, and the endpoint removal takes a moment to reach every proxy. An app that stops accepting connections instantly drops requests still being routed to it. The fix is a preStop sleep of a few seconds before SIGTERM; chiseled images have no sleep binary, so use the native sleep lifecycle action rather than an exec command. After SIGTERM, ASP.NET Core stops accepting new connections, lets in-flight requests finish, then stops hosted services, all within HostOptions.ShutdownTimeout (30 seconds by default). Keep terminationGracePeriodSeconds above preStop plus that timeout, or the kubelet kills the process mid-order.
The pipeline
On a pull request: restore, build with warnings as errors, unit and integration tests (Testcontainers needs Docker on the runner), NuGet audit. On main: the same plus the migration bundle, then a release job that waits for approval on the production environment, pushes an image tagged with the commit SHA (never only latest), runs the bundle and rolls out. The release job runs on a self-hosted runner inside the cluster's network, because the production database must never be opened to hosted runners on the internet. A staging environment is the same job pointed at another environment, run first.
Rollout checklist
| Check | Why |
|---|---|
| Migration is backwards compatible with the running image | Old pods keep serving while the new ones start |
| Image referenced by digest, or by a SHA tag in a registry with immutable tags | A re-pushed tag cannot change what runs |
Readiness passes before traffic; maxUnavailable: 0 | No capacity dip and no cold pods in rotation |
preStop sleep and grace period above ShutdownTimeout | No dropped in-flight orders |
Dashboards: 5xx rate, p99 latency, orders.placed | Decide in minutes whether to roll back |
kubectl rollout undo tested | Rollback must not need a new build |
Example
When to use it
- Let an order placement that started 200 ms before SIGTERM commit and return 201 while the pod shuts down during a rolling update.
- Release orders-api by commit SHA so `kubectl rollout undo` returns to the exact previous image without rebuilding anything.
- Run the migration bundle once from the pipeline before the rollout, instead of letting three replicas race to migrate at startup.
More examples
Dockerfile: restore cache, chiseled runtime, non-root
<code>dotnet publish</code> builds Release by default, so no <code>-c</code> flag is needed. The restore layer only rebuilds when a <code>.csproj</code> or the central package file changes, which takes most CI builds from minutes to seconds. <code>USER $APP_UID</code> runs as UID 1654; Kubernetes can then enforce <code>runAsNonRoot</code>, which needs a numeric user to verify. There is no <code>HEALTHCHECK</code>: Kubernetes ignores it and uses its own probes.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Project files first: this layer is reused until a package reference changes.
COPY Directory.Packages.props Directory.Build.props global.json ./
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/
RUN dotnet restore src/Orders.Api/Orders.Api.csproj
COPY src/ src/
RUN dotnet publish src/Orders.Api/Orders.Api.csproj --no-restore -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Orders.Api.dll"]Deployment, Service and disruption budget
The grace period of 40 seconds covers the 5-second preStop sleep plus the 25-second <code>ShutdownTimeout</code> with room to spare. There is a memory limit but no CPU limit: CPU throttling causes latency spikes, while the memory limit is what the GC uses to size its heaps. The startup probe gives a cold pod up to 60 seconds before liveness takes over, and the connection string arrives from a Secret through the <code>__</code> environment variable convention. Each probe sends <code>Host: orders-api</code> because the kubelet would otherwise send the pod IP, which the production <code>AllowedHosts</code> list rejects with 400.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels: { app: orders-api }
template:
metadata:
labels: { app: orders-api }
spec:
terminationGracePeriodSeconds: 40
securityContext: { runAsNonRoot: true }
containers:
- name: api
image: registry.example.com/shop/orders-api:3f9c2d1
ports: [{ containerPort: 8080 }]
env:
- { name: ASPNETCORE_ENVIRONMENT, value: Production }
- { name: OTEL_EXPORTER_OTLP_ENDPOINT, value: "http://otel-collector.observability:4317" }
- name: ConnectionStrings__Orders
valueFrom: { secretKeyRef: { name: orders-db, key: connection-string } }
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 512Mi }
startupProbe:
httpGet: { path: /health/live, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /health/live, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
periodSeconds: 10
readinessProbe:
httpGet: { path: /health/ready, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
periodSeconds: 5
timeoutSeconds: 3
lifecycle:
preStop:
sleep: { seconds: 5 }
---
apiVersion: v1
kind: Service
metadata:
name: orders-api
spec:
selector: { app: orders-api }
ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: orders-api
spec:
minAvailable: 2
selector:
matchLabels: { app: orders-api }GitHub Actions: test, image, migrate, deploy
Pull requests run only the build job; pushes to main also release. The build job follows Build and Test in CI (locked restore, every step in Release, tests on the build that ships), and the publish passes <code>--os linux --arch x64</code> as Publishing a Container Without a Dockerfile requires, so the image never takes the runner's CPU architecture. The <code>production</code> environment can require a reviewer before the release job starts. Migrations run after the image is pushed and before the rollout, so old pods meet the new schema first, which is why every migration must be backwards compatible. A rollout that does not become ready within five minutes is undone automatically and the job fails. The release job runs on a self-hosted runner inside the cluster's network, so the bundle reaches the database without the database being exposed to GitHub-hosted runners. Cluster credentials for <code>kubectl</code> come from the environment's secrets or OIDC, never from the repository.
name: orders-api
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- run: dotnet restore --locked-mode
- run: dotnet build -c Release --no-restore -warnaserror
- run: dotnet test -c Release --no-build
- name: Build migration bundle
run: |
dotnet tool restore
dotnet ef migrations bundle --project src/Orders.Api --runtime linux-x64 --self-contained -o efbundle --force
- uses: actions/upload-artifact@v4
with: { name: efbundle, path: efbundle }
release:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: [self-hosted, linux, prod-network]
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- uses: actions/download-artifact@v4
with: { name: efbundle }
- run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u ci --password-stdin
- run: >
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer
-p:ContainerRegistry=registry.example.com -p:ContainerRepository=shop/orders-api
-p:ContainerFamily=noble-chiseled -p:ContainerImageTags=${GITHUB_SHA::7}
- run: chmod +x efbundle && ./efbundle --connection "${{ secrets.ORDERS_DB_MIGRATOR }}"
- run: |
kubectl set image deployment/orders-api api=registry.example.com/shop/orders-api:${GITHUB_SHA::7}
kubectl rollout status deployment/orders-api --timeout=5m || (kubectl rollout undo deployment/orders-api && exit 1)
Discussion