.NET CLI 10 – Microsoft Finally Realizes DevOps Exists
The .NET CLI? Reliable. Boring. You run dotnet build, dotnet test, dotnet publish, done. Real DevOps work happens in Dockerfiles, CI/CD configs, and specialized tools. The CLI does its job but was never built for actual operational workflows.
.NET 10 changes this. Four additions that sound minor but fix real problems I’ve hit in production pipelines for years: native container publishing, ephemeral tool execution, better cross-platform packaging, and machine-readable schemas. Not flashy. Not keynote material. But they’re the kind of improvements that save hours every week once you’re running them at scale.
Will they replace your current workflow? Depends on what you’re building. Let’s look at what actually changed.
Built-in Container Publishing: Dockerfiles Become Optional
Let’s start with the biggest change: dotnet publish now generates container images directly. No Dockerfile needed.
You know the drill. Write a Dockerfile (full control, maintenance hell) or use Docker Build Cloud (more dependencies, more complexity). Both work. Both suck in their own ways.
.NET 8 tried this with Microsoft.NET.Build.Containers—opt-in, awkward, felt bolted-on. .NET 10 makes it first-class. There are still limits, but the core experience is solid.
How It Works
One command:
dotnet publish --os linux --arch x64 /t:PublishContainer
Compiles for Linux x64. Packages as OCI container. Tags from project metadata. Pushes if you have credentials.
No Dockerfile. No multi-stage builds. No base image debates. The CLI handles it using project defaults—works great for standard apps, questionable for edge cases.
Practical Implications
Where this really shines: CI/CD pipelines. I’ve been maintaining GitHub Actions workflows that look like this for years:
- name: Build
run: dotnet build --configuration Release
- name: Build Docker Image
run: docker build -t myapp:latest .
- name: Push to Registry
run: docker push myapp:latest
Now:
- name: Publish Container
run: dotnet publish --os linux --arch x64 /t:PublishContainer
Docker’s gone. The CLI handles everything. For lightweight build agents or K8s-based CI, this cuts build times and removes a dependency I’ve been wanting to ditch.
But here’s the trade-off—you surrender control to CLI defaults. Standard app? Perfect. Need custom base images, specific layers, or complex configs? You’re back to Dockerfiles. The CLI won’t bend that far yet.
When Dockerfiles Remain Necessary
Dockerfiles aren’t dead—just optional for simple cases. Need custom layers, multi-stage builds, or complex runtime configs? Use Dockerfiles. For standard ASP.NET Core on Linux? CLI wins.
The philosophy makes sense: You shouldn’t need Docker expertise to containerize a .NET app. But production systems need security scanning, vulnerability patches, compliance checks. The CLI gives you a working container. Whether it’s production-ready depends on your standards.
Containers are one piece. The other friction point in DevOps pipelines? Tool management.
One-Shot Global Tools: dotnet tool exec
Global tools have been around since .NET Core 2.1. Install, then execute. Two steps, every time, and it gets messy fast in CI environments.
.NET 10 adds dotnet tool exec. Run tools without installing. Like npx in Node.js.
Before
Here’s what I’ve been doing in CI pipelines:
dotnet tool install --global dotnet-format
dotnet format --verify-no-changes
Already installed? First command fails (or needs --ignore-failed-sources). Not installed? Second fails. Managing this state across multiple build agents turns into a fragile mess with conditional logic everywhere.
Now
.NET 10 adds dotnet tool exec:
dotnet tool exec dotnet-format -- --verify-no-changes
Fetch, run, discard. No global state. No cleanup. No version conflicts between pipeline runs.
Why This Matters for CI/CD
CI needs stateless builds. This delivers exactly that. Each run fetches the exact tool version, executes, done. No version conflicts. No pre-baking tools into build images.
Working with minimal base images? Just run tools on-demand. No more Dockerfile layers dedicated to tool installations that bloat your images.
The catch is download overhead per execution. For tools you run repeatedly in tight loops, add caching. The CLI doesn’t auto-cache between exec calls, so repeated executions add latency. In most scenarios, simplicity beats speed. High-frequency pipelines? Measure first.
Local Development Benefits
This isn’t just for CI. Working across multiple projects, I’ve always hated maintaining a global tool collection that inevitably ends up with version conflicts. Now I run project-specific tools without polluting my environment. Node.js devs have had this with npx for years—about time .NET caught up.
Versioning still matters. Need a specific version? Specify it or use dotnet-tools.json. The CLI won’t guess.
While we’re talking about tools, there’s another improvement that tool authors will appreciate.
Multi-Platform Global Tool Packaging
Older .NET versions claimed cross-platform support for tools. Reality? Separate packages for linux-x64, win-x64, osx-arm64. Add native dependencies and you’re in for a nightmare.
.NET 10 improves RID handling. One NuGet package, multiple platforms. The CLI resolves the right binary at runtime based on the target platform.
Works well for simple cases. Complex native dependencies? Still rough, but better than before.
Impact on Tool Authors
If you maintain global tools, you can finally ship one package for all platforms. Less packaging hell, fewer user install failures due to platform mismatches.
Not revolutionary. Incremental. Should’ve been fixed years ago, but I’ll take it.
Now here’s the feature that flew completely under the radar but might end up being the most useful for automation.
CLI Schema Export: Automation Meets Introspection
Machine-readable CLI schemas. Sounds boring. Actually game-changing for tooling and automation that currently relies on parsing brittle text output.
What Is a CLI Schema?
It’s structured data describing CLI commands, options, and arguments. What the CLI can execute, what parameters it accepts, how they interact.
dotnet --info --format json
JSON output: CLI capabilities, SDKs, runtimes, commands. Queryable, parseable, stable across versions.
Use Cases
Where this gets practical:
IDE Integration: VS Code could add IntelliSense for CLI commands directly in terminal windows. They’d need to implement it first, but the foundation’s there.
CI/CD Validation: Check .NET versions before your build fails halfway through. Catch environment mismatches early instead of debugging why the pipeline suddenly broke.
Tooling Development: Third-party tools can adapt to CLI capabilities dynamically. No more hardcoded assumptions that break when the SDK updates.
Documentation Generation: Auto-generate CLI reference docs from the schema. Always current, never stale.
This assumes the schema stays stable across releases. So far Microsoft’s track record is decent. Worth monitoring as it matures.
Real-World Scenario
Here’s a concrete example. Your build script needs .NET 10 before it proceeds. Before, you’d parse text output from dotnet --version—fragile and breaks whenever Microsoft tweaks the format.
Now:
dotnet --info --format json | jq -r '.sdks[] | select(.version | startswith("10."))'
No results? .NET 10 isn’t installed. Results? You have the exact version and installation path. Structured, reliable, resistant to cosmetic changes.
The catch is you need jq or equivalent JSON parsing. Modern CI systems? Not a problem. Windows environments without JSON tooling pre-installed? You’ve just shifted the complexity somewhere else.
The Bigger Picture
This isn’t flashy. It’s foundational. The CLI transforms from a black box you invoke to a queryable API. That enables an entire class of tooling improvements—assuming the ecosystem actually builds them.
Why These Changes Matter
None of these features made keynotes. No press coverage. Not language features or runtime magic. But here’s what’s significant: Microsoft’s finally investing in workflow ergonomics instead of just piling on features.
DevOps teams don’t need more frameworks. We need less friction. Fewer dependencies, fewer scripts, fewer components that break when environments change. The .NET 10 CLI moves toward that—not perfectly, but noticeably.
Built-in containers eliminate Docker as a build dependency for standard cases. Tool exec removes global state from CI pipelines, though you pay in download overhead. Multi-platform packaging simplifies distribution when native dependencies cooperate. Schemas enable automation if you have JSON parsing infrastructure.
Real pain points addressed. Real trade-offs introduced. The CLI shifted from “good enough” to “actually designed for ops work.” It’s not finished, but the direction is right.
Conclusion: Evolution, Not Revolution
.NET 10 isn’t revolutionary. It’s evolutionary. That’s exactly what maturing platforms need—incremental wins that compound over time.
If you’re running DevOps pipelines, building CI/CD workflows, or maintaining .NET tooling, these features aren’t curiosities. They’re practical improvements that’ll simplify your work, speed execution, and improve reliability. You just need to understand the limits.
In environments where every eliminated dependency multiplies across thousands of builds monthly, “simpler” becomes a legitimate feature. Whether .NET 10’s CLI improvements hit “simple enough” depends on your operational context and tolerance for trade-offs.
The direction is right. Whether it’s sufficient for your specific needs? Test it in your environment. The CLI’s finally built for DevOps work. Time to see if it holds up to production reality.
