Documentation page: PD4ML Docker Conversion Service. This page is also available as Markdown at pd4ml-docker-service.md.

PD4ML Docker Conversion Service

PD4ML ships a ready-to-run HTTP conversion service in a Docker image: com.pd4ml.tools.Pd4Server wraps the same engine as Pd4Cmd and the Programmer's Manual, but as a long-lived service: one process with a warm engine (the font cache is primed once) and a bounded worker pool, so the JVM and font warm-up are paid at start, not on every request. You POST HTML and get a PDF (or DOCX/RTF) back.

It is stateless -- run as many replicas as you need behind a load balancer -- and it performs no authentication of its own, so it is meant to sit behind an ingress or API gateway.

Container execution requires a site or enterprise license. A single (node-locked) license is detected inside a container and the output falls back to evaluation mode. See Licensing.

Getting the image

Prebuilt images are distributed as tarballs at https://pd4ml.com/docker/, the same way the evaluation downloads are published -- one per PD4ML engine. Download the one that matches your license and load it into Docker:

# PD4ML for Java
curl -fsSLO https://pd4ml.com/docker/pd4ml-service-latest.tar.gz
docker load -i pd4ml-service-latest.tar.gz

# PD4ML for .NET (pd4net)
curl -fsSLO https://pd4ml.com/docker/pd4ml-service-dotnet-latest.tar.gz
docker load -i pd4ml-service-dotnet-latest.tar.gz

That gives you the pd4ml-service image (Ubuntu + a Temurin 17 JRE + the Java engine and its dependencies) or the pd4ml-service-dotnet image (the .NET 10 runtime + pd4net), each tagged :latest and with its own version, for example pd4ml-service:1.0.0. The images are versioned independently of the PD4ML engine inside them; docker inspect shows the engine in the com.pd4ml.engine and com.pd4ml.engine.version labels. The license is not part of the image -- you mount it at run time.

Which image: Java or .NET?

Both images expose the same HTTP API, so the choice comes down to the license you hold, not to your client's language -- any language calls either service over HTTP:

  • You have a PD4ML license for Java -- use pd4ml-service.
  • You have a PD4ML license for .NET -- use pd4ml-service-dotnet. A .NET license does not license the Java engine, so without this image you would have to buy PD4ML for Java just to run the conversion service in Docker; the .NET image lets you deploy the license you already own.
  • You have a Java + .NET bundle -- either works.

A license for the other platform runs the engine in evaluation mode (watermarked output).

Building the image: Java or .NET engine

To build the image yourself, the engine is fetched from the PD4ML repositories at build time (no local PD4ML build needed). From the docker/ directory of the PD4ML distribution:

# PD4ML for Java (the default)
docker build -t pd4ml-service .

# pd4net, PD4ML's .NET port, on .NET 10
docker build -t pd4ml-service-dotnet --build-arg PD4ML_ENGINE=dotnet .
Build argument Default Meaning
PD4ML_ENGINE java java: PD4ML for Java from the Maven repository. dotnet: pd4net, the Pd4mlNet NuGet package from pd4ml.com/v3-eval/dotnet.
PD4ML_VERSION [4.2.0-SNAPSHOT,) The engine version, or a version range (Maven or NuGet syntax), to fetch. The default is the latest 4.2.0+ build, snapshots included.

Both engines serve the same HTTP API, parameters and environment variables, so clients can switch without changes. The version must contain the HTTP service (4.2.0 and later); the build fails with a clear message otherwise. For the Java engine, fetch-pom.xml pins the engine's runtime dependencies (the published com.pd4ml:pd4ml is a thin jar with no declared dependencies); the .NET engine brings its own dependencies, including the native SkiaSharp and HarfBuzz libraries for the image's Linux architecture.

The .NET engine needs a PD4ML license for .NET (or a Java + .NET bundle); a Java-only license runs it in evaluation mode.

Quick start

docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  pd4ml-service

# in another shell:
curl -fsS -X POST --data-binary @page.html \
  'http://localhost:8080/convert?page=A4&width=800' -o out.pdf

The license file is mounted read-only at the path the service reads (/etc/pd4ml/pd4ml.lic by default). Nothing about the license is baked into the image, so the same image is safe to share and to push to a registry.

The HTTP API

Method & path Purpose
POST /convert The request body is the HTML; the response is the converted document.
GET /healthz Liveness / readiness probe. Returns 200 ok.
GET / A one-line usage string.

POST /convert query parameters:

Parameter Default Meaning
format pdf Output format: pdf, pdfa (PDF/A-1b), pdfua (PDF/UA, accessible), docx or rtf.
page A4 A page size name -- any field of com.pd4ml.PageSize (A4, LETTER, LEGAL, A3, B4, ...).
orientation portrait portrait or landscape.
width 800 The HTML layout width in pixels (the "virtual viewport").
insets -- Page margins as top,left,bottom,right,unit, unit mm or pt (e.g. 20,20,20,20,mm).
encoding -- The input character set (e.g. UTF-8, windows-1251); auto-detected if omitted.
base -- A base URL for resolving relative resources in the HTML.
forms false true emits interactive PDF form fields for <input>/<select>/<textarea>.
bookmarks -- Generate PDF bookmarks from headings or from named anchors.
engine v42 Layout engine: v42 (modern, the service default) or v40 (legacy).
bgcolor -- Page background colour, e.g. %23ffffcc (#ffffcc; # must be URL-encoded as %23).
nohyperlinks false true drops hyperlink annotations from the output.
kerning -- true/false to force kerning on/off (v42 kerns by default, as browsers do).
ligatures false true enables OpenType ligatures.
multicolumn -- Newspaper-style columns as columns,gap with unit mm/pt (e.g. 2,10mm; PRO+).

Examples:

# PDF/A-1b, US Letter, wider layout:
curl -fsS -X POST --data-binary @invoice.html \
  'http://localhost:8080/convert?format=pdfa&page=LETTER&width=1000' -o invoice.pdf

# Word .docx, landscape, with heading bookmarks:
curl -fsS -X POST --data-binary @report.html \
  'http://localhost:8080/convert?format=docx&orientation=landscape&bookmarks=headings' -o report.docx

# resolve the page's relative images/CSS against a base URL:
curl -fsS -X POST --data-binary @page.html \
  'http://localhost:8080/convert?base=https://example.com/reports/' -o out.pdf

These are the common single-document options. Other Pd4Cmd capabilities -- HTML page headers and footers, PDF encryption and permissions, overlays and merging, page-range extraction, per-page image output, and XSL transforms -- need a richer request than a URL carries and are not exposed here; use Pd4Cmd or the library for those.

A body larger than PD4ML_MAX_BYTES is rejected with 413; an empty body with 400; a conversion error with 500 and a short message.

Configuration

All configuration is through environment variables:

Variable Default Meaning
PD4ML_PORT 8080 The port the service listens on.
PD4ML_WORKERS CPU count Maximum concurrent conversions (the worker-pool size).
PD4ML_LICENSE /etc/pd4ml/pd4ml.lic Path to the license file; mount it as a read-only secret.
PD4ML_FONTS -- A font-set directory to load (see Providing fonts).
PD4ML_MAX_BYTES 20971520 Maximum request-body size (20 MiB).
PD4ML_WIDTH 800 The default layout width when width is not given on a request.
PD4ML_ALLOWED_LOCATIONS -- Resource-location allow-list (see External resources).
PD4ML_CONTAINER -- Override container detection (true/false); see Licensing.

Two settings are fixed when the image is built, not when it runs -- they are docker build arguments, and setting them with docker run -e has no effect:

Build argument Default Meaning
PD4ML_ENGINE java The engine: java (PD4ML for Java) or dotnet (pd4net, PD4ML for .NET). A built image reports it in its PD4ML_ENGINE environment variable and com.pd4ml.engine label. See Which image: Java or .NET?
PD4ML_VERSION [4.2.0-SNAPSHOT,) The engine version or version range fetched from the PD4ML repositories (com.pd4ml.engine.version label). See Building the image.

The Java image runs the JVM headless with -XX:MaxRAMPercentage=75, so the heap tracks the container's memory limit -- set that limit rather than tuning -Xmx. The .NET runtime follows the container's memory limit on its own.

Providing fonts

The image contains no operating-system text fonts on purpose -- only PD4ML's own bundled Standard-14 faces (Helvetica/Arial, Times, Courier and the symbol fonts). That is enough for plain documents, but to render other typefaces -- or to embed fonts into the PDF, or to support CJK, Arabic, Hebrew and other scripts -- you provide your own font directory. This is the containerised form of useTTF(...); the full mechanics (the pd4fonts.properties index, @font-face, subsetting) are described in Configuring PDF Fonts.

Collect the .ttf/.otf files your documents need into one directory, mount it into the container, and point PD4ML_FONTS at the mount:

docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  -v "$PWD/fonts:/fonts" \
  -e PD4ML_FONTS=/fonts \
  pd4ml-service

PD4ML indexes the directory on start, writing a pd4fonts.properties mapping into it. That needs the mount to be writable, and it re-indexes on every start.

For a read-only font mount (recommended for production, and required for a Kubernetes ConfigMap), pre-build the index once and then mount the directory read-only. You can do this with the image itself -- no separate PD4ML install:

# index ./fonts once (writes ./fonts/pd4fonts.properties)
docker run --rm -v "$PWD/fonts:/fonts" --entrypoint java pd4ml-service \
  -cp '/opt/pd4ml/lib/*' com.pd4ml.tools.Pd4Cmd -configure.fonts /fonts

# then serve with the fonts mounted read-only
docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  -v "$PWD/fonts:/fonts:ro" -e PD4ML_FONTS=/fonts \
  pd4ml-service

In the HTML, refer to a provided font by its family name as usual (font-family: "Noto Sans"; ...), or declare it with @font-face. A face answers only to the CSS family name it is indexed under -- see Configuring PDF Fonts for naming and @font-face details.

Baking a font set into the image

To ship a self-contained image that needs no font mount at all, build a small image on top of pd4ml-service that copies the fonts in, indexes them once at build time, and points PD4ML_FONTS at them (this recipe and the indexing command above use the Java engine's Pd4Cmd, so they apply to the Java image). Put your .ttf/.otf files in a fonts/ directory next to this Dockerfile:

FROM pd4ml-service
COPY fonts/ /opt/fonts/
# index the set once, at build time, so the directory can stay read-only at run time
RUN java -cp '/opt/pd4ml/lib/*' com.pd4ml.tools.Pd4Cmd -configure.fonts /opt/fonts
ENV PD4ML_FONTS=/opt/fonts
docker build -t pd4ml-service-fonts .
docker run --rm -p 8080:8080 -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" pd4ml-service-fonts

The fonts and their generated pd4fonts.properties index are now part of the image: no -v font mount and no PD4ML_FONTS on docker run are needed, and the font directory is read-only at run time. The entrypoint, the environment variables and the licensing behaviour are all inherited from the base image -- only the fonts are added. The license is still mounted at run time; it is never baked in.

External resources and SSRF

By default the engine may fetch the external resources an HTML document references (CSS, images, fonts). When you convert HTML from untrusted sources, restrict that: set PD4ML_ALLOWED_LOCATIONS to a comma- or semicolon-separated list of allowed location prefixes, and/or sandbox the container's network egress. This maps to the engine's addAllowedResourceLocation(...); the rationale and options are in Restricting Resource Loading for Untrusted HTML.

docker run ... -e PD4ML_ALLOWED_LOCATIONS="https://cdn.example.com/,https://assets.example.com/" pd4ml-service

Deployment

Docker Compose (single host)

services:
  pd4ml:
    image: pd4ml-service
    ports:
      - "8080:8080"
    environment:
      PD4ML_WORKERS: "4"
      HOME: /tmp
    volumes:
      - ./pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro
      - ./fonts:/fonts:ro          # optional; with PD4ML_FONTS below
    # environment: { PD4ML_FONTS: /fonts }
    read_only: true
    tmpfs:
      - /tmp
    restart: unless-stopped

Scaling on one host needs a reverse proxy in front (the published port can't be shared): run docker compose up --scale pd4ml=N behind nginx or traefik. For real scaling, use Kubernetes.

Kubernetes

Create the license as a Secret (a site/enterprise license), then deploy N replicas behind a Service. Mount the fonts from a ConfigMap or a volume and set PD4ML_FONTS.

kubectl create secret generic pd4ml-license --from-file=pd4ml.lic=./pd4ml.lic
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pd4ml
spec:
  replicas: 3
  selector: { matchLabels: { app: pd4ml } }
  template:
    metadata: { labels: { app: pd4ml } }
    spec:
      securityContext: { runAsNonRoot: true, runAsUser: 1000, seccompProfile: { type: RuntimeDefault } }
      containers:
        - name: pd4ml
          image: your-registry/pd4ml-service:1.0.0
          ports: [ { containerPort: 8080 } ]
          env:
            - { name: PD4ML_WORKERS, value: "2" }
            - { name: HOME, value: /tmp }
          resources:
            requests: { cpu: "500m", memory: 512Mi }
            limits:   { cpu: "2",    memory: 2Gi }
          readinessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10 }
          livenessProbe:  { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 25 }
          securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: { drop: [ALL] } }
          volumeMounts:
            - { name: license, mountPath: /etc/pd4ml, readOnly: true }
            - { name: tmp, mountPath: /tmp }
      volumes:
        - { name: license, secret: { secretName: pd4ml-license } }
        - { name: tmp, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata: { name: pd4ml }
spec:
  selector: { app: pd4ml }
  ports: [ { port: 80, targetPort: 8080 } ]

Kubernetes pulls images from a registry, not from a tarball, so docker load the downloaded image and push it to your own registry, then reference it (image: your-registry/pd4ml-service:1.0.0). Add a HorizontalPodAutoscaler on CPU to scale replicas. Expose the Service through your ingress / gateway, which is also where you add authentication.

Scaling and performance

The service is stateless: scale by adding replicas. Within one instance, PD4ML_WORKERS bounds how many conversions run at once -- keep it in step with the container's CPU limit. Give each replica enough memory for the largest documents you convert (the heap is a percentage of the container limit).

A single license is the exception to horizontal scaling: it is meant to run as one instance (scale it up with PD4ML_WORKERS, not out with replicas), and it would in any case fall back to evaluation mode in additional containers.

Licensing

Running in a container is a site/enterprise capability.

  • A site/enterprise license runs normally in any number of containers. Deploy the same license file to every replica as a read-only secret.
  • A single (node-locked) license is detected inside a container and the output drops to evaluation mode (the "PD4ML EVALUATION MODE" banner). Use it on bare metal, not in Docker.

If container detection is wrong for your environment, set PD4ML_CONTAINER=false to turn it off (forcing it off where you are not licensed for containers is a licensing matter, not a technical one).

Troubleshooting

Symptom Cause and fix
Output carries the evaluation banner The license is missing, invalid, or not a site/enterprise license in a container. Check that the secret is mounted at PD4ML_LICENSE; confirm it is a site/enterprise license.
Wrong or missing fonts / boxes for CJK text No font directory, or it isn't indexed. Mount your fonts and set PD4ML_FONTS; for a read-only mount, pre-index it (see Providing fonts).
First request returns empty reply / connection reset The engine is still warming up (a few seconds at start). Use the /healthz readiness probe; it is already wired in the Compose and Kubernetes examples.
413 on POST /convert The body is larger than PD4ML_MAX_BYTES; raise it.
External images/CSS not loaded They are blocked by PD4ML_ALLOWED_LOCATIONS or by network egress rules; allow the needed prefixes.