Deploying a C# application means running dotnet publish and copying the output somewhere. The decisions that matter are made through the flags on that command: whether the target machine needs .NET installed, whether you ship one file or many, and how configuration and secrets reach the running program.
The basic command#
dotnet publish -c Release -o ./publish
-c Release is not optional in practice. A Debug build is larger, slower, and includes debugging information you do not want on a server.
The output folder contains your DLLs, the dependencies, and a runtime configuration file. Copy it to the target machine and run:
dotnet MyApp.dll
Framework-dependent or self-contained#
| Framework-dependent | Self-contained | |
|---|---|---|
| Target needs .NET installed | Yes | No |
| Output size | a few MB | 60 MB or more |
| Security patches | via the shared runtime | you must rebuild |
| Good for | servers you control | desktop distribution |
# Framework-dependent (default)
dotnet publish -c Release
# Self-contained, for 64-bit Linux
dotnet publish -c Release -r linux-x64 --self-contained true
The trade-off is maintenance. A framework-dependent app picks up runtime security fixes when the server updates .NET. A self-contained app carries its own copy, so every runtime fix requires you to rebuild and redeploy.
Runtime identifiers#
win-x64 linux-x64 osx-x64
win-arm64 linux-arm64 osx-arm64
linux-musl-x64 # Alpine
You can publish for any of these from any machine, which makes building a Linux deployment on a Windows laptop straightforward.
Single file#
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true
One executable, no folder of DLLs. Convenient for tools people download and run.
Two things to know. The file extracts itself to a temporary folder on first run, so start-up is slightly slower the first time. And any code that assumes DLLs sit next to the executable — some plugin loaders, some reflection — will need adjusting.
Trimming#
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishTrimmed=true
Trimming removes framework code your application does not reference, which can cut a 60 MB output to under 20 MB.
Ahead-of-time compilation#
dotnet publish -c Release -r linux-x64 -p:PublishAot=true
Native AOT compiles to machine code with no runtime, giving very fast start-up and a small footprint — useful for command-line tools and serverless functions. It comes with real restrictions: no runtime code generation, limited reflection, and some libraries will not work. Suitable for new projects designed with it in mind, awkward to retrofit.
Configuration#
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=shop;"
},
"Logging": {
"LogLevel": { "Default": "Information" }
}
}
Settings are layered, and later sources win: appsettings.json, then appsettings.{Environment}.json, then environment variables, then command-line arguments.
export ASPNETCORE_ENVIRONMENT=Production
export ConnectionStrings__Default="Server=db;Database=shop;"
The double underscore is how environment variables express nesting. That is the standard way to supply secrets in containers and on servers.
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..."
Docker#
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Two details make this efficient. The build happens in an image containing the SDK, while the final image contains only the smaller runtime. And copying the project file and restoring before copying the source means Docker caches the dependency layer, so ordinary code changes rebuild in seconds.
Running as a service on Linux#
[Unit]
Description=My App
After=network.target
[Service]
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/dotnet /var/www/myapp/MyApp.dll
Restart=always
RestartSec=10
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
sudo systemctl enable myapp
sudo systemctl start myapp
sudo journalctl -u myapp -f
Put nginx or another reverse proxy in front for TLS, compression and static files.
A pre-deployment checklist#
- Built with
-c Release - No secrets in the repository or in
appsettings.json ASPNETCORE_ENVIRONMENTset to Production- Detailed error pages disabled for visitors
- Logging goes somewhere you can read after the fact
- A health-check endpoint the platform can poll
- The app runs as a non-root user
- You have tested the exact build you are shipping, not a Debug one
Questions people ask#
Do I need Visual Studio to publish?
No. The dotnet command-line tool does everything, which is also what your continuous integration pipeline will use.
Why is my self-contained build so large?
It includes the whole runtime. Add -p:PublishTrimmed=true to remove unused parts, and test the result carefully afterwards.
Can I publish for Linux from Windows?
Yes. Pass -r linux-x64. Cross-platform publishing is fully supported in both directions.
What is the difference between build and publish?
build compiles for local running. publish produces a self-contained output folder with dependencies and configuration, ready to copy to another machine.
Where to go next#
- C# basics — the language itself.
- C# advanced language features — what you will be deploying.
- Deployment and packaging — the general ideas across languages.