Pulp Engine v0.46.0 — Release Notes
This release introduces a three-phase render isolation architecture that progressively removes
trust from the PDF rendering pipeline. The default production mode (child-process) runs
Puppeteer in a forked child process with an empty environment so no API secrets are reachable
from the renderer. An optional container mode runs each render in an ephemeral Docker container
with --network none, read-only rootfs, and capability restrictions. A new socket mode
(recommended for container deployments) interposes a dedicated controller process so the API
holds no Docker socket authority — the controller exclusively owns daemon access and enforces
hardcoded security flags regardless of API state.
What changed
1. RENDER_MODE=child-process — default production isolation (Phase 1)
Puppeteer now runs in a persistent child process forked with env: {} rather than in the API
process itself. This is the new default (RENDER_MODE=child-process).
Isolation properties:
- No environment variables are inherited from the API process (no
DATABASE_URL,API_KEY_*,S3_SECRET_ACCESS_KEY, etc. are reachable from the renderer). - Assets are pre-inlined as base64 data URIs before dispatch via
assetResolver; the child process never makes loopback requests to the asset server. - A single long-lived worker (
packages/pdf-renderer/src/worker.ts) handles sequential renders; a fresh worker is spawned automatically if the existing one crashes or times out.
No Docker or additional infrastructure is required. Replaces the previous in-process rendering
path transparently — the POST /render API surface is unchanged.
New files:
packages/pdf-renderer/src/dispatcher.ts—RenderDispatcherinterfacepackages/pdf-renderer/src/worker.ts— child process entry point (IPC viaprocess.send)packages/pdf-renderer/src/child-process-dispatcher.ts—ChildProcessRenderDispatcherpackages/pdf-renderer/src/child-process-dispatcher.test.ts
2. RENDER_MODE=container — ephemeral Docker container per render (Phase 2)
Each PDF render runs in a fresh Docker container that is destroyed after the render completes. JSON is passed over stdin/stdout; no network path, no volume mounts.
Hardcoded runtime flags applied by ContainerRenderDispatcher:
| Flag | Effect |
|---|---|
--network none | No network access from worker |
--read-only | Read-only root filesystem |
--tmpfs /tmp:rw,noexec,nosuid,size=256m | Ephemeral writable /tmp; no binary execution |
--cap-drop ALL | No Linux capabilities |
--security-opt no-new-privileges | No setuid escalation |
--memory 512m --cpus 1 | Resource limits (configurable) |
--rm | Container removed after each render |
Only PULP_ENGINE_DISABLE_SANDBOX=true and NODE_ENV=production are passed as container env
vars. API secrets are never forwarded. The worker image (Dockerfile.worker) is a minimal
multi-stage build containing only the pdf-renderer package and its transitive runtime
dependencies — no API server, no Prisma, no auth.
Required configuration:
RENDER_MODE=container
RENDER_CONTAINER_IMAGE=ghcr.io/OWNER/pulp-engine-worker:v0.46.0
New files:
packages/pdf-renderer/src/container-worker.ts— container entry point (JSON stdin/stdout)packages/pdf-renderer/src/container-render-dispatcher.ts—ContainerRenderDispatcherpackages/pdf-renderer/src/container-render-dispatcher.test.tsDockerfile.worker— worker image (multi-stage, non-root UID 1001, no Chromium system libs bundled separately — Puppeteer cache copied from builder stage)apps/worker/— minimal workspace app used bypnpm deployto produce the worker bundle
Residual risk at this phase: The API process holds Docker socket authority. A compromised API could bypass the hardcoded flags by invoking Docker directly.
3. RENDER_MODE=socket — privilege-separated controller (Phase 3)
A new render-controller process owns the Docker socket and listens on a Unix domain socket.
The API connects to the controller over that socket and submits render requests. The API process
holds no Docker socket authority. The controller enforces all security flags
unconditionally — the client cannot influence them.
API process (no docker.sock) ──JSON/\n──► render-controller (has docker.sock)
SocketRenderDispatcher ◄──JSON/\n── spawns docker run, hardcoded flags
What this achieves: Even if the API process is fully compromised, the attacker cannot invoke
arbitrary Docker operations, mount the host filesystem, create privileged containers, or bypass
the isolation flags. The controller binary is much smaller than the API (no HTTP server, no auth,
no storage — socket listener + docker run).
Readiness retry: SocketRenderDispatcher retries ENOENT/ECONNREFUSED for up to 5 s
(10 × 500 ms) on connect, tolerating the normal controller startup race on deploy.
Response size bound: The dispatcher caps incoming responses at 100 MB, preventing a malformed or compromised controller from forcing unbounded buffering in the API process.
Optional seccomp profile: Set RENDER_CONTAINER_SECCOMP_PROFILE on the controller to pass
--security-opt seccomp=<path> to each worker container. The file must be present inside the
controller container (bind-mount as needed).
Required configuration (API process):
RENDER_MODE=socket
RENDER_CONTROLLER_SOCKET=/run/render/render.sock
Required configuration (controller process):
RENDER_CONTAINER_IMAGE=ghcr.io/OWNER/pulp-engine-worker:v0.46.0
RENDER_CONTROLLER_SOCKET=/run/render/render.sock
New files:
packages/pdf-renderer/src/render-controller.ts— standalone controller (exported:validateImageName,loadConfig,handleConnection; startup guard viaimport.meta.url)packages/pdf-renderer/src/socket-render-dispatcher.ts—SocketRenderDispatcherpackages/pdf-renderer/src/render-controller.test.ts— 37 testspackages/pdf-renderer/src/socket-render-dispatcher.test.ts— 15 testsDockerfile.controller— controller image:node:22-slim+docker-ce-clionly (no Chromium), non-root UID 1002compose.container.yaml— privilege-separated deployment topology (see § 4 below)
4. compose.container.yaml — privilege-separated compose topology
A new compose file demonstrates the correct deployment for RENDER_MODE=socket:
render-controller:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ONLY this service
- render-socket:/run/render
pulp-engine:
# No /var/run/docker.sock mount
volumes:
- render-socket:/run/render:ro
depends_on:
render-controller:
condition: service_healthy
The render-controller healthcheck (test -S /run/render/render.sock) ensures the API
container does not start until the socket file exists. The socket volume uses driver_opts: type: tmpfs — no persistence needed.
5. TemplateRenderer dispatcher injection
packages/template-renderer/src/renderer.ts now accepts an optional RenderDispatcher in its
constructor. If omitted, it falls back to a PdfRenderer instance for backward compatibility
(in-process mode). The assetBaseUrl field on RenderRequest is deprecated — the correct
path is assetResolver pre-inlining.
6. Config additions
apps/api/src/config.ts:
| Variable | Notes |
|---|---|
RENDER_MODE | Enum: in-process, child-process (default), container, socket |
RENDER_CONTAINER_IMAGE | Required when RENDER_MODE=container |
RENDER_CONTROLLER_SOCKET | Required when RENDER_MODE=socket |
7. Test coverage
pnpm --filter @pulp-engine/pdf-renderer test— 261 passed (net +104 vs v0.45.0). Covers: isolation flags on bothContainerRenderDispatcherandhandleConnection, secret isolation on all dispatch paths, seccomp flag propagation, image name allowlist, startup validation, 120 s timeout + SIGKILL, response size bound, readiness retry budget, wire format.pnpm --filter @pulp-engine/api test— 619 passed, 0 failed. Two previously-failing tests (render-preview.test.tse-c/e-d) were fixed to mockChildProcessRenderDispatcherin addition toPdfRenderer, as the default render path no longer usesPdfRendererdirectly.
Validation
pnpm --filter @pulp-engine/pdf-renderer test— 261 passed, 0 failedpnpm --filter @pulp-engine/api test— 619 passed, 0 failedpnpm lint— 0 errorspnpm run version:check— passed
Upgrade
No breaking changes. The default RENDER_MODE is child-process, which is a transparent
drop-in for the previous in-process renderer. All env var names, API shapes, and database
schemas are unchanged. No migrations required.
The TemplateRenderer constructor now accepts an optional RenderDispatcher. Callers passing
no arguments are unaffected. The assetBaseUrl field on RenderRequest is deprecated but still
accepted; it is silently ignored when a dispatcher is configured.
Residual risk (honest assessment):
RENDER_MODE=child-process(default): A Chromium/Puppeteer RCE in the child process can still reach the host filesystem and any resources accessible from the host network. It cannot access API secrets from the environment. This mode is appropriate for trusted-team deployments.RENDER_MODE=container: The API process holds Docker socket authority. A compromised API can bypass the hardcoded flags. Appropriate for environments where the API is trusted but an extra containment layer is wanted.RENDER_MODE=socket: The API no longer holds Docker socket authority. A compromised API cannot escalate to arbitrary Docker operations. Therender-controllerprocess still holds the socket — a container-escape RCE reaching the controller process restores daemon authority. Chromium sandbox is still disabled (required for containerisation). No gVisor, no user namespace remapping bundled.- All modes: Pulp Engine is not designed for hostile multi-tenant environments where arbitrary untrusted users author templates. These improvements materially strengthen the isolation for trusted-team deployments; they do not constitute hostile multi-tenant readiness.
docker pull ghcr.io/OWNER/pulp-engine:v0.46.0
docker pull ghcr.io/OWNER/pulp-engine-worker:v0.46.0
docker pull ghcr.io/OWNER/pulp-engine-controller:v0.46.0