Skip to content
getnextpdf.com

Containerize a NextPDF application

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.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 can run docker build against 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.

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:

ExtensionWhy the engine needs it
ext-mbstringMulti-byte string handling for text and encodings
ext-intlUnicode, locale, and internationalization support
ext-gdRaster image decoding and processing
ext-opensslCryptography for signing and secure hashing
ext-zlibStream (Flate) compression of PDF objects
ext-curlHTTP 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”).

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.*.local
var/cache/
storage/
node_modules/
*.log

Exclude 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 /app
COPY 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/app
RUN rm -rf /var/www/app/vendor
COPY --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/app
USER 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.

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.

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 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=1
opcache.enable_cli=0
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

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

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:

Terminal window
docker run --rm your-app:latest php vendor/bin/nextpdf doctor

A 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:

Terminal window
# Fail the pipeline if the engine's environment is not healthy.
docker run --rm your-app:latest php vendor/bin/nextpdf doctor || exit 1

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

  • php:8.4-fpm or -apache instead of -cli. Use the SAPI your app actually serves under. The extension list is identical; only the base tag and the CMD/entrypoint differ. For a queue worker or a CLI batch job, -cli is correct.
  • Alpine (php:8.4-alpine) needs different package names. The apt-get lines above are for the Debian-based default image. On Alpine, install the *-dev headers 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 the docker-php-ext-install gd intl mbstring opcache step, apk del .build-deps — but first apk add --no-cache the runtime libraries the extensions link against (icu-libs, libpng, freetype, libjpeg-turbo, oniguruma) so deleting the build group does not unlink intl.so / gd.so / mbstring.so. This is the same keep-the-runtime-libs rule the Debian block enforces with apt-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 (excluding vendor/, .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 directory COPY is a merge, not a replace, so a host-built vendor/ that reached the context would land first and the COPY --from=vendor /app/vendor /var/www/app/vendor would only overwrite the paths the clean dependency tree contains. In this Dockerfile the RUN rm -rf /var/www/app/vendor before the vendor copy already removes any such directory, so that residue cannot occur here; the merge risk only returns if you drop that rm -rf safeguard, which is why the .dockerignore exclusion is the durable fix.
  • Ship no dev dependencies. --no-dev keeps the test and analysis tooling, and their transitive packages, out of the runtime image and its attack surface.
  • Run unprivileged. The final USER appuser ensures 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.4 to 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.

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.