Run NextPDF on serverless platforms
At a glance
Section titled “At a glance”The native, in-process NextPDF core engine is a near-ideal serverless workload.
It is pure PHP running inside your process — composer require nextpdf/core,
build a document, get the bytes. There is no external binary to spawn, no
headless browser, no daemon to keep alive, and no socket to a sidecar service.
A function that builds a PDF starts cold, runs your PHP, returns the bytes, and
exits. That maps cleanly onto AWS Lambda (through the Bref runtime), Google
Cloud Run, and AWS App Runner.
This page covers deploying that native engine to those three runtimes and the small set of real constraints they impose:
- the runtime filesystem is not durable: Lambda only guarantees a writable
/tmp, while container runtimes (Cloud Run, App Runner) have an ephemeral, container-scoped filesystem — either way fonts must travel inside the deployment package or image and be registered in PHP (the engine reads no font-path environment variable); - cold starts pay for autoloading and any font warmup, so warm the
FontRegistryonce per container, not per invocation; - package size, memory, and timeout must be sized to the build, not to a trivial request.
This page is only for the native engine. The Chrome bridge
(writeHtmlChrome via the suggested nextpdf/artisan package) is a different,
heavier story: it shells out to a headless Chromium through symfony/process,
which a vanilla Lambda zip or a slim container does not contain. Running Chromium
on Lambda means a custom layer with the browser and its shared libraries, far
larger packages, and much longer cold starts — out of scope here. The bare
engine needs none of that.
Before you start, confirm these pieces are in place:
- Your application has a committed
composer.jsonandcomposer.lock, withnextpdf/coreas a dependency. - You have the font files you intend to embed, and you are licensed to embed them.
- You have the toolchain for your target — the Bref CLI and
serverlessframework for Lambda, or a container build for Cloud Run / App Runner.
Why the native engine fits serverless
Section titled “Why the native engine fits serverless”Read straight from the package, nextpdf/core requires php: >=8.4 <9.0 and a
small set of PHP extensions — ext-mbstring, ext-intl, ext-gd,
ext-openssl, ext-zlib, and ext-curl. The standard Bref PHP layers bundle
every one of these. The official php:8.4 container images provide openssl,
curl, and zlib out of the box, but mbstring, gd, and intl are not
bundled — they require installing system dependencies and enabling the
extensions with docker-php-ext-install (see the
Docker deployment guide).
On Bref there is nothing exotic to compile; on the container path you enable
those three extensions in the image build for the bare engine.
What makes the fit clean is what the engine does not do:
- No subprocess for the core path. Building a document and calling
getPdfData()is in-process PHP end to end. Thesymfony/processdependency exists for the optional Chrome bridge, not for native rendering — native PDF generation never spawns a process. - No persistent state. Each invocation builds a fresh document and returns bytes. Nothing must survive between requests except the warm container, which you exploit for font warmup (below) but never rely on for correctness.
- No writable working directory needed. The engine builds the PDF in
memory and returns it as a string; it touches disk only if you call
save(). On serverless you do not — you return the bytes — so the lack of a durable filesystem never bites the build path.
The one hard constraint: no durable writable filesystem
Section titled “The one hard constraint: no durable writable filesystem”The deployment filesystem is not durable, but the model differs by runtime.
AWS Lambda only guarantees a writable /tmp (512 MB by default, configurable
up to 10 GB); the rest of the function filesystem is read-only. Container
runtimes (Cloud Run, App Runner) have an ephemeral, container-scoped writable
filesystem rather than a /tmp-only model — but anything written there is lost
when the container is recycled, so it is scratch space, not storage. In every
case, prefer /tmp or a configured volume for staging, and never rely on writes
to the application image path as durable storage. Two consequences follow.
Never call save() expecting durable output. NextPDF\Core\Document exposes
both save(string $path): void and getPdfData(): string. On serverless you
use getPdfData() and return or upload the bytes — do not treat a write to the
application directory as persistent storage. If you must stage a file (for
example, to multipart-upload to object storage), write under /tmp (or a
configured volume) and clean up, remembering that on a warm container this
scratch space persists across invocations and counts against its size limit.
use NextPDF\Core\Document;
// Right for serverless: get the bytes, return or upload them.$pdf = $document->getPdfData(); // string of PDF bytes, built in memory
// Avoid on serverless: save() writes to disk. On Lambda the application// directory is read-only; on Cloud Run / App Runner it is writable but// ephemeral (lost on container recycle). Neither is durable storage.// $document->save('/var/task/out.pdf'); // not durable — return the bytes insteadDo not install OS fonts at runtime, and do not rely on automatic font
discovery; bundle your font files for production. On Lambda the read-only
filesystem blocks apt-get install fonts-* outright; on a container runtime any
runtime install lands on an ephemeral filesystem and is lost on the next
recycle. And it would not help anyway, because the native engine reads no
OS/fontconfig fonts — it resolves fonts only from files you register. So for
production the font files must ship inside the deployment artifact. If you
deliberately fetch font files into /tmp or a configured volume, you must
register them explicitly with the font registry and accept the added cold-start
and reliability cost — it is not a recommended production pattern.
Bundle and register fonts in the package or image
Section titled “Bundle and register fonts in the package or image”The native engine resolves fonts from font files through the
NextPDF\Typography\FontRegistry, not from fontconfig or OS-installed fonts.
On serverless this is non-negotiable: there is no persistent filesystem to put
fonts on after deploy, so they ship inside the package (a Lambda zip or layer)
or inside the image (Cloud Run / App Runner).
Bundle your .ttf / .otf / .ttc files under a directory in your project —
resources/fonts/ is the convention — so they are included in the artifact.
Then register that directory in PHP. The engine reads no font-path
environment variable: NEXTPDF_FONTS_PATH is the default value of the
nextpdf/laravel package’s fonts_path config key
(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))) and is consumed only by
that framework integration, not by nextpdf/core. A bare function must
construct the registry with the bundled directory:
use NextPDF\Typography\FontRegistry;use NextPDF\Core\DocumentFactory;use NextPDF\Graphics\ImageRegistry;
// Register the directory the deployment artifact bundled the fonts into.// On Lambda/Bref the code root is /var/task; adjust for your runtime.$registry = new FontRegistry(__DIR__ . '/resources/fonts');// (equivalently, $registry->addFontDirectory(__DIR__ . '/resources/fonts');)
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));$document = $factory->create();That is the whole serverless concern for fonts. The file-naming rules, the full registry API, and the non-durable-filesystem handling live on the dedicated page — do not duplicate them here. Read Provision fonts for the native engine in production for the complete pattern, and register the same directory you bundled. The Docker deployment guide covers the equivalent image-side bundling for the Cloud Run / App Runner case.
Cold starts: warm the FontRegistry once per container
Section titled “Cold starts: warm the FontRegistry once per container”A cold start pays for the PHP bootstrap, Composer’s optimized autoloader, and any font parsing the first build triggers. You cannot avoid the bootstrap, but you can move font work out of the hot path and reuse it across warm invocations.
Construct the FontRegistry and DocumentFactory once, outside the handler,
so they live for the life of the container and are reused on every warm
invocation. Optionally call warmup() with the font files you know you will use,
so they are parsed during initialization rather than on the first render, then
lock() the registry so its parsed state is frozen and no per-invocation
mutation can race:
use NextPDF\Typography\FontRegistry;use NextPDF\Core\DocumentFactory;use NextPDF\Graphics\ImageRegistry;
// Container-scoped, built once at cold start (module scope, not per request).$fontsDir = __DIR__ . '/resources/fonts';$registry = new FontRegistry($fontsDir);
// Parse the fonts you will actually use now, so the first render does not.$registry->warmup([ $fontsDir . '/liberation/LiberationSans-Regular.ttf', $fontsDir . '/liberation/LiberationSans-Bold.ttf',]);
// Freeze the parsed state for the life of the warm container.$registry->lock();
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
// Each invocation: fresh document from the shared, warm factory.$handler = static function (array $event) use ($factory): string { $document = $factory->create(); $document->addPage(); $document->cell(0, 10, 'Hello from serverless', newLine: true);
return $document->getPdfData();};Call warmup() before lock() — the registry is frozen once locked, so a
warmup after that raises a configuration error. Treat a font that fails to load
at warmup as a deploy-time error, not a runtime detail: validate that every
font path you intend to warm actually exists and parses at startup, and fail the
deploy (or your health check) if one does not, rather than letting a typo’d path
surface later as missing glyphs. Keep the warmup list to the fonts a typical
invocation needs; warming a large family you rarely use just lengthens every
cold start.
A Bref function on AWS Lambda
Section titled “A Bref function on AWS Lambda”Bref provides the PHP runtime for Lambda as a published
layer and a serverless.yml plugin. The php-84 runtime already ships the
extensions nextpdf/core needs, so you deploy your code and fonts and point a
function at a handler. A minimal serverless.yml:
service: nextpdf-serverless
provider: name: aws region: us-east-1 runtime: provided.al2023
plugins: - ./vendor/bref/bref
functions: generate: handler: handler.php description: Generate a PDF with the native NextPDF engine runtime: php-84 memorySize: 1024 # size to the build; see "Sizing" below timeout: 30 # seconds; raise for large documents # The Lambda filesystem is read-only except /tmp. Fonts ship in the # package under resources/fonts and are registered in the handler.The handler builds the document with the warm, container-scoped factory and
returns the bytes. For an HTTP API, return them base64-encoded with the
application/pdf content type so API Gateway treats the body as binary; for an
invoke or queue trigger, upload the bytes to object storage and return the key:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;use NextPDF\Graphics\ImageRegistry;use NextPDF\Typography\FontRegistry;
// --- Cold-start: built once per container, reused across warm invocations. ---$fontsDir = __DIR__ . '/resources/fonts';$registry = new FontRegistry($fontsDir);$registry->warmup([$fontsDir . '/liberation/LiberationSans-Regular.ttf']);$registry->lock();$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
// --- Per-invocation handler. ---return static function (array $event) use ($factory): array { $document = $factory->create(); $document->addPage(); $document->cell(0, 10, 'Invoice', newLine: true);
// getPdfData() materializes the whole PDF in memory and returns it. $bytes = $document->getPdfData();
return [ 'statusCode' => 200, 'isBase64Encoded' => true, 'headers' => ['Content-Type' => 'application/pdf'], 'body' => base64_encode($bytes), ];};Verify the package contains a healthy environment before you wire traffic to it.
nextpdf/core ships a CLI installed at vendor/bin/nextpdf whose doctor
command reports on exactly the extensions the engine needs. Run it once against
the same runtime image or layer to confirm PHP 8.4 and every required extension
is present.
Cloud Run and App Runner
Section titled “Cloud Run and App Runner”Cloud Run and App Runner run a container rather than a zipped function, so
the build is the Docker image from
Containerize a NextPDF application,
not a Bref package. The native-engine constraints are identical: bundle the
fonts into the image, register the bundled directory in PHP, run unprivileged,
and treat the filesystem as non-durable. Unlike Lambda’s /tmp-only model, a
Cloud Run / App Runner container has an ephemeral, container-scoped writable
filesystem — but it is reset on every recycle, so use /tmp (a tmpfs on
Cloud Run) or a configured volume for scratch and never rely on writes to the
application image path as durable storage.
The differences from Lambda are operational, not structural:
- The container can stay warm across requests under a concurrency setting,
so the container-scoped
FontRegistry/DocumentFactorywarmup above pays off across many requests, not just the next invocation. - You serve over HTTP (an FPM or built-in PHP server SAPI) rather than an invoke event, so you return the bytes through your framework’s response. For a large document, return them as a streamed response — see Stream a large generated PDF as an HTTP response.
- The request timeout and memory are set on the service (Cloud Run service timeout / memory; App Runner instance configuration) rather than per function.
Everything else — the extension set, the font registration, the getPdfData()
output call — is the same code as the Lambda handler.
Sizing: package, memory, and timeout
Section titled “Sizing: package, memory, and timeout”- Package and image size. The artifact carries
vendor/(production only — install with--no-dev) and your bundled fonts. Fonts dominate: a full CJK family is tens of megabytes. Ship only the fonts you actually render to keep the Lambda package under its limits and the image small, which also shortens cold starts. The bundled Liberation family (resources/fonts/liberation/) is small and covers metric-compatible Helvetica substitution. - Memory.
getPdfData()builds the entire document in memory and returns it as one string, so peak memory is roughly the size of one finished PDF plus the build’s working set. Size the function/container memory to the largest document you generate, not to an average. On Lambda, memory also scales CPU, so more memory often means a faster build and a cheaper run despite the higher per-millisecond rate — measure both. A few-page document is comfortable at 512–1024 MB; image-heavy or many-page documents need more. - Timeout. The build, not the transfer, dominates the request budget. Set the function timeout above the worst-case build time with margin. If a document is large enough to risk a timeout, move generation to an asynchronous trigger (a queue-backed Lambda or a Cloud Run job) that writes the result to object storage instead of blocking a synchronous request.
/tmpsize. If you stage anything under/tmp, account for its size limit and remember it persists across warm invocations — clean up, or a long-lived container slowly fills it.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- No durable
save()to the app directory. The deployment filesystem is not durable — Lambda’s app directory is read-only (only/tmpaccepts writes), and a Cloud Run / App Runner container filesystem is writable but ephemeral. UsegetPdfData()and return/upload the bytes; stage under/tmpor a configured volume if you must. - Do not rely on automatic font discovery. Do not install OS fonts at
runtime, and do not rely on automatic font discovery; bundle your font files
for production. The native engine reads no OS/fontconfig fonts — it resolves
only files you register. If you deliberately fetch font files into
/tmpor a configured volume, you must register them explicitly with the font registry and accept the added cold-start and reliability cost. Bundle and register the files. See the fonts page linked above. NEXTPDF_FONTS_PATHdoes nothing for the bare engine. It is thenextpdf/laravelconfig default, not a variablenextpdf/corereads. A bare Bref handler that sets only that variable registers no fonts and renders tofu.- The Chrome bridge does not fit a vanilla function.
writeHtmlChromeneeds a headless Chromium and thesymfony/processsubprocess path. Putting Chromium on Lambda requires a custom layer with the browser and its libraries, far larger packages, and long cold starts. The native engine andwriteHtmlneed none of that — prefer them on serverless. - Cold-start cost is autoload plus font parse. Use
--optimize-autoloaderon the production install and warm the registry once per container. Do not warm fonts you rarely use. - API Gateway needs binary handling. Return
isBase64Encoded: truewithContent-Type: application/pdf, and configure the API to treatapplication/pdfas a binary media type, or the client receives corrupted bytes. - Premium and ionCube are a heavier artifact concern. ionCube-encoded NextPDF Pro / Enterprise builds need the ionCube Loader matched to the exact PHP build in the runtime, which a stock Bref layer does not include. That is out of scope for a core serverless deploy.
Security notes
Section titled “Security notes”- Ship no dev dependencies. Install with
--no-devso the test and analysis tooling never enters the function package or image. - Validate input before building. A PDF build driven by request input is a memory-exhaustion vector; reject out-of-range or oversized inputs at the boundary before any build work runs, and bound concurrency so high traffic does not multiply peak memory into an out-of-memory failure.
- Keep fonts and licenses out of public artifacts. Bundle only fonts you are licensed to embed, and never bake a premium license file into a publicly pushed image or layer — supply it at runtime via an environment value or secret manager instead.
- Least privilege. Give the function/service only the IAM permissions it needs (for example, write access to the one output bucket), and run the container unprivileged as the Docker guide shows.
Conformance
Section titled “Conformance”This guide makes no normative standards claim. The platform facts are read
directly from the nextpdf/core package: the php: >=8.4 <9.0 constraint and
the required extensions ext-mbstring, ext-intl, ext-gd, ext-openssl,
ext-zlib, and ext-curl. The standard Bref PHP-8.4 runtime layer bundles all
six; the official php:8.4 image provides openssl, curl, and zlib, but
mbstring, gd, and intl must be installed and enabled in the image build
with docker-php-ext-install (see the Docker page). The output call is the real
core surface
NextPDF\Core\Document::getPdfData(): string (its disk sibling is
save(string $path): void). Fonts are registered through
NextPDF\Typography\FontRegistry — its directory constructor argument /
addFontDirectory(), with warmup(array $fontFiles) and lock() for the
cold-start pattern — wired via NextPDF\Core\DocumentFactory::create().
NEXTPDF_FONTS_PATH is the nextpdf/laravel package’s fonts_path config key
(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))), not a variable
nextpdf/core reads. The nextpdf CLI doctor command is declared as
"bin": ["bin/nextpdf"] in the package and installed at vendor/bin/nextpdf in
a consuming app. Bref runtime names and AWS Lambda / Cloud Run / App Runner
behaviors are those vendors’ documented features.
See also
Section titled “See also”- Containerize a NextPDF application: the production image used for the Cloud Run / App Runner targets.
- Provision fonts for the native engine in production: the font-file naming, registry API, and warmup-and-lock pattern this page relies on.
- Stream a large generated PDF as an HTTP response: the memory model for returning a built document over HTTP on Cloud Run / App Runner.
- Render at the edge with Cloudflare: when an in-process function is not the right runtime.