Skip to content

Repository files navigation

HtmlToPdf

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"

Why this exists

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.

Quick start

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.pdf

For 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.

API

POST /render

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.pdf

GET /health

Unauthenticated, so container orchestrators can probe it. Returns 200 ok. Reports nothing about configuration.

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

Security model

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:

  1. Blocks every sub-resource by default. Only data: URIs and HTTPS hosts named in Renderer__AllowedResourceHosts are fetched. Everything else is aborted and logged as Blocked sub-resource {Url}.
  2. Requires a shared secret on every render, compared with CryptographicOperations.FixedTimeEquals.
  3. Does not disable web security. Chrome's same-origin policy stays on.
  4. 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.

Deploying to Azure Container Apps

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 tsv

Notes:

  • 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 0 means you pay nothing while idle, at the cost of a cold start of a few seconds. Set it to 1 if 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 in az containerapp show output.
  • Ingress is external here for simplicity. If the caller lives in the same Container Apps environment, use --ingress internal and drop the public surface entirely.

To update the running app to a new image:

az containerapp update --name $APP --resource-group $RG --image $IMAGE

Pulling from a private registry instead

az containerapp registry set \
  --name $APP --resource-group $RG \
  --server myregistry.azurecr.io \
  --identity system

Continuous deployment

Both 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.

GitHub Actions

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 DevOps

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 than latest, so the running revision always names the exact image it came from and a rollback is a single az containerapp update against 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: production gives you a deployment record and a place to hang an approval gate if you want one.

Calling it from C#

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);
});

Building from source

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 Chrome

Layout:

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.

Troubleshooting

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 chromium apt 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-browser and no browser. Install google-chrome-stable from Google's apt repository instead.
  • apt-get autoremove after installing Chrome strips libpng16, libwebp7 and libxml2, 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.

Licence

MIT - see LICENSE.

About

Headless Chrome HTML-to-PDF HTTP service in a container, for serverless hosts whose base image cannot run a browser. .NET 10, SSRF-hardened, Azure Container Apps ready.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages