Docker Desktop users: Right-click the Docker Desktop tray icon →
“Switch to Windows containers…” before building.
Windows Server runs Windows containers natively and does not need this step.
1 Create project & install
Create a new ASP.NET web project and add CobaltPDF from NuGet.
We use dotnet new web because the service needs to listen for HTTP requests.
Terminal
dotnet new web -n MyCobaltApp --framework net8.0
cd MyCobaltApp
dotnet add package CobaltPDF
Terminal
dotnet new web -n MyCobaltApp --framework net10.0
cd MyCobaltApp
dotnet add package CobaltPDF
No .csproj changes needed. CobaltPDF automatically configures the
runtime identifiers and publish settings your project needs via a transitive
MSBuild props file. Just install the NuGet package and proceed to the next step.
Terminal
dotnet new web -n MyCobaltApp --framework net8.0
cd MyCobaltApp
dotnet add package CobaltPDF
Terminal
dotnet new web -n MyCobaltApp --framework net10.0
cd MyCobaltApp
dotnet add package CobaltPDF
2 Where to put each file
Every file goes in the project root — the same folder as your .csproj.
When you are done, your folder should look exactly like this:
Project folder
MyCobaltApp/
├── MyCobaltApp.csproj <-- your project file├── Program.cs <-- your code├── Dockerfile <-- Docker build instructions├── docker-compose.yml <-- container config└── .dockerignore <-- excludes bin/obj from build
Tip: The Dockerfile, docker-compose.yml, and
.dockerignore must be next to your .csproj —
not in a subfolder, not in the solution root (unless your .csproj is there too).
3 Add a .dockerignore
Prevents your local bin/ and obj/ from leaking into the container.
Especially important on Windows, where cached paths break the Linux build.
.dockerignore
bin/
obj/
.vs/
*.user
4 Create a Dockerfile
Save this as Dockerfile (no extension) next to your .csproj.
Do not use -r linux-x64 or --self-contained in
your build command. Both flatten the runtimes/ folder and break Chromium
path resolution.
Dockerfile
# ── Build stage ────────────────────────────────────────# Uses the .NET 8 SDK to restore and compile your project.# Change "MyCobaltApp.csproj" to match your own .csproj filename.FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY MyCobaltApp.csproj ./
RUN dotnet restore
COPY . .
# IMPORTANT: do NOT add -r linux-x64 or --self-contained here.# Both flatten the runtimes/ folder and break Chromium path resolution.RUN dotnet publish -c Release --no-restore -o /app/publish
# ── Runtime stage ─────────────────────────────────────# Uses the smaller ASP.NET 8 runtime image (Debian Bookworm).# Installs the native libraries that Chromium needs to run headless.FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
libasound2 libxshmfence1 libx11-xcb1 \
libxfixes3 libxss1 libxtst6 \
fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/publish .
# Ensure the Chromium binary is executable (NuGet does not preserve Unix permissions).RUN chmod +x ./runtimes/linux-x64/native/chrome 2>/dev/null || true
# Change "MyCobaltApp.dll" to match your own project name.ENTRYPOINT ["dotnet", "MyCobaltApp.dll"]
# ── Build stage ────────────────────────────────────────# Uses the .NET 10 SDK to restore and compile your project.# Change "MyCobaltApp.csproj" to match your own .csproj filename.FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY MyCobaltApp.csproj ./
RUN dotnet restore
COPY . .
# IMPORTANT: do NOT add -r linux-x64 or --self-contained here.# Both flatten the runtimes/ folder and break Chromium path resolution.RUN dotnet publish -c Release --no-restore -o /app/publish
# ── Runtime stage ─────────────────────────────────────# .NET 10 dropped Debian images — uses Ubuntu 24.04 "Noble" instead.# Some library names changed with a t64 suffix (e.g. libasound2 → libasound2t64).FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
libasound2t64 libxshmfence1 libx11-xcb1 \
libxfixes3 libxss1 libxtst6 \
fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/publish .
# Ensure the Chromium binary is executable (NuGet does not preserve Unix permissions).RUN chmod +x ./runtimes/linux-x64/native/chrome 2>/dev/null || true
# Change "MyCobaltApp.dll" to match your own project name.ENTRYPOINT ["dotnet", "MyCobaltApp.dll"]
Why is this different? .NET 10 dropped Debian images entirely.
The runtime uses aspnet:10.0-noble (Ubuntu 24.04), and several library
packages were renamed with a t64 suffix (e.g. libasound2
became libasound2t64).
Simpler setup: Windows containers run Chromium natively —
no apt-get or library installs needed.
Dockerfile
# ── Build stage ────────────────────────────────────────# Uses the .NET 8 SDK to restore and compile your project.# Change "MyCobaltApp.csproj" to match your own .csproj filename.FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS build
WORKDIR /src
COPY MyCobaltApp.csproj ./
RUN dotnet restore
COPY . .
# IMPORTANT: do NOT add --self-contained here.RUN dotnet publish -c Release --no-restore -o /app/publish
# ── Grab ASP.NET runtime binaries ────────────────────FROM mcr.microsoft.com/dotnet/aspnet:8.0-windowsservercore-ltsc2022 AS aspnet
# ── Runtime stage ─────────────────────────────────────# Uses the FULL Windows Server image (not Server Core) because# headless Chromium requires Media Foundation and other DLLs# that Microsoft removed from Server Core.FROM mcr.microsoft.com/windows/server:ltsc2022 AS runtime
# Copy the ASP.NET runtime from the official image.COPY --from=aspnet ["C:/Program Files/dotnet", "C:/Program Files/dotnet"]
RUN setx /M PATH "C:\Program Files\dotnet;%PATH%"ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV ASPNETCORE_HTTP_PORTS=8080
# Chromium requires the Visual C++ Redistributable.ADD https://aka.ms/vs/17/release/vc_redist.x64.exe /temp/vc_redist.x64.exe
RUN C:\temp\vc_redist.x64.exe /install /quiet /norestart
RUN del C:\temp\vc_redist.x64.exe
WORKDIR /app
COPY --from=build /app/publish .
# Change "MyCobaltApp.dll" to match your own project name.ENTRYPOINT ["dotnet", "MyCobaltApp.dll"]
# ── Build stage ────────────────────────────────────────# Uses the .NET 10 SDK to restore and compile your project.# Change "MyCobaltApp.csproj" to match your own .csproj filename.FROM mcr.microsoft.com/dotnet/sdk:10.0-windowsservercore-ltsc2025 AS build
WORKDIR /src
COPY MyCobaltApp.csproj ./
RUN dotnet restore
COPY . .
# IMPORTANT: do NOT add --self-contained here.RUN dotnet publish -c Release --no-restore -o /app/publish
# ── Grab ASP.NET runtime binaries ────────────────────FROM mcr.microsoft.com/dotnet/aspnet:10.0-windowsservercore-ltsc2025 AS aspnet
# ── Runtime stage ─────────────────────────────────────# Uses the FULL Windows Server image (not Server Core) because# headless Chromium requires Media Foundation and other DLLs# that Microsoft removed from Server Core.FROM mcr.microsoft.com/windows/server:ltsc2025 AS runtime
# Copy the ASP.NET runtime from the official image.COPY --from=aspnet ["C:/Program Files/dotnet", "C:/Program Files/dotnet"]
RUN setx /M PATH "C:\Program Files\dotnet;%PATH%"ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV ASPNETCORE_HTTP_PORTS=8080
# Chromium requires the Visual C++ Redistributable.ADD https://aka.ms/vs/17/release/vc_redist.x64.exe /temp/vc_redist.x64.exe
RUN C:\temp\vc_redist.x64.exe /install /quiet /norestart
RUN del C:\temp\vc_redist.x64.exe
WORKDIR /app
COPY --from=build /app/publish .
# Change "MyCobaltApp.dll" to match your own project name.ENTRYPOINT ["dotnet", "MyCobaltApp.dll"]
Chromium needs more than Docker's default 64 MB of shared memory. Set shm_size
to prevent crashes.
docker-compose.yml
services:
cobalt-app:
build:
context: .dockerfile: Dockerfile # Chromium needs more than Docker's default 64 MB of shared memory. # Without this, the browser will crash on larger pages.shm_size: "1gb" # Maps host port 5000 → container port 8080 (ASP.NET default). # Change 5000 to any free port on your machine.ports:
- "5000:8080"
Windows containers don't need the shm_size override that Linux requires.
docker-compose.yml
services:
cobalt-app:
build:
context: .dockerfile: Dockerfile# Maps host port 5000 → container port 8080 (ASP.NET default).# Change 5000 to any free port on your machine.ports:
- "5000:8080"
6 Write your Program.cs
Replace the default Program.cs with the code below. This creates a web server
with two endpoints: a health check at / and a PDF generator at /api/pdf
that renders google.com as a PDF.
Program.cs
using CobaltPdf;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ =>
{
CobaltEngine.Configure(o =>
{
CloudEnvironment.ConfigureForDocker(o);
o.MaxSize = 2; // max concurrent browser instances
});
return newCobaltEngine();
});
var app = builder.Build();
// Health check — verify the service is running
app.MapGet("/", () => "CobaltPDF is running");
// Render google.com as a PDF and return the file
app.MapGet("/api/pdf", async (CobaltEngine engine, CancellationToken ct) =>
{
var pdf = await engine.RenderUrlAsPdfAsync(
"https://www.google.com", ct);
returnResults.File(pdf.BinaryData, "application/pdf", "output.pdf");
});
app.Run();
7 Build & run
These commands work identically on Windows (PowerShell / CMD), macOS, and Linux.
Run them from the folder that contains your docker-compose.yml.
Docker must be running before you execute any docker commands.
On Windows and macOS, open Docker Desktop and wait for the engine to start
(the whale icon in the system tray should be steady, not animating).
On Linux, ensure the Docker daemon is active with sudo systemctl start docker.
Terminal
# Build the image and start the container in the background
docker compose up --build -d
The -d flag runs the container in detached mode so your terminal stays free.
The first build takes a few minutes to download images and NuGet packages. Subsequent
builds use Docker's layer cache and are much faster.
Stop the container with docker compose down.
View logs with docker compose logs -f.
8 Test your service
Your service exposes two endpoints. Once the container is running you can test them immediately.
Health check
Open your browser and go to
http://localhost:5000.
You should see the text "CobaltPDF is running".
# PowerShell aliases "curl" to Invoke-WebRequest — use curl.exe instead
curl.exe http://localhost:5000/api/pdf -o google.pdf
Windows users: PowerShell aliases curl to Invoke-WebRequest,
which has completely different syntax. Always use curl.exe (with the .exe) to
call the real curl.
Fast-track your API with CobaltPdf.Requests
The CobaltPdf.Requests NuGet package is a lightweight (~50 KB) set of
serializable request/response models. Install it on both the Docker service
and any client apps that call it. Clients never need the full CobaltPDF engine.
Server: add the POST endpoint
Install CobaltPdf.Requests on your Docker project (the one with CobaltPDF):
Terminal — Docker project
dotnet add package CobaltPdf.Requests
Then add these endpoints to your Program.cs (below the existing MapGet routes):
Program.cs — add to Docker project
using CobaltPdf.Requests;
// POST /api/pdf → raw PDF file
app.MapPost("/api/pdf", async (PdfRequest request, CobaltEngine renderer, CancellationToken ct) =>
{
var pdf = await request.ExecuteAsync(renderer, ct);
returnResults.File(pdf.BinaryData, "application/pdf", "output.pdf");
});
// POST /api/pdf/json → PdfResponse JSON (for service-to-service calls)
app.MapPost("/api/pdf/json", async (PdfRequest request, CobaltEngine renderer, CancellationToken ct) =>
{
var pdf = await request.ExecuteAsync(renderer, ct);
returnResults.Ok(PdfResponse.FromBytes(pdf.BinaryData));
});
Two endpoints:/api/pdf returns a raw PDF file (great for browsers and direct downloads).
/api/pdf/json returns a PdfResponse JSON object (ideal for service-to-service calls).
Client: call the service
Install CobaltPdf.Requests on your client project — no need for the full CobaltPDF engine:
Terminal — client project
dotnet add package CobaltPdf.Requests
Then call your Docker-hosted PDF service from any .NET app:
Client example
using System.Net.Http.Json;
using CobaltPdf.Requests;
var client = newHttpClient { BaseAddress = newUri("http://localhost:5000") };
var request = newPdfRequest
{
Url = "https://www.google.com",
PaperFormat = "A4",
Landscape = false
};
var response = await client.PostAsJsonAsync("/api/pdf/json", request);
var result = await response.Content.ReadFromJsonAsync<PdfResponse>();
File.WriteAllBytes("google.pdf", result!.ToBytes());
For the full PdfRequest API (margins, watermarks, headers/footers, wait strategies, and more),
see the CobaltPdf.Requests documentation.
Deploy to Azure
Push your Docker image to Azure Container Registry (ACR) and run it on Azure Container Apps.
1. Create a resource group & container registry
Azure CLI
# Log in to Azure
az login
# Create a resource group (change name and location to suit)
az group create --name cobalt-rg --location eastus
# Create a container registry (name must be globally unique)
az acr create --resource-group cobalt-rg --name mycobaltacr --sku Basic
az acr login --name mycobaltacr
2. Tag & push your image
Terminal
# Build the image (if you haven't already)
docker compose build
# Tag it for your registry
docker tag mycobaltapp-cobalt-app mycobaltacr.azurecr.io/cobalt-app:latest
# Push to ACR
docker push mycobaltacr.azurecr.io/cobalt-app:latest
After deployment, Azure prints your app's public URL. Visit https://<your-app>.<region>.azurecontainerapps.io/api/pdf
to generate a PDF.
Resources: Chromium is memory-hungry. Use at least 2Gi of memory.
For production workloads, consider 4Gi.
Deploy to AWS
Push your Docker image to Amazon ECR and run it on AWS Fargate (ECS).
1. Create an ECR repository & push
AWS CLI
# Set your AWS region and account ID
AWS_REGION=us-east-1
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Create an ECR repository
aws ecr create-repository --repository-name cobalt-app --region $AWS_REGION
# Log Docker into ECR
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
# Tag and push
docker tag mycobaltapp-cobalt-app $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/cobalt-app:latest
docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/cobalt-app:latest
2. Deploy with ECS Fargate
The fastest way is with the AWS Copilot CLI, which creates the ECS cluster,
Fargate task, load balancer, and networking for you:
Terminal
# Install AWS Copilot (if you haven't already)# https://aws.github.io/copilot-cli/docs/getting-started/install/# Initialize and deploy (follow the interactive prompts)
copilot init \
--app cobalt \
--name cobalt-app \
--type "Load Balanced Web Service" \
--dockerfile ./Dockerfile \
--port 8080 \
--deploy
Copilot will output your public URL when done. Visit /api/pdf to test.
Task sizing: Use at least 1 vCPU / 2 GB for the Fargate task.
Chromium needs memory. For production, use 2 vCPU / 4 GB.
Set SHM_SIZE via copilot svc override if you see browser crashes.
Troubleshooting
Chromium executable path not found
This means Chromium.Path returned null at runtime.
The resolver iterates the runtime graph from your app’s .deps.json
to probe runtimes/{rid}/native/chrome. If the graph is empty or the
binary is missing, the path cannot be resolved.
Check your CobaltPDF version. Version 1.2.0+ automatically sets
RuntimeIdentifiers and ErrorOnDuplicatePublishOutputFiles
via a transitive MSBuild props file — no manual .csproj changes needed.
If you are on an older version, add
<RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers> and
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
to your <PropertyGroup>.
Remove -r linux-x64 and --self-contained from your Dockerfile build command. Both flatten the runtimes/ folder structure that the resolver requires.
Use dotnet publish -c Release -o /app/publish (no -r flag).
Windows containers: Use the full mcr.microsoft.com/windows/server image (not windowsservercore or nanoserver). Server Core is missing Media Foundation and other DLLs that Chromium requires. The Dockerfile above copies the ASP.NET runtime from the official image into the full Windows Server base.
apt-get exit code 100 (Linux — .NET 10)
.NET 10 dropped Debian images. Use aspnet:10.0-noble (Ubuntu 24.04) and note
that several packages were renamed with a t64 suffix. Use the
.NET 10 Dockerfile above for the correct package names.
Browser crashes (Linux)
Add shm_size: "1gb" to docker-compose.yml,
or --shm-size=1g with docker run.
This is a Linux-specific requirement — Windows containers do not need it.
Missing fonts
The Linux Dockerfile installs common fonts. For CJK support, add
fonts-noto-cjk to the apt-get install line.
Windows containers include system fonts by default.
PowerShell curl errors
PowerShell aliases curl to Invoke-WebRequest. Use
curl.exe (with the .exe extension) to call real curl on Windows.