ASP.NET Core on a Container Host: The Dockerfile You Bring, and What Runs in 512 MB
ASP.NET Core is a good fit for a small container: the runtime is container-aware, a minimal API idles in well under 100 MB, and Microsoft publishes multi-architecture images that just work. What a container host will not do for you is write the Dockerfile. SnapDeploy generates Dockerfiles for Node.js, Python, Java, Go, PHP, Ruby and static sites, and .NET is not on that list, so this guide starts with the file you bring. It is twelve lines, and it is better than a generated one anyway.
Tested with .NET 10, the current long-term-support release (supported November 2025 to November 2028). .NET 8 and .NET 9 both reach end of support on 10 November 2026, so a new project should start on 10.
The Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080 ASPNETCORE_ENVIRONMENT=Production DOTNET_EnableDiagnostics=0
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]- Restoring before copying the source keeps the package layer cached between builds when only code changed.
- The
aspnetruntime image is the small one;sdkis for the build stage only. The Alpine variants are roughly 110 MB for a minimal API, about half the Debian-based ones. - Microsoft's images ship for both x86 and ARM, so the build works unchanged on a host that builds ARM images, which SnapDeploy does.
- The
appuser exists in the official images since .NET 8; running as it costs nothing. DOTNET_EnableDiagnostics=0turns off the debugger and profiler hooks, which a production container does not need.
Ports, hosts and health
The official images listen on port 8080 by default since .NET 8; the ASPNETCORE_URLS line above makes that explicit and binds all interfaces, which is what a container needs (binding to localhost is the classic "build succeeded, nothing answers" mistake). If the platform sets PORT, honour it in Program.cs rather than guessing:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health");
app.MapGet("/api/time", () => new { now = DateTime.UtcNow });
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
app.Run($"http://0.0.0.0:{port}");Behind the platform's proxy, add app.UseForwardedHeaders() with ForwardedHeaders.XForwardedFor | XForwardedProto so redirects and Request.Scheme see HTTPS. Without it, an app that forces HTTPS redirects loops.
Memory on 512 MB
The .NET garbage collector reads the container's memory limit and sizes its heap accordingly, and on fewer than two cores it uses the workstation collector automatically, so there is nothing to tune for a quarter vCPU. A minimal API idles at 40 to 80 MB; an app with EF Core, authentication and a few controllers sits around 120 to 200 MB after warm-up. Both are comfortable in 512 MB. If you see the container killed with exit code 137, look for an unbounded in-memory cache or a large response being buffered, not at the runtime. Start-up on a quarter vCPU is a few seconds for a minimal API and 10 to 20 seconds with EF Core migrations, well inside the roughly one-minute wake a free container takes after sleeping.
A database, and migrations at start
Npgsql for PostgreSQL, Pomelo for MySQL, both with EF Core. Read the connection string from DATABASE_URL, which a SnapDeploy database add-on injects and every provider gives you to paste. EF Core wants the key-value form, so convert the URL once at start:
var url = new Uri(Environment.GetEnvironmentVariable("DATABASE_URL")!);
var userInfo = url.UserInfo.Split(':');
var conn = $"Host={url.Host};Port={url.Port};Database={url.AbsolutePath.TrimStart('/')};Username={userInfo[0]};Password={userInfo[1]};SSL Mode=Require;Maximum Pool Size=5";
builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(conn));
// after Build():
using (var scope = app.Services.CreateScope())
scope.ServiceProvider.GetRequiredService<AppDb>().Database.Migrate();Maximum Pool Size=5 keeps one container far inside a PostgreSQL Mini's 50 connections. Running Migrate() at start is safe with a single container and keeps schema and code in the same deploy. Options for the database itself: PostgreSQL or MySQL Mini ($29/month, connection string injected), a $1 DB Sprint Pack for 12 hours, or a free external plan such as Neon.
Deploying
- Commit the Dockerfile and a
.dockerignorewithbin/,obj/and.git. - Push. Containers → Deploy from GitHub → pick the repository. The port is read from
EXPOSE(8080). - Add environment variables:
DATABASE_URL(or attach an add-on), any API keys, andASPNETCORE_ENVIRONMENT=Productionif it is not in the Dockerfile. - Deploy. The first build takes a few minutes (SDK image pull plus
dotnet restore); later builds reuse the restore layer. Open/healthon the container URL.
Test the image before you push
docker build -t api .
docker run --rm -p 8080:8080 -e PORT=8080 api
curl -i http://localhost:8080/healthIf dotnet publish fails here it fails on the platform; if the container starts but the health check is refused, the app bound to localhost. Both cost a deploy from the daily allowance when found on the platform and nothing when found here.
Where free stops
- Free containers sleep after 15 minutes without traffic and wake in about a minute; hosted services and timers inside the app only run while it is awake.
- 100 container-hours a month across four containers, counted while awake.
- No free database; custom domains and no sleeping need Always-On ($12/month per container).
- Data Protection keys, if you use cookies or antiforgery, must be persisted outside the container (in the database, via
PersistKeysToDbContext), or every restart signs everyone out.
Configuration the .NET way
ASP.NET Core's configuration reads environment variables automatically, with a double underscore standing in for the colon of a nested key. So a setting the code reads as Configuration["ConnectionStrings:Default"] or Configuration["Smtp:Host"] is set in the container as ConnectionStrings__Default and Smtp__Host, with no code change and no appsettings.Production.json committed with secrets in it. Keep appsettings.json for non-secret defaults and let the container's environment override the rest; that split is what makes the same image work in staging and production.
Numbers used: .NET support dates from Microsoft's support policy (September 2026); SnapDeploy free tier from the free tier docs (4 containers, 512 MB, 0.25 vCPU, 100 container-hours a month, no card); database add-ons from the add-on pricing page.