4. Building an image
This lesson packages two small web APIs into images — one in C# (ASP.NET Core minimal API), one in Java (Spring Boot WebFlux, built on Reactor) — and runs them with wslc. Both do the same thing, so you can compare the two ecosystems step by step.
The full code is in the repository: code/wsl-containers. Everything below was run with wslc 2.9.11; the outputs are real.
The two applications
Section titled “The two applications”Each API exposes two endpoints on port 8080:
GET /returns a small JSON document: application name, runtime, operating system and machine name;GET /ticksstreams three Server-Sent Events, one per second.
| C# | Java | |
|---|---|---|
| Framework | ASP.NET Core 10 minimal API | Spring Boot 4.1 WebFlux |
| Single value | anonymous object returned by the lambda | Mono<Info> |
| Stream | IAsyncEnumerable<int> + TypedResults.ServerSentEvents |
Flux<Long> + text/event-stream |
| Web server | Kestrel | Netty |
| Default port in a container | 8080 | 8080 |
C# — csharp-api/Program.cs:
app.MapGet("/", () => new{ App = "csharp-api", Runtime = RuntimeInformation.FrameworkDescription, Os = RuntimeInformation.OSDescription, Machine = Environment.MachineName});
app.MapGet("/ticks", (CancellationToken ct) => TypedResults.ServerSentEvents(Ticks(ct)));
static async IAsyncEnumerable<int> Ticks([EnumeratorCancellation] CancellationToken ct){ for (var i = 0; i < 3; i++) { await Task.Delay(TimeSpan.FromSeconds(1), ct); yield return i; }}Java — java-reactor-api/src/main/java/dev/learn/reactorapi/JavaReactorApiApplication.java (project generated with start.spring.io, dependency Spring Reactive Web):
record Info(String app, String runtime, String os, String machine) {}
@RestControllerclass InfoController {
@GetMapping("/") Mono<Info> info() { return Mono.just(new Info( "java-reactor-api", "Java " + Runtime.version(), System.getProperty("os.name") + " " + System.getProperty("os.version"), System.getenv().getOrDefault("HOSTNAME", "?"))); }
@GetMapping(value = "/ticks", produces = MediaType.TEXT_EVENT_STREAM_VALUE) Flux<Long> ticks() { return Flux.interval(Duration.ofSeconds(1)).take(3); }
}The Containerfile
Section titled “The Containerfile”A Containerfile (same syntax as a Dockerfile) describes how to build the image. Both apps use a multi-stage build: a first stage with the full SDK compiles the application, a second stage with only the runtime receives the result. The build tools never reach the final image.
C# — csharp-api/Containerfile:
# --- Stage 1: build with the full SDK (compiler, NuGet) ---FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /src
# Restore first: this layer stays cached as long as the .csproj doesn't changeCOPY CsharpApi.csproj .RUN dotnet restore
COPY . .RUN dotnet publish -c Release -o /app --no-restore
# --- Stage 2: run with the ASP.NET Core runtime only ---FROM mcr.microsoft.com/dotnet/aspnet:10.0WORKDIR /appCOPY --from=build /app .
# Non-root user provided by the .NET imagesUSER $APP_UIDEXPOSE 8080ENTRYPOINT ["dotnet", "CsharpApi.dll"]Java — java-reactor-api/Containerfile:
# --- Stage 1: build with Maven and the full JDK ---FROM maven:3.9-eclipse-temurin-25 AS buildWORKDIR /src
# Download dependencies first: this layer stays cached as long as pom.xml doesn't changeCOPY pom.xml .RUN mvn -q dependency:go-offline
COPY src ./srcRUN mvn -q package
# --- Stage 2: run with the JRE only ---FROM eclipse-temurin:25-jreWORKDIR /appCOPY --from=build /src/target/app.jar app.jar
# Non-root user provided by the Ubuntu base imageUSER ubuntuEXPOSE 8080# Netty loads a native library: allow it explicitly (Java 24+ warns otherwise)ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "app.jar"](<finalName>app</finalName> in pom.xml gives the jar a fixed name.)
| Instruction | Role |
|---|---|
FROM image AS name |
starts a stage from a base image and names it |
WORKDIR |
working directory in the image |
COPY |
copies files from the build context (the folder passed to build) |
COPY --from=build |
copies files from another stage |
RUN |
command run during the build |
USER |
user the container process runs as |
EXPOSE |
documents the listening port (doesn’t publish it) |
ENTRYPOINT |
command run when the container starts |
The same steps, side by side:
| Step | C# | Java |
|---|---|---|
| Build image | dotnet/sdk:10.0 |
maven:3.9-eclipse-temurin-25 |
| Dependencies | dotnet restore (NuGet) |
mvn dependency:go-offline |
| Compile and package | dotnet publish → folder of DLLs |
mvn package → one executable jar |
| Runtime image | dotnet/aspnet:10.0 |
eclipse-temurin:25-jre |
| Non-root user | USER $APP_UID (app, uid 1654) |
USER ubuntu (uid 1000) |
Each project also has a .dockerignore (bin/ and obj/ for C#, target/ for Java) so local build outputs aren’t sent into the build. wslc honors it: a file placed in obj/ didn’t reach the image, and COPY . . stayed cached.
Building
Section titled “Building”From each project folder (wslc build finds the Containerfile on its own; use -f for another name):
cd code\wsl-containers\csharp-apiwslc build -t csharp-api .
cd ..\java-reactor-apiwslc build -t java-reactor-api .Excerpt of the C# build:
[build 3/6] COPY CsharpApi.csproj .[build 4/6] RUN dotnet restore [build] Determining projects to restore... [build] Restored /src/CsharpApi.csproj (in 201 ms).[build 5/6] COPY . .[build 6/6] RUN dotnet publish -c Release -o /app --no-restore [build] CsharpApi -> /src/bin/Release/net10.0/CsharpApi.dll [build] CsharpApi -> /app/[stage-1 3/3] COPY --from=build /app .exporting to image | naming to docker.io/library/csharp-api> wslc image listREPOSITORY TAG IMAGE ID CREATED SIZEcsharp-api latest c1bdb0897739 3 minutes ago 230MBjava-reactor-api latest 3a731cbc7d15 4 minutes ago 387MBRunning
Section titled “Running”Both apps listen on 8080 inside their container; publish them on two different Windows ports:
wslc run -d --rm -p 5000:8080 --name csharp csharp-apiwslc run -d --rm -p 8081:8080 --name java java-reactor-apiwslc container listCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESb8acbb234fb1 java-reactor-api "java --enable-nativ…" 8 seconds ago Up 7 seconds 127.0.0.1:8081->8080/tcp java8bdcda0c3927 csharp-api "dotnet CsharpApi.dll" 8 seconds ago Up 7 seconds 127.0.0.1:5000->8080/tcp csharpwslc publishes on 127.0.0.1 by default, so use that address with curl (curl.exe ships with Windows):
curl.exe http://127.0.0.1:5000/curl.exe http://127.0.0.1:8081/{"app":"csharp-api","runtime":".NET 10.0.12","os":"Ubuntu 24.04.5 LTS","machine":"8bdcda0c3927"}{"app":"java-reactor-api","runtime":"Java 25.0.4+7-LTS","os":"Linux 6.18.40.1-microsoft-standard-WSL2","machine":"b8acbb234fb1"}The machine name is the container ID. The stream (-N disables curl’s buffering, so the events show up one per second):
curl.exe -N http://127.0.0.1:5000/tickscurl.exe -N http://127.0.0.1:8081/ticksdata: 0
data: 1
data: 2Java writes data:0 without the space; both forms are valid SSE.
Proof it’s Linux, and not root
Section titled “Proof it’s Linux, and not root”wslc exec csharp uname -awslc exec csharp idwslc exec java idLinux 8bdcda0c3927 6.18.40.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Fri Jul 31 22:12:15 UTC 2026 x86_64 x86_64 x86_64 GNU/Linuxuid=1654(app) gid=1654(app) groups=1654(app)uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev)The kernel is the WSL one: containers share the kernel of the wslc session VM. Without the USER line, the Java container would run as uid=0(root).
Reading the logs
Section titled “Reading the logs”wslc container logs csharpwslc container logs javainfo: Microsoft.Hosting.Lifetime[14] Now listening on: http://[::]:8080info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down. :: Spring Boot :: (v4.1.1)
... Starting JavaReactorApiApplication v0.0.1-SNAPSHOT using Java 25.0.4 with PID 1 (/app/app.jar started by ubuntu in /app)... Netty started on port 8080 (http)... Started JavaReactorApiApplication in 1.391 seconds (process running for 1.795)wslc container stop csharp javaDiagnosing
Section titled “Diagnosing”wslc container list --all # includes stopped containers, with their exit codewslc container logs <container> # what the application printedwslc container inspect <container> # effective configuration: command, env, ports, exit codewslc image inspect <image>Freeing up disk space
Section titled “Freeing up disk space”Each rebuild moves the tag to the new image and leaves the old one behind, untagged:
REPOSITORY TAG IMAGE ID CREATED SIZEcsharp-api latest c1bdb0897739 3 minutes ago 230MB<none> <none> ddb9e015ed72 4 minutes ago 387MBjava-reactor-api latest 3a731cbc7d15 4 minutes ago 387MB<none> <none> c32cae38bd07 7 minutes ago 230MBwslc container prune # removes stopped containerswslc image prune # removes dangling images (the <none> ones)wslc image prune --all # removes all images not used by a container (no confirmation)Key takeaways
Section titled “Key takeaways”- A multi-stage build compiles with the SDK and ships only the runtime: 230 MB for the C# image instead of 918 MB for the build stage.
- Copy the project file (
.csproj,pom.xml) and restore dependencies before copying the sources, to keep that layer cached. RUNruns at build time,ENTRYPOINTat startup.- Run as a non-root user:
USER $APP_UIDfor .NET images,USER ubuntufor Temurin images. - Both apps listen on 8080 inside the container;
-p host:containerchooses the Windows port. container list --all,logsandinspectare the first things to reach for when a container doesn’t behave as expected.
Exercises
Section titled “Exercises”- How big would the C# image be if you shipped the build stage instead of the runtime stage? Measure it without editing the
Containerfile.
Solution
--target stops the build at a named stage:
wslc build --target build -t csharp-api:build .wslc image listcsharp-api latest c32cae38bd07 3 minutes ago 230MBcsharp-api build 3d3e47d940b4 3 minutes ago 918MBThe SDK, NuGet caches and intermediate files make the build stage four times larger. Remove it afterwards: wslc image remove csharp-api:build.
- Make both apps listen on port 9000 inside their container, without rebuilding the images.
Solution
Both frameworks read the port from an environment variable, passed with -e:
wslc run -d --rm -e ASPNETCORE_HTTP_PORTS=9000 -p 5000:9000 --name csharp csharp-apiwslc run -d --rm -e SERVER_PORT=9000 -p 8081:9000 --name java java-reactor-apiwslc container logs csharpwslc container logs java Now listening on: http://[::]:9000... Netty started on port 9000 (http)The container side of -p must follow: 5000:9000, not 5000:8080. EXPOSE 8080 in the Containerfile is only documentation and doesn’t prevent this.
- A teammate starts the Java app with
wslc run -d --rm -e SERVER_PORT=abc --name java java-reactor-api. A few seconds later,wslc container listshows nothing andwslc container logs javaanswers:
Container 'java' not found.Error code: WSLC_E_CONTAINER_NOT_FOUNDWhat happened, and how do you find the cause?
Solution
The app crashed at startup, and --rm removed the container along with its logs. Run it again without --rm:
wslc run -d -e SERVER_PORT=abc --name java java-reactor-apiwslc container list --allwslc container logs javaCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES7b374a736ad1 java-reactor-api "java --enable-nativ…" 7 seconds ago Exited (1) 4 seconds ago java***************************APPLICATION FAILED TO START***************************
Description:
Failed to bind properties under 'server.port' to java.lang.Integer:
Property: server.port Value: "abc" Origin: System Environment Property "SERVER_PORT" Reason: failed to convert java.lang.String to java.lang.Integer (caused by java.lang.NumberFormatException: For input string: "abc")wslc container inspect java confirms "SERVER_PORT=abc" in Env and "ExitCode": 1. Clean up with wslc container remove java.
GET /reports"os":"Ubuntu 24.04.5 LTS"for C# but"os":"Linux 6.18.40.1-microsoft-standard-WSL2"for Java. Are the two containers running on different systems?
Solution
No. The two runtimes don’t describe the same thing:
RuntimeInformation.OSDescription(.NET) reads the distribution of the image (/etc/os-release): theaspnet:10.0image is based on Ubuntu 24.04;os.name+os.version(Java) give the name and version of the kernel, shared by all containers of the session.
wslc exec java cat /etc/os-release shows that the Temurin 25 image is based on Ubuntu 26.04, and wslc exec csharp uname -r shows the same WSL kernel as Java.
Sources
Section titled “Sources”- WSL container — Microsoft Learn
- Containerize a .NET app and .NET container images — Microsoft Learn
- Server-Sent Events in ASP.NET Core minimal APIs — Microsoft Learn
- Container images and Dockerfiles — Spring Boot reference
- Web on Reactive Stack (WebFlux) — Spring Framework reference
- Multi-stage builds and Dockerfile reference — Docker docs (
wslcuses the same syntax)