Production Deployment (Container)
This guide shows how to package and run a custom Kestrun PowerShell application in Docker using New-KrDockerDeployment.
The generated deployment bundle is based on:
- the Microsoft ASP.NET Core .NET 10 runtime image
- PowerShell 7.6 installed from the Microsoft Linux package repository
- a packaged Kestrun service application (
.krpack)
Scope
- Target: Docker hosts and Docker Compose deployments.
- Runtime model: container image with a packaged PowerShell Kestrun app.
- Packaging model:
.krpackservice package. - Not covered: native VM/bare-metal services and daemons. See Production Deployment (Service/Daemon).
- Not covered: Kubernetes manifests or Helm charts.
Prerequisites
- Docker Engine or Docker Desktop with
docker composeavailable. - A valid
.krpackpackage for your Kestrun service. - The
KestrunPowerShell module available on the machine generating the bundle. - If you build the
.krpacklocally, PowerShell 7.4 or greater is recommended.
Container Flow Overview
The recommended workflow is:
- Build a
.krpackfrom your app. - Generate a Docker deployment bundle with
New-KrDockerDeployment. - Build the image.
- Run it with Docker Compose or
docker run. - Rebuild and redeploy when your package changes.
Prepare a Service Package
As with service/daemon deployments, start from a .krpack.
If you already have an application folder with a valid Service.psd1:
New-KrServicePackage -SourceFolder .\MyServiceApp -OutputPath .\my-service.krpack
If you want to package directly from a single script:
New-KrServicePackage `
-ScriptPath .\Service.ps1 `
-Name 'my-service' `
-Description 'Containerized Kestrun service' `
-Version 1.2.0 `
-OutputPath .\my-service.krpack
Quick verification:
Get-KrServiceDescriptor -Path .\MyServiceApp\Service.psd1
Or, if you are validating an unpacked package staging folder:
Expand-Archive .\my-service.krpack .\tmp\my-service -Force
Get-KrServiceDescriptor -Path .\tmp\my-service
Generate a Docker Deployment Bundle
Use New-KrDockerDeployment to create a self-contained Docker build context from the .krpack.
Minimal usage:
New-KrDockerDeployment -PackagePath .\my-service.krpack
By default this creates:
- an output folder named
<service-name>-docker - a Docker image name like
kestrun-<service-name>:<version> - a Docker Compose service/container name based on the package descriptor name
- host/container port mapping
8080:8080
Explicit output and ports:
New-KrDockerDeployment `
-PackagePath .\my-service.krpack `
-OutputPath .\deploy\docker `
-PublishedPort 5000 `
-ContainerPort 8080
Custom image and service names:
New-KrDockerDeployment `
-PackagePath .\my-service.krpack `
-ServiceName 'frontend-api' `
-ImageName 'registry.example.com/my-service:1.2.0'
Overwrite an existing generated bundle:
New-KrDockerDeployment -PackagePath .\my-service.krpack -OutputPath .\deploy\docker -Force
New-KrDockerDeployment Parameters
Required:
-PackagePath <string>: path to the.krpackservice package.
Optional:
-OutputPath <string>: output directory for the generated Docker bundle. Defaults to<service-name>-docker.-ImageName <string>: image name written intodocker-compose.yml.-ServiceName <string>: Compose service name and container name.-PublishedPort <int>: host port exposed by Docker Compose. Defaults to8080.-ContainerPort <int>: container port exposed by the image and used forPORT/ASPNETCORE_URLS. Defaults to8080.-KestrunModulePath <string>: optional path to a localKestrunmodule root to stage into the image.-Force: overwrite an existing output bundle.-WhatIfand-Confirm: standard PowerShell safety switches.
The cmdlet returns a summary object with paths and resolved names, including:
DeploymentPathComposePathDockerfilePathEntrypointPathServiceNameImageNamePublishedPortContainerPort
Generated Bundle Layout
The generated output folder contains:
docker-compose.ymlDockerfileentrypoint.shapp.krpackKestrun/.dockerignore
Purpose of each file:
docker-compose.yml: local/default deployment manifest with image name, build context, ports, environment variables, and named volumes for descriptorApplicationDataFolders.Dockerfile: image definition for ASP.NET Core + PowerShell + staged Kestrun module + app package.entrypoint.sh: runtime startup script that extracts the package, reconnects descriptorApplicationDataFoldersto persistent storage, resolvesService.psd1, and launches the packaged entry script.app.krpack: your packaged Kestrun app copied into the image build context.Kestrun/: a staged copy of the current Kestrun module used during image build..dockerignore: keeps the Docker build context minimal and predictable.
Runtime Behavior Inside the Container
The generated image does the following:
- Starts from
mcr.microsoft.com/dotnet/aspnet:10.0. - Installs PowerShell from the Microsoft package repository.
- Copies the staged
Kestrunmodule into the first PowerShell module path as:
Kestrun/<version>
- Creates
/opt/microsoft/powershell/7/profile.ps1with:
if (Get-Module -ListAvailable Kestrun) {
Import-Module Kestrun
}
- Copies
app.krpackand the startup script into the image. - At container startup:
- extracts
app.krpackinto/opt/kestrun/service
- extracts
- recreates each descriptor
ApplicationDataFolderspath as a symbolic link to a durable path under/opt/kestrun/application-data - seeds an empty durable folder from the packaged content the first time that folder is mounted
- reads
Service.psd1 - resolves
EntryPoint - executes the packaged PowerShell script with
pwsh -File
The generated Compose file sets:
PORTASPNETCORE_URLS
If Service.psd1 defines ApplicationDataFolders, the generated Compose file also adds one named volume per folder and mounts it under /opt/kestrun/application-data/....
This means a typical app using:
Add-KrEndpoint -Port $Port
can run without additional container-specific code.
Descriptor-Driven Application Data Volumes
ApplicationDataFolders in Service.psd1 now drives Docker persistence as well as package-based service updates.
Example descriptor:
@{
FormatVersion = '1.0'
Name = 'bike-rental-shop-web'
Description = 'Standalone Razor Pages web client for the bike rental shop backends.'
Version = '1.0.0'
EntryPoint = './Service.ps1'
ServiceLogPath = './logs/bike-rental-shop-web.log'
ApplicationDataFolders = @(
'data/'
'logs/'
)
}
When New-KrDockerDeployment reads that descriptor it generates:
- a named Docker volume for
data/ - a named Docker volume for
logs/ - startup logic that relinks
./dataand./logsin/opt/kestrun/serviceto those durable locations
This keeps mutable application state and logs intact when you rebuild the image and redeploy the container.
Why the Profile Import Matters
Containerized Kestrun apps can define classes, attributes, and other types that need the Kestrun module available when the entry script is evaluated.
The generated profile ensures that when the packaged entry script starts, Kestrun is already imported in the PowerShell session.
This keeps the startup path simpler than maintaining a custom bootstrap wrapper for every app.
Build the Image
From the generated bundle folder:
cd .\my-service-docker
docker build -t kestrun-my-service:1.2.0 .
Or use Compose:
cd .\my-service-docker
docker compose build
Run the Container with Docker Compose
The generated docker-compose.yml is the recommended starting point:
cd .\my-service-docker
docker compose up
Run detached:
docker compose up -d
Stop and remove:
docker compose down
Rebuild after package changes:
docker compose build --no-cache
docker compose up -d
Run the Container with docker run
If you do not want to use Compose:
docker run --rm -p 5000:8080 --name my-service kestrun-my-service:1.2.0
Use -d for detached mode:
docker run -d -p 5000:8080 --name my-service kestrun-my-service:1.2.0
Inspect logs:
docker logs my-service
Open an interactive PowerShell session:
docker exec -it my-service pwsh
Environment Variables and Port Binding
The generated Compose file sets:
environment:
PORT: "8080"
ASPNETCORE_URLS: "http://+:8080"
This aligns with Kestrun’s container-friendly endpoint behavior.
If your service script does:
param(
[int]$Port = $env:PORT ?? 8080
)
Add-KrEndpoint -Port $Port
the container will bind correctly to all interfaces inside the container.
To change ports, regenerate the bundle with different values:
New-KrDockerDeployment `
-PackagePath .\my-service.krpack `
-PublishedPort 5000 `
-ContainerPort 5001 `
-Force
That produces:
- host port
5000 - container port
5001 PORT=5001ASPNETCORE_URLS=http://+:5001
Configuration, Secrets, and Volumes
The generated bundle is intentionally minimal. For production use, extend docker-compose.yml as needed.
Typical additions:
- extra bind mounts or named volumes beyond the descriptor-driven
ApplicationDataFolders - environment-specific configuration via
environment:orenv_file: - reverse proxy integration
- restart policy tuning
- secret injection through your deployment platform
Example additions:
services:
my-service:
volumes:
- my-service-config:/opt/kestrun/service/config
env_file:
- .env.production
volumes:
my-service-config:
If your app already declares ApplicationDataFolders, the generated bundle includes the corresponding durable volumes automatically. Add explicit extra volumes only for paths that are not declared in the descriptor.
Update Workflow
When your service changes:
- increment the package version in
Service.psd1 - build a new
.krpack - regenerate the Docker deployment bundle
- rebuild the image
- redeploy the container
If you keep the generated named volumes in place, the container reuses the existing ApplicationDataFolders content across redeployments.
Example:
New-KrServicePackage `
-SourceFolder .\MyServiceApp `
-OutputPath .\my-service-1.2.1.krpack `
-Force
New-KrDockerDeployment `
-PackagePath .\my-service-1.2.1.krpack `
-OutputPath .\deploy\docker `
-Force
cd .\deploy\docker
docker compose build --no-cache
docker compose up -d
Avoid docker compose down -v when you want descriptor-managed application data to persist, because -v removes the named volumes generated from ApplicationDataFolders.
Production Hardening Notes
- Treat the generated bundle as a starting point, not the final shape for every environment.
- Pin image tags intentionally; avoid ambiguous rollout practices.
- Use descriptor
ApplicationDataFoldersfor durable app data and logs, and add extra volumes only when you need more paths. - Put secrets outside the image.
- Prefer a reverse proxy, ingress, or load balancer in front of the container for TLS termination and public exposure.
- Validate your generated bundle in staging before promoting it.
- If you need a non-root container, extend the generated
Dockerfileand verify all file and port permissions.
Troubleshooting
The container starts but the app is not reachable
Check the mapped host and container ports:
docker ps
docker logs my-service
Verify your app uses the generated PORT or ASPNETCORE_URLS settings rather than a hard-coded port.
Compose validation fails
Inspect the generated Compose file:
Get-Content .\docker-compose.yml
Regenerate the bundle with -Force if the folder contains stale output.
PowerShell module import issues inside the container
Confirm the module is installed:
docker exec -it my-service pwsh -Command "Get-Module -ListAvailable Kestrun"
Confirm the profile exists:
docker exec -it my-service pwsh -Command "Get-Content /opt/microsoft/powershell/7/profile.ps1"
The service package entry script is wrong
Inspect Service.psd1 inside the extracted app:
docker exec -it my-service pwsh -Command "Import-PowerShellDataFile /opt/kestrun/service/Service.psd1 | Format-List"
Make sure EntryPoint resolves to a real file under the packaged app root.
Current Limits
New-KrDockerDeploymenttargets Docker and Docker Compose output today.- The generated image is Linux-based and built from the .NET 10 ASP.NET runtime image.
- The generated bundle does not create Kubernetes manifests.
- Persistent volumes, secrets management, health orchestration, and reverse-proxy wiring are left to the deployment environment.
Return to the Guides index.