/bin/sh: 1: curl: not found in a container's health log, with (unhealthy) in docker ps, means the HEALTHCHECK command calls a program the image does not contain. The mcr.microsoft.com/dotnet/aspnet:10.0 image has no curl, so the check failed while the app itself answered Hello World! on its published port. Install curl in the image, or write a probe that does not need it.

In the test, one apt-get install line in the runtime stage turned the same container from (unhealthy) into (healthy) with no change to the HEALTHCHECK line itself.

The error

PS> docker ps --filter name=fix10-dc-h --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
NAMES         IMAGE                  STATUS
fix10-dc-h2   fix10-dc-health:good   Up 20 seconds (healthy)
fix10-dc-h1   fix10-dc-health:bad    Up 21 seconds (unhealthy)

PS> docker inspect --format "{{json .State.Health}}" fix10-dc-h1
{"Status":"unhealthy","FailingStreak":4,"Log":[{"Start":"2026-11-13T05:21:31.549031859Z","End":"2026-11-13T05:21:31.588035043Z","ExitCode":1,"Output":"/bin/sh: 1: curl: not found\n"},...

The HEALTHCHECK in the broken image:

HEALTHCHECK --interval=5s --timeout=3s --retries=3 CMD curl -f http://localhost:8080/ || exit 1

Why it happens

Docker stored the check as ["CMD-SHELL","curl -f http://localhost:8080/ || exit 1"] (docker inspect --format "{{json .Config.Healthcheck}}"), so it runs through /bin/sh inside the container. The shell finds no curl, and the || exit 1 turns that into exit code 1. The captures show the final state, not the moment it changed: "Status":"unhealthy" with "FailingStreak":4 and the same output in every log entry, and the stored check with "Retries":3.

The aspnet:10.0 image tested here is Ubuntu 24.04.5 LTS (its /etc/os-release) with the .NET runtime and without curl. The health log is the place where the missing program shows up. In a second run of the same broken image, published on 127.0.0.1:15937, the app logged normal startup lines and answered the request from Windows, while docker ps said (unhealthy):

> docker logs fix10-dc-h3
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:8080
...
> curl.exe -sS http://127.0.0.1:15937/
Hello World!
...
fix10-dc-h3   fix10-dc-health:bad    Up 26 seconds (unhealthy)

The fix

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
RUN dotnet new web -n FixLab -o . && dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app .
HEALTHCHECK --interval=5s --timeout=3s --retries=3 CMD curl -f http://localhost:8080/ || exit 1
ENTRYPOINT ["dotnet", "FixLab.dll"]
PS> docker inspect --format "{{.State.Health.Status}} {{(index .State.Health.Log 0).ExitCode}} {{(index .State.Health.Log 0).Output}}" fix10-dc-h2
healthy 0   % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
...
Hello World!

The RUN line goes in the final stage, the one the container runs, not in the SDK build stage. The image sets no USER (the container ran as uid=0(root)), so apt-get works there; put the line above any USER $APP_UID line of your own. The check itself can keep localhost: it runs inside the container, next to Kestrel.

The trade-off is a larger image and one more package to keep patched. If you would rather not add curl, the alternative is a probe built into your own app, for example a small console command in the image that calls the health endpoint; that was not part of this test.

How it was reproduced

A two-stage Dockerfile ran dotnet new web and dotnet publish on mcr.microsoft.com/dotnet/sdk:10.0 and copied the output into mcr.microsoft.com/dotnet/aspnet:10.0 (runtime 10.0.13), with the HEALTHCHECK above. The fixed image added the apt-get line and nothing else. Both containers ran side by side on Docker 29.9.3 on Windows; the container and image names are the test's own.

Frequently asked

Does the ASP.NET Core Docker image include curl?
No. In the test, a HEALTHCHECK calling curl on mcr.microsoft.com/dotnet/aspnet:10.0 failed with /bin/sh: 1: curl: not found until curl was installed with apt-get in the final stage.
How do I see why a Docker container is unhealthy?
Run docker inspect --format "{{json .State.Health}}" with the container name. The Log array holds the exit code and output of the last health checks. In the test docker logs showed only the app's startup lines and nothing about the failing check.
Should the HEALTHCHECK use localhost or the published port?
localhost and the container port. The check runs inside the container, so in the test curl -f http://localhost:8080/ answered Hello World! with no port published at all.

More decoded errors in the Fixes category. When Compose waits on this check with service_healthy, the same failure surfaces as dependency failed to start: container is unhealthy.