Using Playwright with .NET API and Docker
playwright dotnet docker

It is possible to include Playwright in a .NET API and also to bake it into a Docker image.
October 31, 2023

In my ePaper project I needed to generate a screenshot of a rendered HTML page and quickly realized that the easiest method would be to use https://playwright.dev. In this post I'm going to note how I achieved the following:

  1. Use Playwright from a .NET API to generate webpage screenshot.
  2. Bake Playwright into a Docker image, alongside the main application.

Using Playwright from .NET

It is possible to run Playwright from .NET code using eiter special libraries for unit testing, or direcly with Microsoft.Playwright. That's the NuGet package I used.

My C# code then generates an HTML file, Playwright renders it in headless Firefox (using file:// to get local file's URL) and captures a screenshot.

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Firefox.LaunchAsync(new() {
    Headless = true,
    // for experiments with various browser settings:
    //FirefoxUserPrefs = new Dictionary<string, object>() {
    //    { "gfx.font_rendering.cleartype_params.rendering_mode", 1 }
    //}
});

var page = await browser.NewPageAsync(new() {
    ViewportSize = new() { Height = 300, Width = 400 }
});
            
await page.GotoAsync($"file://{dataDir}/rendered-template.html");
var imageBytes = await page.ScreenshotAsync(new() { Path = Path.Combine(dataDir, $"screenshot.png") });
// ...do something with the image

If you run this for the first time, you'll get informed that dependencies need to be installed first - see the next section, because you have run the installation for local debugging as well as for the Docker image.

Docker image

I started with the default Dockerfile that Visual Studio gave me when new project was created and then tried to figure out how to bake Playwright into it.

Playwright is initialized by calling playwright.ps1 install --with-deps firefox. This PowerShell script is a wrapper for the actual installation functionality that comes from the NuGet package. And because dependencies may change, it's recommended to use this method instead of figuring out what are the required libraries and pre-installing them on the host (or in the image) manually.

The caveat

Unfortunately, that means that in order to install the necessary dependencies along with the Firefox browser, the app itself needs to be built first. Which adds unnecessary build time, because the app build Docker layer will change every time, therefore the browser and libraries will be installed every time as well.

# This works, but is quite inefficient.
# (Base stage is the mcr.microsoft.com/dotnet/aspnet image.)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN pwsh /app/playwright.ps1 install --with-deps firefox

The solution

There's a solution for that, of course. I got "inspired" by the official Playwright Dockerfile and modified my base stage to create an empty .NET console app, add just the Playwright NuGet package, build, and finally run the installation PowerShell script. Then delete the app.

Considerations:

Code:

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS base
WORKDIR /app

# ENV configuration is important, because we're not using the production aspnet image anymore.
ENV ASPNETCORE_URLS=http://+:80
ENV ASPNETCORE_ENVIRONMENT=Production
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright

COPY ./MyProject.Api/MyProject.Api.csproj /tmp/

RUN mkdir /ms-playwright && \
    mkdir /ms-playwright-agent && \
    cd /ms-playwright-agent && \
    dotnet new console && \
    echo '<?xml version="1.0" encoding="utf-8"?><configuration><packageSources><add key="local" value="/tmp/"/></packageSources></configuration>' > nuget.config && \
    pwversion=$(cat /tmp/MyProject.Api.csproj | grep -oPm1 "(?<=Include=\"Microsoft.Playwright\" Version=\")[^\"]+") && \
    dotnet add package Microsoft.Playwright -v $pwversion && \
    dotnet build && \
    ./bin/Debug/net6.0/playwright.ps1 install --with-deps firefox && \
    rm -rf /var/lib/apt/lists/* && \
    rm -rf /tmp/* && \
    dotnet nuget locals all --clear && rm -rf /root/.nuget && \
    rm -rf /ms-playwright-agent && \
    chmod -R 777 /ms-playwright
EXPOSE 80

# All that follows is from the default Visual Studio Dockerfile.
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["MyProject.Api/MyProject.Api.csproj", "MyProject.Api/"]
RUN dotnet restore "MyProject.Api/MyProject.Api.csproj"
COPY . .
WORKDIR "/src/MyProject.Api"
RUN dotnet build "MyProject.Api.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyProject.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .

ENTRYPOINT ["dotnet", "MyProject.Api.dll"]

I'm using Bash to get the right NuGet version from the app's CSPROJ. It's a nice piece of magic that might come handy sometime again :)

pwversion=$(cat /tmp/MyProject.Api.csproj | grep -oPm1 "(?<=Include=\"Microsoft.Playwright\" Version=\")[^\"]+")
dotnet add package Microsoft.Playwright -v $pwversion

Found something inaccurate or plain wrong? Was this content helpful to you? Let me know!

📧 codez@deedx.cz