Skip to content
getnextpdf.com

Run NextPDF on serverless platforms

The native, in-process NextPDF core engine is a near-ideal serverless workload. It is pure PHP running inside your processcomposer 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 FontRegistry once 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.json and composer.lock, with nextpdf/core as 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 serverless framework for Lambda, or a container build for Cloud Run / App Runner.

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. The symfony/process dependency 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 instead

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

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:

handler.php (outline)
<?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 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/DocumentFactory warmup 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.

  • 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.
  • /tmp size. 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.
  • No durable save() to the app directory. The deployment filesystem is not durable — Lambda’s app directory is read-only (only /tmp accepts writes), and a Cloud Run / App Runner container filesystem is writable but ephemeral. Use getPdfData() and return/upload the bytes; stage under /tmp or 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 /tmp or 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_PATH does nothing for the bare engine. It is the nextpdf/laravel config default, not a variable nextpdf/core reads. A bare Bref handler that sets only that variable registers no fonts and renders tofu.
  • The Chrome bridge does not fit a vanilla function. writeHtmlChrome needs a headless Chromium and the symfony/process subprocess 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 and writeHtml need none of that — prefer them on serverless.
  • Cold-start cost is autoload plus font parse. Use --optimize-autoloader on the production install and warm the registry once per container. Do not warm fonts you rarely use.
  • API Gateway needs binary handling. Return isBase64Encoded: true with Content-Type: application/pdf, and configure the API to treat application/pdf as 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.
  • Ship no dev dependencies. Install with --no-dev so 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.

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.