Windows Containers in Kubernetes

Kubernetes supports Windows Server container workloads through Windows worker nodes. A production Kubernetes cluster running Windows containers has a mixed-OS architecture: the control plane (API server, etcd, scheduler, controller manager) runs on Linux nodes, while Windows-specific workloads are scheduled onto Windows Server 2022 worker nodes. This article walks through building a Windows container image, pushing it to a registry, writing the Kubernetes manifests, and deploying to a mixed cluster.

Before proceeding, ensure you have a functional Kubernetes cluster with at least one Windows Server 2022 worker node already joined. Node setup involves installing the Windows node prerequisites (containerd, kubelet, kube-proxy, wins), joining the cluster, and applying the appropriate CNI plugin. Flannel with host-gateway mode and Calico are the two most commonly used CNI plugins for mixed Windows/Linux clusters.

Building a Windows Container Image

Windows container images must be built on a Windows host. You cannot build a Windows container image on a Linux host. The build host should run Windows Server 2022 with Docker or containerd installed, and the base image must match a compatible Windows Server version.

Create a Dockerfile for a simple ASP.NET application:

# Dockerfile for a Windows container ASP.NET app
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS build
WORKDIR /src
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"
COPY . .
WORKDIR "/src/MyApp"
RUN dotnet build "MyApp.csproj" -c Release -o /app/build

# Stage 2: Publish
FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish

# Stage 3: Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-nanoserver-ltsc2022 AS final
WORKDIR /app
EXPOSE 80
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Build the image on the Windows Server 2022 build host:

cd C:ProjectsMyApp
docker build -t registry.internal.example.com/myteam/myapp:1.0 .

Important: Windows container images come in two variants — Windows Server Core (windowsservercore) and Nano Server (nanoserver). Nano Server is smaller and has a lower attack surface, but it lacks many Win32 APIs that some applications require. Server Core is larger but broadly compatible. ASP.NET Core applications typically run on Nano Server. Legacy .NET Framework applications require Server Core or the full Windows Server base image.

Windows container images are version-tied. An image built with the ltsc2022 base image will only run on Windows Server 2022 nodes. It will be rejected by Windows Server 2019 nodes. This version matching is enforced by the container runtime and cannot be bypassed.

Pushing the Image to a Registry

After building, push the image to your private registry or a cloud registry. Ensure the Windows build host trusts the registry’s TLS certificate (see the private registry article for certificate installation steps).

# Log in to the registry
docker login registry.internal.example.com

# Push the image
docker push registry.internal.example.com/myteam/myapp:1.0

# Optionally, also push with a 'latest' tag
docker tag registry.internal.example.com/myteam/myapp:1.0 registry.internal.example.com/myteam/myapp:latest
docker push registry.internal.example.com/myteam/myapp:latest

For Kubernetes to pull from a private registry, you must create an image pull secret in the namespace where the workload will run:

kubectl create secret docker-registry regcred 
  --docker-server=registry.internal.example.com 
  --docker-username=myteam-deployer 
  --docker-password=DeployerPassword123! 
  [email protected] 
  --namespace=production

Creating the Kubernetes Deployment YAML

A Kubernetes Deployment for Windows containers requires a nodeSelector or node affinity rules to ensure the pod is scheduled only onto Windows nodes. Without this, the scheduler may attempt to run the pod on a Linux node, where it will fail because the Windows container image is incompatible with the Linux kernel.

Create a file named myapp-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
  labels:
    app: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      # Required: schedule only on Windows nodes
      nodeSelector:
        kubernetes.io/os: windows
      # Tolerate the Windows node taint if applied
      tolerations:
        - key: "os"
          operator: "Equal"
          value: "windows"
          effect: "NoSchedule"
      imagePullSecrets:
        - name: regcred
      containers:
        - name: myapp
          image: registry.internal.example.com/myteam/myapp:1.0
          ports:
            - containerPort: 80
              protocol: TCP
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: ASPNETCORE_URLS
              value: "http://+:80"

The nodeSelector: kubernetes.io/os: windows label is automatically applied to Windows nodes by the Kubernetes node lifecycle controller. You do not need to set this label manually.

Creating the Kubernetes Service

A Service exposes the Deployment’s pods to network traffic. For internal access within the cluster, use a ClusterIP Service. For external access, use LoadBalancer (if your cluster has a load balancer controller) or NodePort.

apiVersion: v1
kind: Service
metadata:
  name: myapp-svc
  namespace: production
spec:
  selector:
    app: myapp
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

If you need to expose the application externally on a specific port of the Windows node:

  type: NodePort
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080

Deploying to the Cluster

Apply both manifests from a machine with kubectl configured against your cluster:

kubectl apply -f myapp-deployment.yaml
kubectl apply -f myapp-service.yaml

# Watch pods start up
kubectl get pods -n production -w

# Check deployment status
kubectl rollout status deployment/myapp -n production

Windows pods take longer to start than Linux pods because the container runtime must extract and layer the Windows base image (which can be several gigabytes on first pull). On subsequent starts, the image layers are cached on the node and startup is faster. Expect 2–5 minutes for the first pod pull; subsequent starts typically complete in 30–90 seconds.

Resource Requests and Limits for Windows Containers

Windows containers support CPU and memory resource requests and limits in the same way as Linux containers, but with some behavioural differences. CPU limits on Windows containers are enforced through Windows Job Object scheduling weights, not CFS bandwidth controls as used on Linux. This means Windows CPU limiting is less precise than Linux, and a container may briefly exceed its CPU limit before being throttled.

Memory limits on Windows containers are enforced by the Windows kernel. When a container exceeds its memory limit, the container process is terminated (OOMKill equivalent). Always set appropriate memory limits to prevent a runaway process from exhausting the Windows node’s memory.

A practical resource sizing guide for ASP.NET Core applications on Windows Server 2022:

resources:
  requests:
    memory: "256Mi"   # Typical ASP.NET Core app baseline
    cpu: "250m"       # 0.25 vCPU
  limits:
    memory: "1Gi"     # Allow headroom for request spikes
    cpu: "1000m"      # 1 full vCPU maximum

Windows containers also have a baseline memory overhead from the Windows runtime components loaded into each container. Even an empty Windows Nano Server container consumes approximately 150–200 MB of memory at startup. Set your requests accordingly to avoid pod eviction due to insufficient memory headroom.

ConfigMap and Secret Injection

Kubernetes ConfigMaps and Secrets can be injected into Windows containers as environment variables or as volume-mounted files, just as with Linux containers.

Create a ConfigMap for application configuration:

kubectl create configmap myapp-config 
  --from-literal=DatabaseHost=sqlserver.internal.example.com 
  --from-literal=DatabasePort=1433 
  --from-literal=LogLevel=Information 
  --namespace=production

Create a Secret for sensitive values:

kubectl create secret generic myapp-secrets 
  --from-literal=DatabasePassword=SecureSqlPassword! 
  --from-literal=ApiKey=abcdef1234567890 
  --namespace=production

Reference these in the Deployment spec under the container’s env section:

          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: myapp-config
                  key: DatabaseHost
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: DatabasePassword

Volume-mounted ConfigMaps and Secrets work on Windows containers but require the mount path to use Windows-style paths. Specify the mount path as a Windows path:

          volumeMounts:
            - name: config-volume
              mountPath: "C:\app\config"
      volumes:
        - name: config-volume
          configMap:
            name: myapp-config

Windows Container Logging to Stdout

Kubernetes collects container logs from stdout and stderr. Windows containers support this, but some Windows applications write logs to the Windows Event Log or to files rather than stdout. For Kubernetes log collection to work correctly, your application must write to stdout/stderr.

ASP.NET Core applications configured with the console logging provider write to stdout automatically. Verify this in your Program.cs:

// Program.cs — ensure console logging is configured
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();

For legacy applications that write to files, you can use a sidecar logging container or configure a log forwarder (such as Fluentbit for Windows) to tail the log files and emit them to stdout. View logs with:

kubectl logs -n production deployment/myapp --tail=100 -f

Updating Deployments with kubectl rollout

To update a running deployment to a new image version, update the image tag in the Deployment manifest and apply, or use the kubectl set image command:

# Update to a new image version
kubectl set image deployment/myapp 
  myapp=registry.internal.example.com/myteam/myapp:1.1 
  -n production

# Monitor the rolling update progress
kubectl rollout status deployment/myapp -n production

# If the new version is broken, roll back immediately
kubectl rollout undo deployment/myapp -n production

# View rollout history
kubectl rollout history deployment/myapp -n production

The default rolling update strategy creates new pods before terminating old ones. Because Windows pods have a longer startup time, set an appropriate minReadySeconds value to prevent Kubernetes from marking a new Windows pod as available before it has fully initialised:

spec:
  minReadySeconds: 60
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

With maxUnavailable: 0 and maxSurge: 1, the rolling update adds one new pod before removing one old pod, ensuring there is always at least the desired number of pods serving traffic throughout the update. This is the recommended strategy for Windows container deployments where pod startup latency is a factor.