Containerizing an ASP.NET Core Web API with Docker and Setting Up CI/CD with GitHub Actions
We containerize an ASP.NET Core Web API with a multi-stage Dockerfile: dotnet publish, the layer cache, .dockerignore, ports and environment settings; then we build a GitHub Actions CI/CD pipeline that tests the code and pushes the image to ghcr.io.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the last two posts we secured our API with unit and integration tests. The code is correct, but it still lives on your machine. The way to retire the phrase "works on my machine" is to put the app into a single package together with its dependencies, and to test and produce that package automatically on every push. In this post we package it with Docker and automate the process with GitHub Actions.
A guess first. Halfway through the post we will produce an image called productcatalog-api:1.0. Running locally with dotnet run, the app listened on the port from launchSettings. If you start the container with docker run -d -p 8080:8080 productcatalog-api:1.0 and run curl http://localhost:8080/api/products/1, what happens?
dotnet publish: What Are We Packaging?
We don't put source code into the container; we put ready-to-run output. That output is produced by dotnet publish:
dotnet publish src/ProductCatalog.Api/ProductCatalog.Api.csproj -c Release -o /app/publish
The folder contains the files we will see in the image shortly: ProductCatalog.Api.dll is the app itself, .deps.json is the dependency list, .runtimeconfig.json says which runtime to run on, and there is appsettings.json. The API in this post is the ProductCatalog API from the unit testing post with an in-memory repository and a /health/live endpoint added; the test project is the same one.
The difference between build and publish: build produces enough output to run during development, while publish produces a complete folder meant to be copied to a server and run there. The Release configuration turns on compiler optimizations; Debug output should not go to production.
A Multi-Stage Dockerfile
A Dockerfile at the project root, next to ProductCatalog.sln:
# Stage 1: build and publish in the SDK image
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
# The csproj alone first: the restore layer comes from cache even when the code changes
COPY src/ProductCatalog.Api/ProductCatalog.Api.csproj src/ProductCatalog.Api/
RUN dotnet restore src/ProductCatalog.Api/ProductCatalog.Api.csproj
# Then the rest of the code
COPY src/ src/
RUN dotnet publish src/ProductCatalog.Api/ProductCatalog.Api.csproj -c Release -o /app/publish --no-restore
# Stage 2: a small image with only the runtime
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ProductCatalog.Api.dll"]
Two FROM lines mean two separate stages. The first stage uses the sdk image: the compiler, NuGet, MSBuild, in short everything needed to build. The second stage is the aspnet image; it only contains the .NET runtime and ASP.NET Core. COPY --from=build carries the publish folder from the first stage into the second, and everything else stays behind. Think of a furniture workshop: you build the table in the workshop with all your tools, but you deliver only the table to the customer's home, not the workbench.
The reason we copy the csproj separately, before the code, is Docker's layer cache. Docker stores every instruction as a layer and doesn't rerun a layer whose input hasn't changed. Every time you change your code, the COPY src/ src/ layer and everything after it rerun, but as long as the csproj doesn't change, dotnet restore comes from the cache. Downloading packages is the slowest part of the build, so this ordering cuts build time considerably. Because we restore separately, we pass --no-restore to publish.
Why .dockerignore Is a Must
docker build sends the entire folder it runs in to Docker as the build context. When you say COPY src/ src/, the bin/ and obj/ folders on your machine go into the container too. Put this file next to the Dockerfile:
**/bin/
**/obj/
.git/
.github/
tests/
We saw what happens if you skip it. When we first built a project with a Swashbuckle reference locally and then produced the image without a .dockerignore, the publish step failed like this:
error NETSDK1064: Package Microsoft.OpenApi, version 1.6.22 was not found. It might have been deleted since NuGet restore. Otherwise, NuGet restore might have only partially completed, which might have been due to maximum path length restrictions.
Here is what happened: the restore inside the container produced its own obj/project.assets.json, but right after that, COPY src/ src/ overwrote it with the obj/ folder from your machine. The package paths in that file pointed to the NuGet folder on macOS, and there is no such folder in the container. In a project without any package references the same error may not show up, which makes it easy to say "it was fine for me"; it blows up the day you add your first package.
Building and Running the Image
docker build -t productcatalog-api:1.0 .
docker run -d -p 8080:80 --name catalog productcatalog-api:1.0
curl -s -w " %{http_code}\n" http://localhost:8080/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1450} 200
curl -s -w " %{http_code}\n" http://localhost:8080/health/live
Healthy 200
Let's look at the container's log:
docker logs catalog
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:80
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /app/
The answer to the guess is in the first line: inside the container the app listens on port 80. The reason is the aspnet:6.0 image itself. Looking at the image's environment variables with docker inspect shows ASPNETCORE_URLS=http://+:80. When you say -p 8080:8080, Docker connects port 8080 on your machine to port 8080 in the container, but nobody is listening there. curl answered like this:
curl: (56) Recv failure: Connection reset by peer
The right mapping is -p 8080:80: the left side is your machine, the right side is the container. Note that the environment is Production by default too; launchSettings.json isn't used in the container.
The image we produced shows up as 309 MB in the docker images output. There is no SDK in it; packaging it together with the build tools would make it several times bigger. By the way, there is no curl or wget in the image either, so if you want to use Docker's HEALTHCHECK instruction you need to add a tool. Orchestrators like Kubernetes, on the other hand, call the /health/live and /health/ready endpoints from the health check post themselves, from the outside.
Environment and Configuration
The same image should run in the test environment and in production; the difference should come not from the image but from the configuration you give it. The environment variable provider we saw in the configuration post does its real job here:
docker run -d -p 8080:80 -e ASPNETCORE_ENVIRONMENT=Staging --name catalog-stg productcatalog-api:1.0
docker logs catalog-stg | grep "Hosting environment"
Hosting environment: Staging
Nested settings are written with a double underscore: ConnectionStrings:Default in appsettings.json becomes ConnectionStrings__Default as an environment variable. This translation exists because a colon can't safely be used in an environment variable name on Linux.
The golden rule: secrets don't go into the image. If you write a connection string, a JWT signing key or API keys into the Dockerfile with ENV, anyone who can pull the image can read them with docker inspect. Provide secrets at runtime, through the orchestrator's secret mechanism or at least with -e.
CI with GitHub Actions
Typing docker build by hand is fine once, not on every push. GitHub Actions runs the YAML files in your repository's .github/workflows/ folder based on triggers. .github/workflows/ci.yml:
name: ci
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 6.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release --verbosity normal
docker:
# No image until the tests pass; no push on PRs
needs: build-and-test
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
The first job is CI itself: check out the code, install the .NET 6 SDK, restore, build in Release, run the tests. It runs on every push to main and on every pull request opened against main. If a test breaks, the job turns red and that shows on the pull request; broken code is caught before it is merged. The steps are separate for the sake of debugging: in the log you immediately see which step failed. --no-restore and --no-build are there so the same work isn't done twice.
The action versions are the current major versions as of November 2026, when this post was written: actions/checkout@v7, actions/setup-dotnet@v6, docker/login-action@v4, docker/setup-buildx-action@v4, docker/metadata-action@v6 and docker/build-push-action@v7. We checked every input we use against the action.yml files of those versions. When the project moves to net10.0, the only line that changes is dotnet-version: 10.0.x.
Pushing the Image to a Registry
The second job is the first step of CD: turning tested code into an image and putting it into a registry. needs: build-and-test keeps this job from starting before the tests pass, and if: github.event_name == 'push' keeps images from being pushed for pull requests.
We use GitHub Container Registry (ghcr.io) as the registry; the nice thing about it is that it needs no separate password. The GITHUB_TOKEN given automatically to every workflow run is enough, as long as the job has the packages: write permission in its permissions section. metadata-action produces two tags: the short SHA of the commit (with a sha- prefix) and latest if you are on the default branch. It also does a quiet favor: it lowercases the image name. Since Docker doesn't allow uppercase letters in image names, writing a repository name like MertOzen/ProductCatalog straight into the tag would break the build.
The deploy part, meaning running this image on a server, on Azure App Service or in a Kubernetes cluster, varies with the target platform. But the logic of the chain is the same everywhere: push, test, image, registry, deploy. Each link depends on the success of the one before it.
While preparing this post, we really built and ran the Dockerfile with both the sdk:6.0/aspnet:6.0 and the sdk:10.0/aspnet:10.0 images. We ran the workflow's dotnet steps locally with the same commands and saw the 13 tests pass; we did not run the workflow itself on GitHub, we limited ourselves to parsing the YAML and verifying the action inputs.
Common Pitfalls
Using an out-of-support image. mcr.microsoft.com/dotnet/aspnet:6.0 can still be pulled; the image we pulled had runtime 6.0.36 and Debian 11. But .NET 6 support ended on November 12, 2024, which means this image no longer gets security patches. The image in this post is for learning; use a supported version in production.
Forgetting the port when you change versions. When we built the same Dockerfile with the 10.0 tags, the image's environment had ASPNETCORE_HTTP_PORTS=8080 instead of ASPNETCORE_URLS, and the app listened on http://[::]:8080. With this change, which came in .NET 8, -p 8080:80 no longer works and you need -p 8080:8080. The new images also define APP_UID=1654, meaning a non-root user is ready and waiting; but unless you add a USER line to the Dockerfile, the container still runs as root, and in our experiment whoami said root. We will look at both in detail in the .NET 8 post.
Trying to turn on HTTPS in the container. The development certificate isn't in the image, and it shouldn't be. Usually the load balancer or reverse proxy in front terminates TLS and the container speaks plain HTTP. If you use UseHttpsRedirection, you also need to configure forwarded headers for it to work correctly behind a proxy.
Running the tests only in the Dockerfile. A RUN dotnet test line slows down the image build, hides the test results from CI's reports and drags test dependencies into the build stage. Running the tests in a separate CI step and producing the image only afterwards is cleaner; that is exactly what our workflow does.
Relying only on the latest tag. latest points to a different image on every push; when something goes wrong you can't tell which commit is running, and you can't roll back. The SHA tag ties each image to a commit; use it in your deploys.
A Small Experiment
★☆☆ Make a change that comes after the COPY src/ src/ line in the Dockerfile, for example add a comment to a controller, and rebuild the image. See the restore step listed as CACHED in the output. Then add a package to the csproj, build again and compare.
★★☆ Update the Dockerfile to sdk:10.0 and aspnet:10.0, and the csproj to net10.0. First run the container with -p 8080:80 and see the error, then find the right port mapping. Add USER $APP_UID to the final stage and compare the output of whoami through docker exec.
★★★ Add a step to the workflow that passes --logger trx to dotnet test and keeps the result file with actions/upload-artifact@v7. Break a test on purpose, open a pull request, and see both the job turning red and the docker job never starting.
With that, the skeleton is complete: from HTTP basics to routing, from validation to EF Core, from authentication to caching, from testing to containers, we built the skeleton of a Web API with the .NET 6 API surface and saw every line also run on .NET 10. From here on, the series turns into a time machine. First we go back and learn to read the old projects with a Startup.cs and its ConfigureServices and Configure methods; you will still run into them a lot in the field. Then we fast-forward and take .NET 7, 8, 9 and 10 one by one: what each version added to this skeleton, what it changed and what it retired, with a "this is how we did it in .NET 6, this is how it looks now" comparison.