TutorialSep 2026 · 7 min read

Build, Ship And Deploy A .NET App From A Single .csproj

Visual Studio.NETDockerCI/CD

In the previous two articles, a pre-build event turned out to be nothing more than an MSBuild target running an Exec task, and I used that to stamp every build with a date-based version and its git branch and commit. If a project file can run git, it can run anything, including the docker, scp and ssh commands I was typing by hand every time I pushed a build to my QA server. This article moves from versioning to deployment: one target, in one .csproj, that builds a Docker image, copies it to a Linux server and deploys it.

Before this, deploying to my QA server meant the same routine every time: build the image, save it, scp it across, ssh in and restart the container. It's a sequence of commands that should run after a successful build, and running commands at a particular point in the build is exactly what the PreBuild target from Part 1 does. The only difference is when.

The application in this article is a standard ASP.NET Core web app with a Dockerfile, created from the Visual Studio template with Docker support enabled. The server is an Ubuntu VM with Docker installed and SSH key access. The deployment has three steps:

  1. Build a Docker image and save it to a .tar file
  2. Copy the .tar file to the server with scp
  3. Run a script on the server with ssh that loads the image and restarts the container

Nothing here needs a container registry or a build server. Every tool involved (docker, scp, ssh) is already on a typical Windows developer machine.

Adding A Target

In Part 1, Visual Studio wrote a target for us that used BeforeTargets to run before the built-in PreBuildEvent target. A deployment needs the opposite: a target that runs once the build has finished. When you build a project, you're running a target called Build, which itself depends on dozens of others that the .NET SDK provides, and AfterTargets attaches your own target to the end of that chain. Add the following just before the closing </Project> tag:

<Target Name="DeployAfterBuild" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
    <Message Text="Hello from DeployAfterBuild" Importance="high" />
</Target>

AfterTargets="Build" tells MSBuild to run this target once the Build target has completed successfully. If compilation fails, it never runs, and that is exactly the behavior you want from a deployment step. The Condition restricts it to Release builds, so switching Visual Studio to Release and building now produces the message in the Output window:

[PASTE: Output window lines from a Release build showing "Hello from DeployAfterBuild"]

The Importance="high" attribute matters more than it looks. Visual Studio's default build output verbosity is Minimal, which hides messages of normal importance. Without it, your message is written to the log, but you'll never see it.

Building The Image To A File

The first real step is to build the Docker image. Rather than pushing it to a registry, I ask docker buildx to write it to a file, using the --output option:

<Target Name="DeployAfterBuild" AfterTargets="Build" Condition="'$(Configuration)' == 'Release' AND '$(OS)' != 'Unix'">
    <PropertyGroup>
        <ImageName>myapp/web</ImageName>
        <ImageTarFile>$(ProjectDir)bin\Release\net8.0\$(MSBuildProjectName).tar</ImageTarFile>
        <DockerBuildxCommand>docker buildx build --output type=docker,name=$(ImageName),dest="$(ImageTarFile)" --progress=plain --platform linux/amd64 -t "$(ImageName):latest" -f "$(MSBuildProjectDirectory)\Dockerfile" "$(SolutionDir.TrimEnd('\'))"</DockerBuildxCommand>
    </PropertyGroup>

    <Message Text="Executing Docker Build Command:" Importance="high" />
    <Message Text="$(DockerBuildxCommand)" Importance="high" />
    <Exec Command="$(DockerBuildxCommand)" />
</Target>

There are a few things to notice here. The first is that a PropertyGroup can be declared inside a target. Properties declared this way are only evaluated when the target runs, so they can build on values that are only known at that point. $(ProjectDir), $(MSBuildProjectName) and $(MSBuildProjectDirectory) are all well-known properties supplied by MSBuild, so the target doesn't need to hardcode where the project lives.

The second is $(SolutionDir.TrimEnd('\')). This is a property function: MSBuild lets you call .NET string methods directly on a property's value. The build context passed to Docker has to be the solution folder, because the Dockerfile that Visual Studio generates copies in the other projects that the web app references. SolutionDir ends in a backslash, and a trailing backslash immediately before a closing quote escapes the quote on the Windows command line. TrimEnd('\') removes it. It's a small thing, but it will cost you an afternoon if you don't know about it.

The third is the Message that prints the full command before Exec runs it. I'd recommend doing this for every command you execute from MSBuild. When something goes wrong, you can copy the exact command from the Output window and run it yourself in a terminal to see what happened.

Finally, the '$(OS)' != 'Unix' condition keeps the target from running anywhere other than Windows, where bin\Release\net8.0\ and backslash paths make sense.

📷 [SCREENSHOT: Output window during a Release build, showing the echoed docker buildx command followed by buildx progress lines]

Shipping And Deploying

With the image saved as a file, the remaining two steps are one command each. Add these properties to the PropertyGroup:

<DockerUploadCommand>scp -i "C:\keys\deploy_key" "$(ImageTarFile)" ubuntu@203.0.113.10:/home/ubuntu/docker/web.tar</DockerUploadCommand>
<DockerDeployCommand>ssh -i "C:\keys\deploy_key" ubuntu@203.0.113.10 /home/ubuntu/docker/deploy.sh /home/ubuntu/docker/web.tar web</DockerDeployCommand>

Then execute them in order after the build:

<Exec Command="$(DockerBuildxCommand)" />
<Exec Command="$(DockerUploadCommand)" />
<Exec Command="$(DockerDeployCommand)" />

Exec fails the build if the command it runs returns a non-zero exit code, and each Exec only runs if the previous one succeeded. So if the image fails to build, nothing is uploaded, and if the upload fails, nothing is deployed. You get sensible failure behavior without writing any.

The deploy script is the only part of this that lives on the server. From MSBuild's point of view, it's a black box with a simple contract: it takes the path to a .tar file and a service name, and it returns zero if the deployment worked. The simplest version that fulfills that contract is only a few lines long:

#!/usr/bin/env bash
set -euo pipefail
docker load -i "$1"
docker compose -f /home/ubuntu/docker/docker-compose.yml up -d --force-recreate "$2"

docker load imports the image from the file, and docker compose up --force-recreate replaces the running container with one based on the new image. This version has a few seconds of downtime while the container restarts. I'll replace it with a zero-downtime version in a later article, but it's enough to prove the concept.

Here's the complete target:

<Target Name="DeployAfterBuild" AfterTargets="Build" Condition="'$(Configuration)' == 'Release' AND '$(OS)' != 'Unix'">
    <PropertyGroup>
        <ImageName>myapp/web</ImageName>
        <ImageTarFile>$(ProjectDir)bin\Release\net8.0\$(MSBuildProjectName).tar</ImageTarFile>
        <DockerBuildxCommand>docker buildx build --output type=docker,name=$(ImageName),dest="$(ImageTarFile)" --progress=plain --platform linux/amd64 -t "$(ImageName):latest" -f "$(MSBuildProjectDirectory)\Dockerfile" "$(SolutionDir.TrimEnd('\'))"</DockerBuildxCommand>
        <DockerUploadCommand>scp -i "C:\keys\deploy_key" "$(ImageTarFile)" ubuntu@203.0.113.10:/home/ubuntu/docker/web.tar</DockerUploadCommand>
        <DockerDeployCommand>ssh -i "C:\keys\deploy_key" ubuntu@203.0.113.10 /home/ubuntu/docker/deploy.sh /home/ubuntu/docker/web.tar web</DockerDeployCommand>
    </PropertyGroup>

    <Message Text="Executing Docker Build Command:" Importance="high" />
    <Message Text="$(DockerBuildxCommand)" Importance="high" />
    <Exec Command="$(DockerBuildxCommand)" />

    <Message Text="Executing SCP Upload Command:" Importance="high" />
    <Message Text="$(DockerUploadCommand)" Importance="high" />
    <Exec Command="$(DockerUploadCommand)" />

    <Message Text="Executing SSH Deploy Command:" Importance="high" />
    <Message Text="$(DockerDeployCommand)" Importance="high" />
    <Exec Command="$(DockerDeployCommand)" />
</Target>

Switch to Release, build, and a minute or so later the new version is running on the server:

[PASTE: tail of the Output window from a successful run - buildx finishing, scp progress, deploy.sh output, "Build succeeded"]

What's Wrong With This?

It works, and for one project on one server it's arguably all you need. But it doesn't take long to run into its limitations:

  • Every Release build deploys. There is no way to produce a Release build without shipping it to the server. Release was a convenient hook, but it was never really the right one.
  • Everything is hardcoded. The server address, the SSH key path and the image name are all written directly into the commands. Pointing at a staging server means editing three lines, and it's all too easy to miss one.
  • It doesn't scale beyond one project. When I added a second and then a third web project to the solution, each one got its own copy of this target, with its own hardcoded values. Fixing a bug meant fixing it in every copy.

The next article addresses the first two problems by introducing a dedicated Deploy configuration and pulling the values out into properties, and looks at how to tag images per environment. The third problem gets an article of its own, because the solution, Directory.Build.props and Directory.Build.targets, turned out to be one of the most useful MSBuild features I'd never heard of.

Summary

A .csproj file is an MSBuild script, and MSBuild can run anything you can run in a terminal. With one target hooked onto Build using AfterTargets, a project can build its own Docker image, copy it to a server with scp and trigger a deployment with ssh, with no registry, no build server and no third-party service involved. Exec stops at the first failed step, and logging each command before running it makes the whole thing easy to debug.