Mert Özen Further With Every Line
DevOps Docker — #15

Reading Old Docker Setups and Moving Them to 2026: Legacy Builder to BuildKit, Port 80 to 8080, Debian to Ubuntu, Graph Drivers to containerd

Mert Özen Nov 14, 2026 12 min 16 views
Reading Old Docker Setups and Moving Them to 2026: Legacy Builder to BuildKit, Port 80 to 8080, Debian to Ubuntu, Graph Drivers to containerd

Moving an old Dockerfile to 2026 means reading several separate changes together: BuildKit instead of the legacy builder, labels and exec form instead of MAINTAINER and shell form, the move from port 80 to 8080 and from root to the app user in .NET 8, from Debian to Ubuntu in .NET 10, and Engine 29's containerd image store and API version. With real output we modernize Bookstore's hypothetical 2021 Dockerfile line by line and try the .NET 11 SDK's local runtime selection and SOURCE_DATE_EPOCH.

Version note: The examples in this post were run with Docker Engine 29.9.3 (BuildKit 0.34.2 and the legacy builder), .NET 10 and the .NET 11 SDK 11.0.100 (mcr.microsoft.com/dotnet/sdk:11.0); for the old images, mcr.microsoft.com/dotnet/core/sdk:3.1 (SDK 3.1.426) and the configs of aspnet:6.0–aspnet:10.0 were read.

Guess First

Below is a Dockerfile Bookstore.Api might have had in 2021: the form you will see most often in blog posts and company wikis from those years. If you build this file today on Engine 29, with code that has moved to .NET 10, what happens: (a) it builds with warnings, (b) it builds but the container does not answer on 8080, (c) it cannot be built? And count: how many of the 12 instructions do you need to change?

Bookstore's 2021 Dockerfile

Let us give Bookstore, the online bookstore used as the shared example across this site's series, a hypothetical history: the API was written in 2021 with .NET Core 3.1 and containerized with this Dockerfile (labs/15-legacy/Dockerfile.2021):

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
MAINTAINER Bookstore Team <team@bookstore.example.com>
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS http://+:80
EXPOSE 80
ENTRYPOINT dotnet Bookstore.Api.dll

By the standards of its day the file is not bad: multi-stage, no SDK in the final image. But it is one step behind on almost every topic we covered in this series. In the last post of the series we read this file and find out why each line is the way it is and what replaced it today. That is exactly the first job when you inherit an old project.

From the Legacy Builder to BuildKit

In 2021 this file was built with the legacy builder on most Linux machines; BuildKit became the default builder on Linux in Engine 23.0 (February 2023). The legacy builder still exists in Engine 29 and is selected with DOCKER_BUILDKIT=0; if you see this variable in an old CI script, you will know why the output looks different:

DOCKER_BUILDKIT=0 docker build -f labs/15-legacy/Dockerfile.2021 -t dockerseries-bookstore-api:2021 src/Bookstore.Api
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            BuildKit is currently disabled; enable it by removing the DOCKER_BUILDKIT=0
            environment-variable.

Sending build context to Docker daemon  18.48kB
Step 1/12 : FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
 ---> 150d074697d1
Step 2/12 : MAINTAINER Bookstore Team <team@bookstore.example.com>
 ---> Running in ba80c7b0f35d
 ---> Removed intermediate container ba80c7b0f35d
 ---> 50fcaf9aa513
...
Step 6/12 : RUN dotnet publish -c Release -o /app
 ---> Running in 5478fd112637
Microsoft (R) Build Engine version 16.7.3+2f374e28e for .NET
...
/usr/share/dotnet/sdk/3.1.426/Microsoft.Common.CurrentVersion.targets(1177,5): error MSB3971: The reference assemblies for ".NETFramework,Version=v10.0" were not found. You might be using an older .NET SDK to target .NET 5.0 or higher. Update Visual Studio and/or your .NET SDK. [/src/Bookstore.Api.csproj]
The command '/bin/sh -c dotnet publish -c Release -o /app' returned a non-zero code: 1

The traces of the legacy builder: "Sending build context to Docker daemon" (the whole context is sent to the daemon on every build), Step N/M lines, an intermediate container for each instruction ("Running in", "Removed intermediate container") and an image ID at the end of each step. It runs sequentially; the parallel stages from post 7, the cache mounts and build secrets from post 6, --check and Bake exist only in BuildKit. According to Docker's deprecated features page, the legacy builder has been deprecated for Linux images since 23.0, but there is no set version for its removal; it is still used for Windows container images.

The answer to the first question is in the output too: (c). The old SDK image can still be pulled today and even restore passes, but the .NET Core 3.1 SDK does not know net10.0, reads it as .NET Framework 10.0 and fails. Forgetting to update the SDK version along with the code is the most common breakage in old Dockerfiles.

Running --check with BuildKit gives three warnings before the build even starts:

docker build --check -f labs/15-legacy/Dockerfile.2021 src/Bookstore.Api
Check complete, 3 warnings have been found!

WARNING: MaintainerDeprecated - https://docs.docker.com/go/dockerfile/rule/maintainer-deprecated/
Maintainer instruction is deprecated in favor of using label
labs/15-legacy/Dockerfile.2021:2

WARNING: LegacyKeyValueFormat - https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
"ENV key=value" should be used instead of legacy "ENV key value" format
labs/15-legacy/Dockerfile.2021:11

WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for ENTRYPOINT to prevent unintended behavior related to OS signals
labs/15-legacy/Dockerfile.2021:13

(I removed the code excerpt under each warning.) MAINTAINER has been deprecated since Docker 1.13 (2017); LABEL org.opencontainers.image.authors=... replaces it. ENV key value written with a space is the old format. And a shell form ENTRYPOINT is the PID 1 problem from post 3 itself: dotnet runs as a child of the shell, the SIGTERM from docker stop never reaches it, and every deploy waits 10 seconds and ends with 137.

The .NET Side: Port 80, Root and Image Names

The ENV ASPNETCORE_URLS http://+:80 line in the Dockerfile repeats the images' own default of that time. We compared the image configs by reading them from the registry, without pulling:

for t in core/aspnet:3.1 aspnet:6.0 aspnet:7.0 aspnet:8.0 aspnet:9.0 aspnet:10.0; do
    printf "%s " $t
    docker buildx imagetools inspect mcr.microsoft.com/dotnet/$t \
        --format '{{json (index .Image "linux/amd64").Config}}' \
        | jq -c '{User, Env: [.Env[] | select(test("ASPNETCORE_|APP_UID"))]}'
done
core/aspnet:3.1 {"User":null,"Env":["ASPNETCORE_URLS=http://+:80"]}
aspnet:6.0 {"User":null,"Env":["ASPNETCORE_URLS=http://+:80"]}
aspnet:7.0 {"User":null,"Env":["ASPNETCORE_URLS=http://+:80"]}
aspnet:8.0 {"User":null,"Env":["APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080"]}
aspnet:9.0 {"User":null,"Env":["APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080"]}
aspnet:10.0 {"User":null,"Env":["APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080"]}

The break is in .NET 8: ASPNETCORE_URLS=http://+:80 went away, ASPNETCORE_HTTP_PORTS=8080 came in, and an app user, whose uid is in APP_UID, was added to the images. The reason for the port change is non-root: on Linux, listening on ports below 1024 normally requires privilege. Docker lowers that limit (net.ipv4.ip_unprivileged_port_start) to 0 in its own containers, so when we tried it, uid 1654 could listen on 80 in Docker too, even with --cap-drop ALL; but this is a Docker setting, and you should not count on every environment that runs your image doing the same. 8080 works everywhere. Note that the User field stays empty after .NET 8 as well: according to Microsoft's breaking change note, the app user is defined, but using it is opt-in. So an old Dockerfile that only moves its base image to 8.0 keeps running as root; you have to add the USER $APP_UID line yourself (post 8).

When you put this table side by side during a migration, the typical mistakes show up by themselves: a Dockerfile that bumps the base image to 8.0 but keeps the ASPNETCORE_URLS http://+:80 line still opens the app on 80; if USER $APP_UID was added as well, whether the app starts depends on the port setting of the environment that runs the container. On the orchestrator side, containerPort: 80, the health probe port, the reverse proxy's upstream and mappings such as -p 8080:80 in Docker have to be updated at the same time. The Kubernetes series post "Reading Old Kubernetes Manifests: dockershim, PodSecurityPolicy and Removed APIs (1.16–1.25)" covers their manifest counterparts.

Image names changed too. In March 2019 the .NET images moved from Docker Hub to MCR; Docker Hub repositories such as microsoft/dotnet and microsoft/aspnetcore were redirected for a while and deleted in the summer of 2021. With .NET 5 the core/ in the mcr.microsoft.com/dotnet/core/sdk path went away too (mcr.microsoft.com/dotnet/sdk). The old paths still work on MCR for the old tags, as the build above showed, but new versions exist only at the new paths.

From Debian to Ubuntu

The operating system behind the tags changed as well:

for t in 9.0 10.0; do
    printf "aspnet:%s " $t
    docker run --rm --entrypoint cat mcr.microsoft.com/dotnet/aspnet:$t /etc/os-release | grep PRETTY
done
aspnet:9.0 PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
aspnet:10.0 PRETTY_NAME="Ubuntu 24.04.5 LTS"

With .NET 10, tags that do not name an operating system point to Ubuntu 24.04, and no Debian image is published for .NET 10 (post 8). For application code it usually does not matter; what matters is the RUN apt-get install lines in the Dockerfile. Package names and versions are not the same across distributions: an apt-get install that works on Debian 12 may install another version on Ubuntu 24.04 or not find the package at all. Writing the operating system explicitly in the FROM line (aspnet:10.0-noble, aspnet:9.0-bookworm-slim) keeps the distribution from changing silently when you upgrade the base image and turns the change into a decision.

Engine 29: the containerd Image Store, the API Version and More

Docker Engine itself changed too. The containerd image store we saw in post 4 has been the default for new installations since Engine 29.0:

docker info | grep -A1 "Storage Driver"
 Storage Driver: overlayfs
  driver-type: io.containerd.snapshotter.v1

On older installations this line says Storage Driver: overlay2: Docker's own graph driver holds the images. Upgrading to Engine 29 does not move an existing installation to the containerd store automatically; according to Docker's announcement, graph drivers are deprecated but still usable. The switch is made deliberately, and images in the old store are not visible in the new one; they have to be pulled or built again. This is why the docker images output and the disk usage (the DISK USAGE / CONTENT SIZE difference from post 4) differ between new and old servers.

The second change is the API version. The docker CLI and every tool that talks to Docker (CI plugins, monitoring agents, old Testcontainers versions) sends requests with an API version. Engine 29.0 raised the lowest API version it supports to 1.44 (Docker 25), and old tools broke with a "client version ... is too old" error; Docker's announcement describes relaxing this temporarily with the DOCKER_MIN_API_VERSION environment variable or "min-api-version" in daemon.json. 29.3.0 lowered the default back to 1.40 (Docker 19.03). On Engine 29.9.3 we imitated an old API version with DOCKER_API_VERSION:

DOCKER_API_VERSION=1.39 docker version
DOCKER_API_VERSION=1.43 docker version --format "server {{.Server.Version}} api {{.Server.APIVersion}} min {{.Server.MinAPIVersion}}"
Error response from daemon: client version 1.39 is too old. Minimum supported API version is 1.40, please upgrade your client to a newer version
server 29.9.3 api 1.56 min 1.40

An old tool giving this error on an Engine 29 server is a sign that the tool needs updating; lowering the minimum version on the server is a transition measure, not a permanent fix.

Other Engine 29 items you may run into when reading old setups, according to the release notes: Docker Content Trust (verifying image signatures with DOCKER_CONTENT_TRUST=1) was removed from the CLI; today's ways of signing images are in the DevSecOps series. cgroup v1 support is deprecated (it will last until at least May 2029); a server on a very old Linux distribution may warn because of this. The nftables backend we saw in post 12 is experimental. And Docker Compose: docker-compose (v1), written in Python, has had no support since June 2023; today's docker compose (no dash) is a CLI plugin, and the version: field in the compose file now only produces a warning. The details are in the Docker Compose series.

The .NET 11 SDK: Local Runtime Selection and Reproducible Images

.NET 11 (GA on November 10, 2026) brought two changes to producing containers without a Dockerfile (post 9). The first is a breaking change: when LocalRegistry is not set, the SDK used to look for Docker first and then Podman; in .NET 11 it first looks for WSL containers (wslc) on Windows and Apple's container tool on macOS, and if the tool's service is running, it hands the image to it. Docker and Podman are now the fallback. The practical consequence for readers on Windows: if WSL containers, which we covered in post 2, are installed, the image produced by dotnet publish -t:PublishContainer may go to wslc's store instead of Docker Desktop's and not show up in docker images. To pin the runtime, -p:LocalRegistry=Docker (the other values are Podman, Wslc and MacOSContainer) or the same property in a Directory.Build.props is enough. If the pinned runtime is missing, the SDK does not switch to another; asking for Wslc on Linux:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p:LocalRegistry=Wslc
error CONTAINER1012: The local registry is not available, but pushing to a local registry was requested.

The second is reproducible images. According to the .NET 11 SDK what's new page, setting SOURCE_DATE_EPOCH fixes the timestamps in the image, and the same source produces the same digest. We published Bookstore.Api four times with the .NET 11 SDK container: twice with the same epoch, twice without:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer \
    -p ContainerImageTags=repro-1 -p:SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
# ... repro-2 with the same command, plain-3 and plain-4 without SOURCE_DATE_EPOCH ...
docker image ls dockerseries-bookstore-api --format '{{.Tag}}  {{.ID}}' | grep -E "repro|plain" | sort
plain-3  89323bb93dfc
plain-4  0230283c92af
repro-1  06fd07f0544e
repro-2  06fd07f0544e

The two publishes with an epoch gave the same image ID (config digest), the two without gave different ones. Taking the epoch from the time of the last commit means every build from the same commit produces the same digest: you can verify that an image really came from that commit, and unnecessary pushes are avoided (the SDK does not push again if the same manifest is already in the registry). Bookstore.Api still targets net10.0; these features depend on the version of the SDK, that is, of the build environment.

A Checklist: the 2021 Dockerfile Line by Line to 2026

20212026Why
FROM .../dotnet/core/sdk:3.1FROM --platform=$BUILDPLATFORM .../dotnet/sdk:${DOTNET_VERSION}New image path, a supported SDK, multi-architecture builds without QEMU (posts 6 and 7)
MAINTAINER ...LABEL org.opencontainers.image.*Deprecated; OCI labels are read by tools
COPY . . + RUN dotnet restorecsproj first, restore with a cache mount, then the sourceNot repeating the restore on every code change (post 6)
FROM .../dotnet/core/aspnet:3.1aspnet:${DOTNET_VERSION}-noble-chiseled-extraNo shell, non-root, with Turkish culture and time zone data (post 8)
ENV ASPNETCORE_URLS http://+:80Line removed; the image's ASPNETCORE_HTTP_PORTS=8080The image's default is 8080; a safe port for a non-root user everywhere
EXPOSE 80EXPOSE 8080The documentation states the real port
(none)USER $APP_UIDThe User field is empty in Ubuntu images; not falling back to root silently
(none)HEALTHCHECK ... CMD ["dotnet", "Bookstore.Api.dll", "healthcheck"]A health check in a shell-less image (post 8; its cost in post 14)
ENTRYPOINT dotnet Bookstore.Api.dllENTRYPOINT ["dotnet", "Bookstore.Api.dll"]Exec form; SIGTERM reaches the app (post 3)

The result is the file that has been in src/Bookstore.Api/Dockerfile since post 8. The answer to the count in Guess First: 9 of the 12 instructions change or are removed; only the two WORKDIR lines and COPY --from=build stay the same, and new lines such as ARG, LABEL, USER and HEALTHCHECK are added. There is work outside the Dockerfile too: .dockerignore (post 5), the BuildKit cache and Bake (post 6), referencing the image by digest instead of tag and keeping it current (posts 4 and 13), and carrying the port and user change over to the orchestrator.

In Production

Do the migration in layers, not in one go. First modernize the Dockerfile while staying on the same .NET version (BuildKit, exec form, labels) and confirm that the behavior has not changed; then change the port and the user and deploy them together with the orchestrator settings; upgrade the .NET version and the base image family last. If you put all three changes into the same deploy, you will not know which one broke something.

Check old servers with your own eyes. docker version (client and server version, minimum API), docker info (storage driver, cgroup version, firewall backend) and docker compose version tell you within seconds which era a server is stuck in. As in post 2, remove the lines that identify the machine from these outputs before sharing them.

Do not leave old images in the registry, but find out who uses them before deleting. .NET Core 3.1 and .NET 6 images no longer get security patches; images derived from them will look redder every month in the scan from post 13.

Common Pitfalls

Upgrading the base image and touching nothing else. Turning 3.1 into 10.0 looks like a one-line change; but the port, the user, the operating system and the package names change at the same time. Check every row of the table separately.

Forgetting DOCKER_BUILDKIT=0 in CI. Added years ago to work around a problem, this variable silently keeps the pipeline on the legacy builder: cache mounts, build secrets and parallel stages do not work, and no build check runs. It is the first line to look for in old pipelines.

Moving to the .NET 11 SDK without checking where the image goes. If wslc is installed on the build machine, the image does not show up in Docker, and the next docker push step breaks with "image not found". In CI, pin LocalRegistry or send the image directly to a registry (ContainerRegistry).

Not reading an old blog post's command with today's behavior. docker volume prune (post 11), docker-compose, --link (post 12), MAINTAINER: all of them either changed behavior or are deprecated. Before copying a command, look at the post's date and at Docker's current documentation.

A Small Experiment

★☆☆ Modernize labs/15-legacy/Dockerfile.2021 yourself with the steps in the table and see that docker build --check gives no warnings. Compare the result with src/Bookstore.Api/Dockerfile.

★★☆ Start Bookstore.Api with -e ASPNETCORE_HTTP_PORTS=80 and read /proc/sys/net/ipv4/ip_unprivileged_port_start with a helper container (post 14). What is the value? Repeat the experiment on rootless Docker (post 2) or on Podman; does the result change?

★★★ Publish Bookstore.Api with the .NET 11 SDK and -p:SOURCE_DATE_EPOCH on two different machines (or one on Windows and one in WSL) and compare the digests. Are they the same? If not, which file makes the difference?

With this post the Docker series ends. Starting from a container being a process, we turned the three parts of Bookstore (Bookstore.Api, bookstore-web and SQL Server) into images, connected them with volumes and networks, ran them securely and learned to read them when they break. Defining all of this in a single file instead of setting it up by hand with docker run commands every time, starting the pieces in order and managing them together is the topic of the Docker Compose series; running the same images on a cluster is the topic of the published Kubernetes series.