A small, self-contained HTTP service that renders a complete HTML document to a PDF using headless Chrome.
POST some HTML, get PDF bytes back. That is the whole API.
POST /render { "html": "<html>...</html>" } -> application/pdf
GET /health -> 200 "ok"
Serverless .NET hosts increasingly ship minimal base images that cannot run a browser. Azure Functions Flex Consumption is the concrete example: a bundled Chromium fails at launch with
error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file
and Microsoft's position is that this is a platform limitation, not a configuration problem - you cannot install native OS libraries there. Rather than abandon the host or hand-assemble thirty shared libraries, move only the browser into a container you control and leave everything else where it is.
The service is deliberately dumb. It holds no templates, no database connection, no business logic and no customer data. The caller builds its own HTML and owns its own layout. That is what makes it safe to run as a shared, public image.
docker build -t htmltopdf .
docker run -d --name htmltopdf -p 8080:8080 \
-e Renderer__ApiKey=choose-a-long-random-string \
htmltopdf
curl -s -X POST http://localhost:8080/render \
-H "Content-Type: application/json" \
-H "X-Render-Key: choose-a-long-random-string" \
-d '{"html":"<html><body><h1>Hello</h1></body></html>"}' \
-o hello.pdfFor Renderer__ApiKey, you can create a secure random key from grc.com/passwords.
The service refuses to start without Renderer__ApiKey. That is intentional: a misconfigured deployment should fail
loudly rather than expose an anonymous headless browser to the internet.
Requires the X-Render-Key header to match Renderer__ApiKey. Compared in fixed time. Returns 401 otherwise.
Request body:
| Field | Type | Default | Notes |
|---|---|---|---|
html |
string | required | The complete HTML document, including any inline CSS |
marginInches |
number | 0.5 |
Uniform page margin |
printBackground |
bool | true |
Paint CSS background colours and images |
waitForResourcesMs |
int | 1000 |
Pause after load, letting fonts and allowlisted images settle |
Responses:
| Status | Meaning |
|---|---|
200 |
application/pdf body |
400 |
html was empty, or exceeded Renderer__MaxHtmlBytes |
401 |
Missing or incorrect X-Render-Key |
500 |
Chrome failed to launch or render - check container logs |
Example with all options set:
curl -s -X POST http://localhost:8080/render \
-H "Content-Type: application/json" \
-H "X-Render-Key: $RENDER_KEY" \
-d '{
"html": "<html><body><h1>Invoice</h1></body></html>",
"marginInches": 0.5,
"printBackground": true,
"waitForResourcesMs": 1000
}' \
-o invoice.pdfUnauthenticated, so container orchestrators can probe it. Returns 200 ok. Reports nothing about configuration.
Settings bind from the Renderer configuration section, which means environment variables using __ as the separator.
This is what you set in Azure Container Apps, Kubernetes, or docker run -e.
| Environment variable | Required | Default | Purpose |
|---|---|---|---|
Renderer__ApiKey |
Yes | none | Shared secret for X-Render-Key. The service will not start without it |
Renderer__AllowedResourceHosts |
No | empty | Comma-separated hosts the page may load sub-resources from |
Renderer__MaxHtmlBytes |
No | 5242880 |
Largest accepted HTML payload, in bytes |
Renderer__ExecutablePath |
No | set by the image | Path to the Chrome binary |
Read this before exposing the service.
A headless browser that renders submitted HTML is an SSRF engine by default. Any <img>, <link> or <script> in
the submitted markup makes the server fetch that URL - including cloud metadata endpoints such as 169.254.169.254,
and anything else reachable from inside your network.
This service therefore:
- Blocks every sub-resource by default. Only
data:URIs and HTTPS hosts named inRenderer__AllowedResourceHostsare fetched. Everything else is aborted and logged asBlocked sub-resource {Url}. - Requires a shared secret on every render, compared with
CryptographicOperations.FixedTimeEquals. - Does not disable web security. Chrome's same-origin policy stays on.
- Runs as a non-root user inside the container.
If your document loads a logo or webfont from your own domain, allowlist exactly that host:
Renderer__AllowedResourceHosts=www.example.com,fonts.example.com
Prefer embedding images as data: URIs where you can - it needs no allowlist, removes a network round trip, and makes
rendering deterministic.
--no-sandbox is passed to Chrome because its sandbox needs privileges a container does not grant. The container
boundary is the isolation here. Do not use this service to render HTML from sources you do not trust.
Container Apps scales to zero and has a perpetual monthly free grant, which suits bursty work such as invoice generation.
RG=my-resource-group
LOCATION=northeurope
ENV=htmltopdf-env
APP=htmltopdf
IMAGE=ghcr.io/reversepoco/htmltopdf:latest
RENDER_KEY=$(openssl rand -base64 32)
az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.OperationalInsights --wait
az containerapp env create \
--name $ENV --resource-group $RG --location $LOCATION
az containerapp create \
--name $APP \
--resource-group $RG \
--environment $ENV \
--image $IMAGE \
--target-port 8080 \
--ingress external \
--cpu 1 --memory 2Gi \
--min-replicas 0 --max-replicas 3 \
--secrets render-key="$RENDER_KEY" \
--env-vars Renderer__ApiKey=secretref:render-key \
Renderer__AllowedResourceHosts=www.example.com
az containerapp show --name $APP --resource-group $RG \
--query properties.configuration.ingress.fqdn -o tsvNotes:
- 1 vCPU / 2 GiB is the right starting size. Chrome is memory-hungry and 0.5 GiB will crash on non-trivial documents.
--min-replicas 0means you pay nothing while idle, at the cost of a cold start of a few seconds. Set it to1if a caller cannot tolerate that.- The API key is stored as a Container Apps secret and referenced with
secretref:, so it never appears in the environment variable list or inaz containerapp showoutput. - Ingress is
externalhere for simplicity. If the caller lives in the same Container Apps environment, use--ingress internaland drop the public surface entirely.
To update the running app to a new image:
az containerapp update --name $APP --resource-group $RG --image $IMAGEaz containerapp registry set \
--name $APP --resource-group $RG \
--server myregistry.azurecr.io \
--identity systemBoth pipelines below build the image, push it to GHCR, and roll the Container App onto the new tag. Each image is
tagged twice: latest, and an immutable build-specific tag so a deployment can be rolled back to a known image.
Nothing to configure. GITHUB_TOKEN is issued automatically and can publish to GHCR under the repository owner, so
there is no secret to create and no credential to rotate. Actions minutes are free on public repositories.
Save as .github/workflows/publish.yml:
name: build-and-publish
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/htmltopdf:latest
ghcr.io/${{ github.repository_owner }}/htmltopdf:${{ github.sha }}The first push creates the package as private. Make it public once, under Package settings -> Change visibility, otherwise Container Apps cannot pull it without credentials.
Azure Pipelines can build straight from a public GitHub repository. You need two service connections:
| Service connection | Type | Holds |
|---|---|---|
ghcr |
Docker Registry -> Others | Registry https://ghcr.io, your GitHub username, and a PAT with write:packages |
azure-subscription |
Azure Resource Manager | The subscription hosting the Container App |
Save as azure-pipelines.yml:
trigger:
branches:
include:
- main
pool:
vmImage: ubuntu-latest
variables:
imageRepository: reversepoco/htmltopdf
containerRegistry: ghcr
resourceGroup: my-resource-group
containerAppName: htmltopdf
stages:
- stage: Build
displayName: Build and push image
jobs:
- job: BuildAndPush
steps:
- task: Docker@2
displayName: Build and push to GHCR
inputs:
command: buildAndPush
repository: $(imageRepository)
dockerfile: Dockerfile
buildContext: .
containerRegistry: $(containerRegistry)
tags: |
$(Build.BuildId)
latest
- stage: Deploy
displayName: Deploy to Container Apps
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployToAca
environment: production
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: Roll Container App onto the new image
inputs:
azureSubscription: azure-subscription
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az containerapp update \
--name $(containerAppName) \
--resource-group $(resourceGroup) \
--image ghcr.io/$(imageRepository):$(Build.BuildId)Notes:
- The deploy stage uses
$(Build.BuildId)rather thanlatest, so the running revision always names the exact image it came from and a rollback is a singleaz containerapp updateagainst an older tag. - The Docker task builds the image, which means Chrome is installed during the build - expect two to three minutes on a hosted agent, and rather less once layer caching warms up.
environment: productiongives you a deployment record and a place to hang an approval gate if you want one.
public class PdfClient(HttpClient httpClient)
{
public async Task<byte[]> RenderAsync(string html, CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("/render", new { html }, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
}Register it with the key supplied from configuration, never hard-coded:
builder.Services.AddHttpClient<PdfClient>(client =>
{
client.BaseAddress = new Uri(configuration["PdfRenderer:BaseUrl"]!);
client.DefaultRequestHeaders.Add("X-Render-Key", configuration["PdfRenderer:ApiKey"]);
client.Timeout = TimeSpan.FromMinutes(2);
});Requires the .NET 10 SDK and Docker.
dotnet build -c Release # compile only, no browser involved
dotnet test -c Release # unit tests, no browser involved
docker build -t htmltopdf . # full image, installs ChromeLayout:
HtmlToPdf.slnx solution
HtmlToPdf/ the service
HtmlToPdf.Tests/ unit tests
Dockerfile builds HtmlToPdf/ into a runnable image
The tests cover ResourcePolicy, which is the allow/block decision described under Security model, and the parsing of
Renderer__AllowedResourceHosts. They need no browser and run in milliseconds. If you change the resource policy, those
tests are the ones to read first - they encode the cases that matter, including suffix-match and credential-segment
spoofing of an allowlisted host.
error while loading shared libraries: libglib-2.0.so.0
Chrome's native dependencies are missing. You are almost certainly running the binary on a minimal base image. That is the problem this project exists to solve - run the container, do not lift the binary out of it.
The image builds successfully but Chrome is not in it.
Two traps, both of which produce a green build and a broken image:
- On Ubuntu, the
chromiumapt package is a transitional stub that redirects to a snap, and snaps do not run in containers. You get a 2.4 KB shell script at/usr/bin/chromium-browserand no browser. Installgoogle-chrome-stablefrom Google's apt repository instead. apt-get autoremoveafter installing Chrome stripslibpng16,libwebp7andlibxml2, which Chrome links against at runtime.
The Dockerfile runs google-chrome-stable --version as a build-time assertion so neither failure can pass silently.
Keep that line.
Renders are missing images.
Check the logs for Blocked sub-resource. Either add the host to Renderer__AllowedResourceHosts, or embed the image
as a data: URI.
500 on every render, and the logs show a Chrome launch timeout.
Usually memory. Give the container at least 2 GiB.
Fonts render as boxes, or fall back unexpectedly.
The image ships fonts-dejavu-core and fonts-liberation. If your CSS names something else, add the relevant font
package to the Dockerfile or embed the font as a data: URI in your document.
MIT - see LICENSE.