Building and Pushing an Image
Publishing a container for the right architecture, tagging it by commit as well as latest, and why that tag makes rollback possible.
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRegistry=ghcr.io -p ContainerImageTag=<commit-sha>A container image is a packaged filesystem plus the command that starts your application. A registry is the server images are stored in and pulled from. This half of the pipeline turns a commit into an image sitting in a registry, ready for the deploy job.
Architecture
An image is built for one CPU architecture. GitHub's ubuntu-24.04 runners are x64, so --arch x64 is the usual value. If the cluster runs arm64 nodes, pass --arch arm64 instead. The SDK publishes your application for that architecture and layers it onto a matching base image, so nothing is emulated and the build is no slower. Pushing the wrong architecture gives a container that dies the moment it starts.
Tag by commit
A tag is a name pointing at an image, such as :latest. Tags can be moved to point somewhere else. If every build pushes only :latest, yesterday's image has no name left, so "deploy the previous version" has no version to name. Tag with the commit SHA as well. That tag is written once and never reused, which makes it immutable: a rollback is then a one-line change back to the old SHA. A digest, written @sha256:..., goes further and names the exact bytes regardless of tags.
Two ways to say the same thing
Container settings are MSBuild properties. -p ContainerImageTag=1.4.0 on the command line and <ContainerImageTag>1.4.0</ContainerImageTag> in the project file set the identical property, and -p and /p are the same switch. Keep stable values in the project file and the per-build SHA on the command line.
Example
// The three ways to name one image, and which of them a rollback can rely on.
string registry = "ghcr.io";
string repository = "contoso/orders-api";
string commitSha = "9f3c1ab2d4e5f6071829304a5b6c7d8e9f012345";
// Twelve characters of the commit is short enough to read in a log and long
// enough to stay unique inside one repository.
string shortSha = commitSha[..12];
// Written once by one build, then never written again.
Console.WriteLine($"{registry}/{repository}:{shortSha}");
// Points at whatever built most recently. Useful, not trustworthy.
Console.WriteLine($"{registry}/{repository}:latest");
// The content hash of the image manifest. No command can move it.
string digest = "sha256:1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef01";
Console.WriteLine($"{registry}/{repository}@{digest}");
// Output:
// ghcr.io/contoso/orders-api:9f3c1ab2d4e5
// ghcr.io/contoso/orders-api:latest
// ghcr.io/contoso/orders-api@sha256:1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef01When to use it
- Push an image named after the commit so a rollback is a one-line tag change.
- Build for arm64 cluster nodes from an x64 runner without emulation.
- Hand a tester an image reference that cannot change underneath them.
- Pin a deployment to a digest so a re-pushed tag cannot alter what runs.
More examples
The push job
No Dockerfile is involved: the SDK builds the image itself from the published output. github.sha is the full commit hash of the push, so each run produces one permanent tag plus the moving latest. Use this when the project is pure .NET; a Dockerfile is still needed if the image must run shell commands, because the SDK has no equivalent of RUN.
# .github/workflows/image.yml
name: image
on:
push:
branches: [main]
jobs:
image:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # needed to push to GitHub's own registry
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
# The SDK does not take a password. It reads the credentials that
# docker login writes to the runner's config file.
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish and push
run: |
dotnet publish src/Orders.Api \
--configuration Release \
--os linux --arch x64 \
/t:PublishContainer \
-p ContainerRegistry=ghcr.io \
-p ContainerRepository=contoso/orders-api \
-p ContainerImageTags='"${{ github.sha }};latest"'Stable container settings in the project file
Moving the registry and repository here shrinks the workflow to the one value that really changes, the tag. Since .NET 8 these images also default to the non-root user app, so nothing extra is needed to avoid running as root. Reach for the project file whenever you want the same image produced by a laptop and by CI.
<!-- src/Orders.Api/Orders.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<!-- Each of these is the same MSBuild property a -p switch would set.
Values that do not change per build belong here. -->
<ContainerRegistry>ghcr.io</ContainerRegistry>
<ContainerRepository>contoso/orders-api</ContainerRepository>
<!-- Appends -alpine to the inferred base image tag: a smaller image,
so there is less installed software to scan and to patch. -->
<ContainerFamily>alpine</ContainerFamily>
</PropertyGroup>
<ItemGroup>
<!-- Documents the port. ASP.NET Core listens on 8080 in a container. -->
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>Reading back what the registry stored
This prints the reference's Name, MediaType and Digest. Copy the digest into the deploy manifest when you want the deployment pinned to exact bytes rather than to a name, which matters most for a base image you did not build. It is also the value an artifact attestation is signed against.
# After the push, ask the registry what it has under that tag.
docker buildx imagetools inspect ghcr.io/contoso/orders-api:9f3c1ab2d4e5
Discussion