Containerize a NextPDF application
At a glance
Section titled “At a glance”You want a small, reproducible Docker image that runs the native, in-process
NextPDF core engine — composer require nextpdf/core, generating PDFs inside
your PHP process. This page builds exactly that: a php:8.4 image with only the
extensions the engine actually needs, no development dependencies in the final
layer, bundled fonts, a non-root runtime user, opcache tuned for production, and
a verification step that fails the build if anything is missing.
This page is only for the native engine. The Chrome bridge
(writeHtmlChrome via nextpdf/artisan) and the Connect server are separate
runtimes with their own, heavier images — a headless Chromium install for the
bridge, a long-running service for Connect. Do not add a browser or a server to
this image; the native engine needs neither.
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 can run
docker buildagainst your application directory.
This is an operations how-to. There is almost no PHP here; the work is the Dockerfile and a few environment settings.
What the engine actually requires
Section titled “What the engine actually requires”The image must satisfy the engine’s real platform constraints, nothing more.
Reading them straight from the package, nextpdf/core requires
php: >=8.4 <9.0 and these PHP extensions:
| Extension | Why the engine needs it |
|---|---|
ext-mbstring | Multi-byte string handling for text and encodings |
ext-intl | Unicode, locale, and internationalization support |
ext-gd | Raster image decoding and processing |
ext-openssl | Cryptography for signing and secure hashing |
ext-zlib | Stream (Flate) compression of PDF objects |
ext-curl | HTTP client for the engine’s outbound calls |
Map those to the official php:8.4 image. openssl, curl, and zlib are
already compiled into the official PHP image, so you do not
docker-php-ext-install them. mbstring, gd, and intl are not bundled
and must be installed, and each needs its system development headers present
first — mbstring additionally needs the libonig-dev (Oniguruma) build
dependency. Do not add engine extensions
the package does not list — every extra docker-php-ext-install is build time
and attack surface you do not need. The one non-engine extension this image does
install is opcache: it is a runtime performance extension, not bundled
enabled on the official image, and the opcache tuning below depends on it being
present (see “Opcache for production”).
The production Dockerfile
Section titled “The production Dockerfile”This is a two-stage build. The first stage installs Composer dependencies with the development packages excluded; the second stage is the lean runtime image that ships.
First add a .dockerignore next to the Dockerfile. Its primary job is to keep
the host environment — a host-built vendor/, local secret files, and build
caches — out of the build context entirely, so COPY . /var/www/app ships only
what you intend: smaller, faster, and safer builds that cannot leak local
.env secrets or carry megabytes of host vendor/ into the image.
Excluding vendor/ also matters because a directory COPY is a merge, not a
replace. The Dockerfile below runs RUN rm -rf /var/www/app/vendor before the
COPY --from=vendor ... /var/www/app/vendor, so in this image a host vendor/
can never survive under the clean dependency tree. But if you ever remove that
rm -rf safeguard, a host-built vendor/ in the context would land first and the
vendor-stage copy would only overwrite the paths the clean tree contains — any
extra host files (a stale or dev-installed package, an orphaned class) would then
survive underneath it. Keeping vendor/ out of the context closes that hole
regardless of the rm -rf.
# .dockerignore — keep the host environment out of the build context.vendor/.git/.env.env.local.env.*.localvar/cache/storage/node_modules/*.logExclude the real local secret files (.env, .env.local, .env.*.local), not
a blanket .env.* — that wildcard also drops non-secret templates such as
.env.example that you do want to ship so the image carries a documented
configuration baseline. Keep any committed, non-secret env template in the
context; exclude only the files that actually hold local secrets.
# syntax=docker/dockerfile:1
# ---- Stage 1: dependencies (no dev) ---------------------------------------FROM composer:2 AS vendor
WORKDIR /appCOPY composer.json composer.lock ./
# Install production dependencies only. --no-dev excludes phpunit, phpstan,# infection, and the other require-dev tooling from the shipped image.# --optimize-autoloader builds a class map for the *vendor* tree here; the# application's own classes are not present in this stage yet, so they are# optimized after the source copy in the runtime stage (see below).RUN composer install \ --no-dev \ --no-interaction \ --no-progress \ --prefer-dist \ --optimize-autoloader \ --no-scripts
# ---- Stage 2: runtime ------------------------------------------------------FROM php:8.4-cli AS runtime
# System headers for the gd, intl, and mbstring extensions that need compiling.# The PHP image already provides openssl, curl, and zlib, so those are NOT# listed; gd, intl, and mbstring are installed below. opcache has no system# headers and is installed in the same step. mbstring is built against# Oniguruma, so libonig-dev is in the *-dev set and its runtime lib (libonig5)# is preserved by the same detection below.## Build the *-dev headers (which pull in the runtime libs), compile the# extensions, then mark only the runtime shared libraries the extensions# actually link against so they survive the --auto-remove purge of the headers.# Removing libicu / libpng / libjpeg / libfreetype / libonig here would unlink# intl.so, gd.so, or mbstring.so at runtime ("undefined symbol" / "cannot open# shared object file").RUN set -eux; \ savedAptMark="$(apt-mark showmanual)"; \ apt-get update; \ apt-get install -y --no-install-recommends \ libicu-dev \ libpng-dev \ libjpeg62-turbo-dev \ libfreetype6-dev \ libonig-dev; \ docker-php-ext-configure gd --with-freetype --with-jpeg; \ docker-php-ext-install -j"$(nproc)" gd intl mbstring opcache; \ # Detect the runtime .so dependencies of the just-built extensions and # mark them manual so --auto-remove keeps them while dropping the headers. apt-mark auto '.*' > /dev/null; \ apt-mark manual $savedAptMark > /dev/null; \ find /usr/local/lib/php/extensions -type f -name '*.so' -exec \ sh -c 'ldd "$1" 2>/dev/null \ | awk "/=>/ { print \$3 }" \ | grep -E "^/" \ | xargs -r dpkg-query -S 2>/dev/null \ | cut -d: -f1 \ | sort -u \ | xargs -r apt-mark manual' _ {} \; ; \ apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ rm -rf /var/lib/apt/lists/*
# Production opcache settings (see the opcache section below). The opcache# extension is installed above (docker-php-ext-install opcache); this file only# tunes it.COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# A static Composer binary for the one optimized-autoloader rebuild below. It is# copied into the build but the final stage runs no Composer at request time.COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app
# Application code, then the vendor tree from the dependency stage. The .dockerignore# should already keep a host vendor/ out of the context; the rm here is a second line# of defense so a stale host-built vendor/ can never merge under the clean one (a# directory COPY merges, it does not replace).COPY . /var/www/appRUN rm -rf /var/www/app/vendorCOPY --from=vendor /app/vendor /var/www/app/vendor
# Now that the application source is present, regenerate the optimized class map# so the APP's own classes are in the optimized autoloader, not just the vendor# packages. --no-dev keeps require-dev out; --no-scripts avoids running# application hooks during the image build.RUN composer dump-autoload \ --optimize \ --no-dev \ --no-interaction \ --no-scripts \ && rm -f /usr/bin/composer
# The bundled fonts live at /var/www/app/resources/fonts. The native engine does# NOT read any font-path environment variable — the entrypoint registers that# directory in PHP (see "Bundle fonts into the image" below). There is no ENV# line for fonts here.
# Run as a non-root user (see the non-root section below).RUN useradd --system --no-create-home --uid 10001 appuser \ && chown -R appuser:appuser /var/www/appUSER appuser
CMD ["php", "bin/generate.php"]The dependency stage runs with --no-scripts so no application post-install
hook runs against an incomplete tree; run any application build step (asset
compilation, cache warming) in a later stage after the code is copied.
Multi-stage Composer install (no dev dependencies)
Section titled “Multi-stage Composer install (no dev dependencies)”The shipped image must not contain development tooling. The --no-dev flag on
composer install is the load-bearing line: it skips everything under
require-dev in nextpdf/core and your application — the test runner, static
analyzer, and mutation tools — none of which has any place in production. Pair it
with --optimize-autoloader so the autoloader is a generated class map rather
than a filesystem scan on every request.
Copy composer.json and composer.lock before the rest of the source so
Docker caches the dependency layer and only re-resolves when the lock file
changes. Because that first install runs against the lock file alone — without
the application source — --optimize-autoloader there builds the class map for
the vendor tree only; your application’s own classes are not present yet.
That is why the runtime stage runs
composer dump-autoload --optimize --no-dev --no-scripts once after copying
the source: it folds the app’s classes into the same optimized class map. Do not run a separate composer dump-autoload in a worktree
you also develop in (it would commit a production class map into a dev tree); the
rebuild belongs in the image, after the source copy, as shown above.
Bundle fonts into the image
Section titled “Bundle fonts into the image”The native engine resolves fonts from font files it can read, not from
OS-installed fonts. Installing fonts-* packages or running fc-cache does
nothing the native path can see, so this image installs no system fonts. Bundle
your .ttf / .otf files under resources/fonts/; the COPY . /var/www/app
above already carries them into the image.
Getting the files into the image is only half the job. The bare native engine
reads no font-search 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 plain
php bin/generate.php entrypoint with only that variable set registers no fonts
and renders the same tofu this image exists to prevent. The entrypoint must
register the bundled directory in PHP:
use NextPDF\Typography\FontRegistry;use NextPDF\Core\DocumentFactory;use NextPDF\Graphics\ImageRegistry;
// Register the directory the Dockerfile bundled the fonts into.$registry = new FontRegistry('/var/www/app/resources/fonts');// (equivalently, $registry->addFontDirectory('/var/www/app/resources/fonts');)
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));$doc = $factory->create();That is the whole Docker concern for fonts. The file-naming rules, the registry API, the warmup-and-lock pattern, and read-only-filesystem handling all live on the dedicated page — do not duplicate them here. Read Provision fonts for the native engine in production for the full pattern, and register the same directory you bundled.
Run as a non-root user
Section titled “Run as a non-root user”The official PHP images run as root by default. A PDF generator does not need
root, so create an unprivileged user and switch to it. The Dockerfile above adds
a system user appuser with a fixed high UID (10001), gives it ownership of
the application tree, and ends with USER appuser so every process the
container starts is unprivileged.
Keep the application read-only at runtime where you can. The engine reads its
font files and writes only its output and an optional parsed-font cache, so a
readOnlyRootFilesystem container works as long as the output path and any cache
directory are writable mounts. Combine this with dropped Linux capabilities and a
no-new-privileges flag in your orchestrator for defense in depth.
Opcache for production
Section titled “Opcache for production”Opcache pays off for long-lived PHP workers — an FPM pool or an Apache
mod_php process that serves many requests from one warm process. Those
processes compile your classes once and then never stat source files on a hot
path, which is exactly what opcache.validate_timestamps=0 buys you. Opcache is
not enabled out of the box on the official php:8.4 image, so the Dockerfile
above installs it with docker-php-ext-install opcache (you can equivalently
docker-php-ext-enable opcache if the extension is already compiled). The
conf.d file below is tuning, not the enable step — it does nothing until the
extension is loaded. Ship it as a conf.d include (docker/opcache.ini, copied
in the Dockerfile):
opcache.enable=1opcache.enable_cli=0opcache.memory_consumption=192opcache.interned_strings_buffer=16opcache.max_accelerated_files=20000opcache.validate_timestamps=0opcache.validate_timestamps=0 means the cache never re-checks source files —
correct for an immutable image, since the only way the code changes is a new
image. Tune memory_consumption and max_accelerated_files to your
application’s class count.
The CMD shown is a one-shot CLI generator, and opcache.enable_cli=0 is
correct for it. A short-lived php bin/generate.php process starts, compiles,
renders once, and exits, so an opcode cache it cannot share with a next request
gives no benefit — leave CLI opcache off and pay none of its memory cost. Opcache
only earns its keep where the process is reused: an FPM/Apache SAPI, or a
genuinely long-running CLI worker (a queue consumer or a RoadRunner-style
server). Only that kind of resident CLI worker would set opcache.enable_cli=1;
for the one-shot generator here, keep it 0.
If you do run a setup that uses opcache preloading (a long-lived FPM worker
with an opcache.preload script), set opcache.preload=/path/to/preload.php and
add opcache.preload_user=appuser so the preload runs as the unprivileged user.
Without an actual opcache.preload script, opcache.preload_user does nothing,
which is why it is not in the baseline config above — do not add it unless you
also set opcache.preload.
Verify the image
Section titled “Verify the image”Add a verification step so a misbuilt image fails loudly instead of producing
tofu or a fatal at the first request. NextPDF ships a CLI whose doctor command
inspects the running PHP environment and reports on exactly the extensions the
engine cares about — openssl, zlib, mbstring, gd, curl, and intl.
The package declares "bin": ["bin/nextpdf"], so in a consuming application
Composer installs the executable at vendor/bin/nextpdf (not bin/nextpdf,
which is the path inside the nextpdf/core package itself). Run it inside the
built image:
docker run --rm your-app:latest php vendor/bin/nextpdf doctorA healthy result confirms PHP 8.4 and every required extension is loaded. Wire the same call into the build (or a CI smoke job) so a missing extension stops the pipeline:
# Fail the pipeline if the engine's environment is not healthy.docker run --rm your-app:latest php vendor/bin/nextpdf doctor || exit 1For an end-to-end check, render one page through your own entrypoint and assert on the output, as the fonts page describes for a font smoke check.
Edge cases & gotchas
Section titled “Edge cases & gotchas”php:8.4-fpmor-apacheinstead of-cli. Use the SAPI your app actually serves under. The extension list is identical; only the base tag and theCMD/entrypoint differ. For a queue worker or a CLI batch job,-cliis correct.- Alpine (
php:8.4-alpine) needs different package names. Theapt-getlines above are for the Debian-based default image. On Alpine, install the*-devheaders as a virtual build group (apk add --no-cache --virtual .build-deps icu-dev libpng-dev freetype-dev libjpeg-turbo-dev oniguruma-dev) and, after thedocker-php-ext-install gd intl mbstring opcachestep,apk del .build-deps— but firstapk add --no-cachethe runtime libraries the extensions link against (icu-libs,libpng,freetype,libjpeg-turbo,oniguruma) so deleting the build group does not unlinkintl.so/gd.so/mbstring.so. This is the same keep-the-runtime-libs rule the Debian block enforces withapt-mark. - Do not install
fonts-*packages. They are invisible to the native engine. Bundle font files instead — see the fonts page linked above. - Premium and ionCube are a different image concern. The ionCube-encoded NextPDF Pro / Enterprise builds need the ionCube Loader installed in the image and matched to the container’s exact PHP build (8.4, NTS vs. ZTS). That is out of scope for a core image; if you deploy premium, follow the Docker section of ionCube Loader setup.
- Keep a host
vendor/out of the build context. The.dockerignore(excludingvendor/,.git/, and local caches) keeps the host tree out of the context entirely — that is what makes the build small, fast, and free of leaked local secrets. It also guards the directory-merge case: a directoryCOPYis a merge, not a replace, so a host-builtvendor/that reached the context would land first and theCOPY --from=vendor /app/vendor /var/www/app/vendorwould only overwrite the paths the clean dependency tree contains. In this Dockerfile theRUN rm -rf /var/www/app/vendorbefore the vendor copy already removes any such directory, so that residue cannot occur here; the merge risk only returns if you drop thatrm -rfsafeguard, which is why the.dockerignoreexclusion is the durable fix.
Security notes
Section titled “Security notes”- Ship no dev dependencies.
--no-devkeeps the test and analysis tooling, and their transitive packages, out of the runtime image and its attack surface. - Run unprivileged. The final
USER appuserensures no container process runs as root. Pair it with a read-only root filesystem and dropped capabilities in your orchestrator. - Pin the base image. Pin
php:8.4to a digest in production so a rebuild cannot silently pull a changed base, and rebuild on a cadence to pick up security patches deliberately. - Keep fonts and licenses out of public layers. Bundle only fonts you are licensed to embed, and never bake a premium license file into a publicly pushed image — mount it at runtime instead.
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 verification command is the real nextpdf CLI
doctor handler — declared as "bin": ["bin/nextpdf"] in nextpdf/core and
therefore installed at vendor/bin/nextpdf in a consuming app — which reports on
the same extension set. The native engine
registers fonts through NextPDF\Typography\FontRegistry (the directory
constructor argument / addFontDirectory()) wired via
NextPDF\Core\DocumentFactory; 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 registry
behavior is documented on the fonts page linked under See also.
See also
Section titled “See also”- Provision fonts for the native engine in production: the font-file naming, registry API, and warmup-and-lock pattern this image relies on.
- Stream a large generated PDF as an HTTP response: the memory model for serving a built document from a framework controller.
- Render at the edge with Cloudflare: when an in-process container is not the right runtime.
- ionCube Loader setup: the separate image concern for ionCube-encoded premium builds.