Building and Pushing Images

Build a multi-arch image for an ASP.NET Core API in CI, tag it from the commit, push it to a registry and deploy it by digest.

Syntaxuses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }}

The image is the release artifact: the thing you scan, promote from staging to production and roll back to. CI builds it once per commit, gives it a name that leads back to that commit and pushes it. Every later stage refers to that exact image; nothing is rebuilt per environment.

Dockerfile or PublishContainer

Dockerfile + buildxdotnet publish /t:PublishContainer
Needs a Docker daemonYes (BuildKit)No, it can push straight to a registry
Extra OS packages, native toolsAny RUN stepNot possible; pick a base image that already has them
Multi-archplatforms: with cross-compilationRuntimeIdentifiers with several Linux RIDs
DefaultsWhatever the file saysNon-root user, port 8080, labels from the project

Choose PublishContainer for a plain API on a Microsoft base image: less to maintain and no daemon in the job. Choose a Dockerfile when the image needs anything else installed or the platform team standardises on Dockerfiles. Both are covered in the Deploy stage; this lesson is about running them in a pipeline.

Tags that lead back to a commit

Tag every image with the full commit SHA (sha-4f1c2e9...), add 1.8.0 when a release tag is pushed and, if you like, a moving main tag for humans. Never deploy a moving tag such as latest: with imagePullPolicy: IfNotPresent nodes keep running whatever they cached, two pods of one Deployment can run different code, and a rollback to latest is not a rollback. Better still, deploy by digest (orders-api@sha256:...): a tag can be pushed again, a digest is the content itself. docker/build-push-action returns it as steps.build.outputs.digest; pass that to the deploy job. Also stamp the commit into the binary with -p:SourceRevisionId=$GIT_SHA, so /version answers which build is running.

Multi-arch without QEMU pain

Building linux/arm64 on an x64 runner through QEMU emulation runs the whole SDK under emulation, and a restore and publish that took 2 minutes can take 15. .NET can cross-compile instead: run the build stage on the runner's own platform (FROM --platform=$BUILDPLATFORM) and publish for the target with -a $TARGETARCH. Only the final stage uses the target platform, and it has no RUN step, so no emulation is needed. The result is one manifest list; each node pulls the matching image. The classic failure is an arm64 manifest that contains x64 binaries, which starts with exec format error only on the Graviton or Ampere node pool.

Pull requests and credentials

Build on pull requests so a broken Dockerfile is caught, but push only from main and tags: workflows triggered by forks get a read-only token and no secrets. For GitHub Container Registry the job's GITHUB_TOKEN with packages: write is enough; cloud registries should use OIDC federation rather than stored keys. Pin third-party actions in these jobs to a full commit SHA with the version in a comment, and let Dependabot bump them: a tag such as @v6 can be moved to different code, which then runs with your registry token. cache-from: type=gha reuses layers between runs, so the restore layer is rebuilt only when a project file or Directory.Packages.props changes.

Example

Example · yaml
name: image

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]
  pull_request:

permissions:
  contents: read
  packages: write

jobs:
  image:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v5

      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ghcr.io/acme/orders-api
          tags: |
            type=sha,format=long
            type=semver,pattern={{version}}
            type=raw,value=main,enable={{is_default_branch}}

      - id: build
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          file: src/Orders.Api/Dockerfile
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: GIT_SHA=${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Record the digest
        if: github.event_name != 'pull_request'
        run: echo "Pushed ghcr.io/acme/orders-api@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"

When to use it

  • Push ghcr.io/acme/orders-api with tags sha-<full commit> and 1.8.0 when v1.8.0 is tagged, and hand the digest to the deploy job.
  • Build linux/amd64 and linux/arm64 images in 4 minutes instead of 20 by cross-compiling with BUILDPLATFORM and -a $TARGETARCH.
  • Build the image on every pull request to catch a broken Dockerfile, but push only from main so fork PRs never need registry credentials.

More examples

Cross-compiling Dockerfile for amd64 and arm64

BuildKit sets <code>BUILDPLATFORM</code> to the runner's platform and <code>TARGETARCH</code> to <code>amd64</code> or <code>arm64</code> for each target, and the SDK accepts those names for <code>-a</code>. The SDK stage therefore always runs natively and only the output differs. Restore has to use the same architecture as publish, or <code>--no-restore</code> fails because the assets file has no entry for that runtime. Every project the API references needs its project file and lock file copied before restore, and <code>--locked-mode</code> makes the shipped image use the package versions CI tested; list both runtimes in <code>RuntimeIdentifiers</code> so the lock files cover them. The <code>.git</code> folder is normally excluded by <code>.dockerignore</code>, which is why the commit arrives as a build argument.

Example · dockerfile
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
ARG GIT_SHA=unknown
WORKDIR /src

COPY Directory.Build.props Directory.Packages.props ./
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/packages.lock.json src/Orders.Api/
COPY src/Orders.Infrastructure/Orders.Infrastructure.csproj src/Orders.Infrastructure/packages.lock.json src/Orders.Infrastructure/
RUN dotnet restore src/Orders.Api/Orders.Api.csproj -a $TARGETARCH --locked-mode

COPY src/ src/
RUN dotnet publish src/Orders.Api/Orders.Api.csproj -a $TARGETARCH --no-restore \
    -o /app -p:SourceRevisionId=$GIT_SHA

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["dotnet", "Orders.Api.dll"]

The same image with the SDK's container support

No Dockerfile and no buildx: the SDK assembles the layers itself and pushes to <code>ContainerRegistry</code> using the credentials <code>docker/login-action</code> stored. With several Linux runtime identifiers in <code>RuntimeIdentifiers</code> it publishes one image per architecture plus an index. The odd quoting of <code>ContainerImageTags</code> is needed because MSBuild treats a bare semicolon on the command line as a separator between properties. <code>dotnet publish</code> already builds Release, so <code>-c Release</code> is not needed.

Example · yaml
name: image-sdk

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  image:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v5
        with:
          global-json-file: global.json
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      # ContainerFamily and RuntimeIdentifiers (linux-x64;linux-arm64) live in Orders.Api.csproj.
      - name: Publish and push
        run: >
          dotnet publish src/Orders.Api/Orders.Api.csproj
          /t:PublishContainer
          -p:ContainerRegistry=ghcr.io
          -p:ContainerRepository=acme/orders-api
          -p:ContainerImageTags='"sha-${{ github.sha }};main"'
          -p:SourceRevisionId=${{ github.sha }}

A /version endpoint that reads the stamped commit

The SDK appends the source revision to <code>InformationalVersion</code> after a plus sign: from the git repository when <code>.git</code> is available, or from <code>SourceRevisionId</code> when the build passes it in. A deploy job can then compare <code>/version</code> with the commit it deployed, and an on-call engineer can see which build a pod runs without access to the cluster. <code>Instance</code> is the pod name inside Kubernetes. Expose only the version and commit, never configuration.

Example · csharp
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// The SDK appends the source revision: "1.8.0+4f1c2e9a7b...".
var informational = typeof(Program).Assembly
    .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
var plus = informational.IndexOf('+');
var info = new BuildInfo(
    Version: plus < 0 ? informational : informational[..plus],
    Commit: plus < 0 ? "unknown" : informational[(plus + 1)..],
    Instance: Environment.MachineName);

app.MapGet("/version", () => TypedResults.Ok(info)).AllowAnonymous();

app.Run();

public record BuildInfo(string Version, string Commit, string Instance);

Discussion

  • Be the first to comment on this lesson.