Deploy a .NET App on a Small Container — Publish First, Then Tune the GC
Deploying a .NET app to a small container takes two things most guides skip: upload the output of dotnet publish, not your source tree, and understand that the .NET garbage collector sizes its heap from the container's memory limit, not the host's. Get the first wrong and the build has nothing runnable to start; get the second wrong and your app quietly claims far more memory than you intended on a modest quota. Both are easy once you know them, and a small ASP.NET Core service is otherwise a very comfortable fit — .NET is one of the runtimes the build agent handles directly.
Publish first — this is the big one
The deploy path expects prebuilt publish output. It runs your app; it does not compile it. So the artifact you upload is what dotnet publish produced, not the folder your .csproj lives in.
dotnet publish -c Release -o ./publish
Then zip the contents of ./publish and upload that — the zip layout guide covers the expected structure. Inside you'll find your .dll, its dependencies, and a .runtimeconfig.json, which is also how the platform works out which .NET version you need.
Two consequences worth stating plainly:
- Don't upload your source folder. There's no
dotnet buildstep, so a project file with no compiled output has nothing to run. - Publish for Linux. These are Linux x86 containers. If you're on Windows,
dotnet publish -c Release -r linux-x64when you need a runtime identifier.
.NET 6 isn't supported. Target 8.0, or 9.0 if that's what your project uses. Both are detected from your publish output.
Framework-dependent or self-contained
Both work, and they change the start command:
- Framework-dependent (the default) produces
MyApp.dlland starts withdotnet MyApp.dll. Smaller upload, since the runtime isn't bundled. - Self-contained produces an executable and starts with
./MyApp. Larger upload, no runtime dependency.
Framework-dependent is the better default here — the upload cap is 50 MB, and a self-contained publish can approach or exceed that before you've added anything of your own. If you do go self-contained, PublishTrimmed brings it back down considerably.
The web bits are handled
For an ASP.NET Core app, two environment variables matter and both get set for you:
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://+:8080
http://+:8080 is the .NET way of saying "listen on all interfaces" — the same requirement as 0.0.0.0 in any other stack, and the same failure if you get it wrong: a successful deploy that returns 502, because nothing is listening where the router expects.
Bind an HTTPS port to container port 8080 and TLS is terminated at the edge with a certificate issued automatically. Your app speaks plain HTTP inside the container; you don't configure Kestrel for HTTPS.
Memory: what the GC actually does in a container
This is where .NET surprises people on small quotas, and it's worth understanding rather than guessing.
Workstation GC is the default, not Server GC — which is the memory-friendly choice already. Don't enable ServerGarbageCollection on a single small container; it creates per-core heaps and inflates your footprint for throughput you don't need.
The GC reads the container's limit, not the host's. Microsoft's documentation is explicit that for a process running in a container, the GC considers physical memory based on the container limit. So your RAM quota is what it plans around.
The default heap ceiling is 75% of that limit. If no heap hard limit is set, the default is 20 MB or the heap-hard-limit percent of the container limit, whichever is larger — and that percent defaults to 75% in a memory-constrained environment (GC config docs).
That's the number to know. On a 0.25 GB app, the GC will happily grow toward ~190 MB before it gets aggressive, which leaves little room for everything else in the process.
Three settings that help
If you're tight on memory, in rough order of usefulness:
DOTNET_GCHeapHardLimitPercent # lower the ceiling, e.g. 50
DOTNET_GCConserveMemory # 0–9; docs suggest starting at 5–7
DOTNET_GCRegionSize # 1 MB is recommended for very small heaps
GCHeapHardLimitPercentcaps the heap as a share of the container limit. Set it as an environment variable in hexadecimal, or useSystem.GC.HeapHardLimitPercentinruntimeconfig.jsonin decimal — the two formats differ, which is a genuine trap.GCConserveMemorytrades more frequent collections for a smaller heap. Microsoft's own tip is to experiment starting between 5 and 7.GCRegionSizeat 1 MB reduces the GC's own bookkeeping overhead, which the docs recommend specifically for processes with very small heaps.
On .NET 9 and later, DATAS — dynamic adaptation to application size — is on by default and already keeps the heap roughly proportional to your live data. If you're on 9, try it as-is before reaching for the knobs above.
Publish-time settings
Two .csproj properties reduce footprint before the app even starts:
<InvariantGlobalization>true</InvariantGlobalization>
<PublishTrimmed>true</PublishTrimmed>
InvariantGlobalization drops ICU, which meaningfully shrinks the image — and is required if you're basing on Alpine or chiselled images. It also changes culture-sensitive behaviour, so don't enable it blindly on an app that formats dates or sorts strings per-locale.
If you'd rather write the Dockerfile
Nothing stops you. Build and publish in a multi-stage Dockerfile, base the final stage on mcr.microsoft.com/dotnet/aspnet:8.0, and push by git — the platform builds the Dockerfile at your repository root. That path does compile, because you control the build.
The zip route exists so you don't have to; the Dockerfile route exists so you can.
FAQ
Do I upload my source code or the build output?
Publish output. Run dotnet publish -c Release -o ./publish and zip the contents of that folder — there's no compile step in the zip deploy path.
Which .NET versions are supported?
8.0 and 9.0. .NET 6 isn't supported. The version is detected from your publish output's runtimeconfig.json.
What start command does my app need?
dotnet MyApp.dll for a framework-dependent publish, or ./MyApp for a self-contained one.
Do I need to configure HTTPS in Kestrel?
No. Bind an HTTPS port to your container port and TLS is terminated at the edge with an automatic certificate. Your app serves plain HTTP internally.
Why is my .NET app using so much memory?
The GC sizes its heap from the container's memory limit and, by default, will grow toward 75% of it. Lower GCHeapHardLimitPercent, or try GCConserveMemory, before assuming a leak.
Should I enable Server GC?
No, not on a single small container. Workstation GC is the default and the right one here — Server GC creates per-core heaps and uses more memory.
Wrapping up
.NET has a reputation for being heavy that mostly comes from defaults chosen for servers with plenty of memory. In a container it reads the limit you gave it and plans around that, so the practical work is telling it you meant a small limit: publish rather than upload source, stay framework-dependent, leave Workstation GC alone, and lower the heap ceiling if things feel roomy. After that a minimal API is a perfectly ordinary small container workload.