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
The service comes as one base image plus the engine you choose. The base image,
pd4ml-service-base, holds everything except PD4ML itself: Ubuntu 24.04, a Java 17 runtime and the
.NET 10 (ASP.NET) runtime, and the third-party libraries of both engines. A short build adds the engine
-- PD4ML for Java or PD4ML for .NET -- with the image's pd4ml-install command. The base image is
distributed as a tarball at https://pd4ml.com/docker/, the same way the
evaluation downloads are published:
# the base image
curl -fsSLO https://pd4ml.com/docker/pd4ml-service-base-latest.tar.gz
docker load -i pd4ml-service-base-latest.tar.gz
# add the engine that matches your license: java or dotnet
docker build -t pd4ml-service --build-arg PD4ML_ENGINE=java https://pd4ml.com/docker/engine.Dockerfile
That gives you the pd4ml-service image. To get a newer PD4ML build later, run the same
docker build again -- the base image stays; download a new one only for runtime updates, or when
the build says the engine needs libraries the base does not have. The base is versioned on its own
(pd4ml-service-base:2.0.0 and :latest); docker inspect shows the engine in the com.pd4ml.engine
label, and /opt/pd4ml/engine in the image records the engine and its version. The license is
not part of the image -- you mount it at run time.
Which engine: Java or .NET?
Both engines 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 -- build with
PD4ML_ENGINE=java. - You have a PD4ML license for .NET -- build with
PD4ML_ENGINE=dotnet. A .NET license does not license the Java engine, so without it you would have to buy PD4ML for Java just to run the conversion service in Docker; the .NET engine 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). The .NET
engine runs on Linux x64 (linux/amd64) only.
The engine build
engine.Dockerfile is three lines on top of the base image; save it next to your own build files if you
prefer (it is also in the docker/ directory of the PD4ML distribution):
docker build -t pd4ml-service -f engine.Dockerfile --build-arg PD4ML_ENGINE=dotnet .
| Build argument | Default | Meaning |
|---|---|---|
PD4ML_ENGINE |
java |
java: the com.pd4ml:pd4ml jar from the PD4ML Maven repository. dotnet: the Pd4mlNet package from pd4ml.com/v3-eval/dotnet. |
PD4ML_VERSION |
newest | The engine version. The default is the newest 4.2.0+ release, else the newest 4.2.0+ snapshot. |
BASE |
pd4ml-service-base:latest |
The base image to build on. |
The version must contain the HTTP service (4.2.0 and later); the build fails with a clear message otherwise. Without network access, install a local file in your own Dockerfile instead:
FROM pd4ml-service-base
COPY pd4ml-4.2.0.jar /tmp/
RUN pd4ml-install java /tmp/pd4ml-4.2.0.jar # or: pd4ml-install dotnet /tmp/Pd4mlNet.4.2.0.nupkg
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 engine: Java or .NET? |
PD4ML_VERSION |
newest | The engine version pd4ml-install installs (recorded in /opt/pd4ml/engine). See The engine build. |
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/java/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/java/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 build the engine image on the loaded
base image, push it to your own registry, then reference it (image: your-registry/pd4ml-service:4.2.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. |
