Render at the edge with Cloudflare, with local fallback
At a glance
Section titled “At a glance”The Cloudflare bridge sends your HTML to a Cloudflare Worker render endpoint and
returns the PDF. Rendering runs at the edge, so you do not operate a long-lived
browser process. You create an HTTPS-only config, wire a PHP Standards
Recommendation (PSR)-18 client and PSR-17 factories, call render(), and can
add a local renderer for unreachable Workers. This guide shows the render call,
the fallback path, and the Server-Side Request Forgery (SSRF), Domain Name
System (DNS)-rebinding, and Transport Layer Security (TLS) public-key-pinning
controls the bridge enforces before any request leaves the process.
Prerequisites, up front:
- NextPDF core and
nextpdf/cloudflareare installed. - A Worker endpoint serves the render contract over HTTPS and accepts a bearer token. The bridge rejects a non-HTTPS Worker URL before it sends anything.
- A PSR-18 client (for example Guzzle 7) and PSR-17 request and stream
factories are available. For the pinned cURL transport, also provide a PSR-17
response factory and
ext-curl. - For local fallback,
nextpdf/artisan(or another local renderer) is available.
This is a how-to. For your first runnable render, start with the Cloudflare quickstart.
Install
Section titled “Install”Install the bridge, a PSR-18 client, and PSR-17 factories.
composer require nextpdf/cloudflare guzzlehttp/guzzleFor local fallback, install a local renderer that the bridge can call.
composer require nextpdf/artisanLoad the Worker bearer token and any R2 credentials from environment variables or a secrets manager. Never commit them.
Conceptual overview
Section titled “Conceptual overview”CloudflareHtmlRenderer::render() validates the HTML and the destination,
sends an authenticated POST to the Worker, and parses the response. The
Worker returns raw PDF bytes (Content-Type: application/pdf) or a JSON body
with a base64 pdf field. The renderer maps the response to a final readonly CloudflareRenderResult that carries the bytes, requested width, height, render
location (derived from the CF-Ray header), and render time.
The bridge separates failures into two explicit classes:
CloudflareRenderException— the Worker answered but the render failed (an HTTP error or a body that does not start with%PDF). This is a render failure and is never retried with a fallback.CloudflareNotAvailableException— the edge could not be reached and no usable fallback was available.
Local fallback covers the second case. When the Worker cannot be reached and
fallbackToLocal is true, the bridge calls the
LocalRendererFactoryInterface you provide. It does this lazily: the factory’s
create() runs only on the fallback path. On a fallback render, the result’s
renderLocation is the literal string local.
The bridge protects the network boundary before any request leaves PHP. It
rejects a non-HTTPS Worker URL. It rejects a Worker host that resolves to
private or reserved address space, checking all A and AAAA records rather
than only the first. It also re-resolves the host immediately before connecting,
which closes the time-of-check/time-of-use (TOCTOU) window against DNS
rebinding. When you provide a PSR-17 response factory and either a resolved IP
set or Subject Public Key Info (SPKI) pins, the bridge uses a pinned cURL
transport. That transport binds the connection to the vetted IPs
(CURLOPT_RESOLVE), enforces TLS public-key pinning
(CURLOPT_PINNEDPUBLICKEY), verifies the peer and host, and does not follow
redirects.
API surface
Section titled “API surface”// Configuration (final readonly):new CloudflareRendererConfig( string $workerUrl, // required, must be HTTPS string $apiToken, // required, #[SensitiveParameter] int $renderTimeout = 30, string $defaultCss = '', int $maxHtmlSize = 5_000_000, ?string $r2FontBucket = null, bool $fallbackToLocal = true, list<string> $pinnedPublicKeys = [], // sha256/<base64> list<string> $backupPublicKeys = [],)CloudflareRendererConfig::fromArray(array $config): self
// The renderer:new CloudflareHtmlRenderer( CloudflareRendererConfig $config, ClientInterface $httpClient, // PSR-18 RequestFactoryInterface $requestFactory, // PSR-17 StreamFactoryInterface $streamFactory, // PSR-17 ?LoggerInterface $logger = null, // PSR-3 ?LocalRendererFactoryInterface $localRendererFactory = null, ?HtmlSecurityPolicyInterface $htmlSecurityPolicy = null, ?ResponseFactoryInterface $responseFactory = null, // enables pinned transport)CloudflareHtmlRenderer::render(string $html, float $widthPt = 595.28, float $heightPt = 0.0, list<string> $fontFiles = []): CloudflareRenderResultCloudflareHtmlRenderer::isAvailable(): boolrender() defaults to A4 width (595.28 points) and auto-detected height
(heightPt: 0). For the full field reference and the fromArray() key map, see
the Cloudflare configuration page under See also.
Code sample — Quick start
Section titled “Code sample — Quick start”Create the config, build the renderer, render, and write the bytes.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;use GuzzleHttp\Psr7\HttpFactory;use NextPDF\Cloudflare\CloudflareHtmlRenderer;use NextPDF\Cloudflare\CloudflareRendererConfig;use NextPDF\Cloudflare\Exception\CloudflareNotAvailableException;use NextPDF\Cloudflare\Exception\CloudflareRenderException;
$config = new CloudflareRendererConfig( workerUrl: 'https://pdf-renderer.example.workers.dev/render', apiToken: getenv('CF_PDF_TOKEN') ?: throw new RuntimeException('CF_PDF_TOKEN not set'),);
$httpFactory = new HttpFactory();
$renderer = new CloudflareHtmlRenderer( config: $config, httpClient: new Client(), requestFactory: $httpFactory, streamFactory: $httpFactory, responseFactory: $httpFactory, // enables the pinned cURL transport);
try { $result = $renderer->render('<h1>Hello from the edge</h1>');
if (!$result->isValid()) { throw new RuntimeException('Worker did not return a valid PDF'); }
file_put_contents('output.pdf', $result->pdfData);} catch (CloudflareRenderException $exception) { // Worker answered but the render failed. Not retried with fallback. fwrite(STDERR, 'Render failed: ' . $exception->getMessage() . PHP_EOL); exit(1);} catch (CloudflareNotAvailableException $exception) { // Edge unreachable and no usable fallback. fwrite(STDERR, 'Edge unavailable: ' . $exception->getMessage() . PHP_EOL); exit(2);}The token comes from the environment and is never hard-coded. workerUrl must
use HTTPS; the bridge rejects an http:// URL before it sends any request.
Code sample — Production
Section titled “Code sample — Production”In production, wire a local renderer factory so an unreachable Worker falls back
instead of failing the request. Configure TLS pins with a backup pin. The
factory’s create() runs only on the fallback path.
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final readonly class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface{ public function __construct(private ChromeHtmlRenderer $chrome) {}
public function create(): LocalRendererInterface { return new readonly class($this->chrome) implements LocalRendererInterface { public function __construct(private ChromeHtmlRenderer $chrome) {}
/** @param array<string, mixed> $options */ public function render(string $html, array $options = []): string { $widthPt = (float) ($options['widthPt'] ?? 595.28); // A4 width $heightPt = (float) ($options['heightPt'] ?? 0.0); // 0 = auto-fit
return $this->chrome->render($html, $widthPt, $heightPt)->getPdfData(); } }; }}Wire the factory and pins into the renderer.
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\CloudflareHtmlRenderer;use NextPDF\Cloudflare\CloudflareRendererConfig;
$config = CloudflareRendererConfig::fromArray([ 'worker_url' => getenv('CF_WORKER_URL') ?: '', 'api_token' => getenv('CF_PDF_TOKEN') ?: '', 'render_timeout' => 60, 'fallback_to_local' => true, 'pinned_public_keys' => ['sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='], 'backup_public_keys' => ['sha256/Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys='],]);
$renderer = new CloudflareHtmlRenderer( config: $config, httpClient: $httpClient, requestFactory: $httpFactory, streamFactory: $httpFactory, logger: $logger, localRendererFactory: new ArtisanLocalRendererFactory($chrome), responseFactory: $httpFactory,);When fallback runs, the result’s renderLocation is local and heightPt is
0.0. The bridge logs the fallback at warning, then info. Always configure a
backup pin before certificate rotation, so a planned rotation does not lock the
bridge out of the endpoint.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- A Worker error is not a reachability failure. A Worker that returns an
HTTP error or malformed body raises
CloudflareRenderExceptionand is never retried with the fallback. Only an unreachable edge falls back. Keep the two catch arms distinct. - Fallback needs both the flag and a factory. With
fallbackToLocal: truebut no factory wired, an unreachable Worker raisesCloudflareNotAvailableExceptionand names the missing factory. Wire the factory. isAvailable()is a hint, not a guarantee. It sends an authenticatedHEADand returnstruefor a status below500; the followingPOSTcan still fail. Do not treat it as a contract.- Pinning is opt-in. An empty pin set disables pinning. Use an empty set only with a stable, known certificate chain, and keep a backup pin once you pin.
fontFilesneeds an R2 bucket. ThefontFilesargument matters only when the config setsr2FontBucket; otherwise it has no effect.- The bridge does not sign. It returns PDF bytes. Render at the edge, then sign in your own process, so the signing key never crosses the edge boundary.
Performance
Section titled “Performance”Edge rendering moves the browser cost off your hosts. You still pay for one
HTTPS round trip to the Worker plus the Worker’s render time, which the result
reports as renderTimeMs. The bridge applies the configured timeout through the
pinned transport. Set it from measured Worker latency with headroom, and keep it
below any upstream gateway timeout. The package states only the limits it
enforces itself. It makes no claim about Cloudflare platform CPU, memory, or
request-body ceilings. For those limits, consult Cloudflare’s documentation and
your Worker.
Security notes
Section titled “Security notes”- The destination is validated before the request leaves PHP. Non-HTTPS URLs are rejected. A host that resolves to private or reserved address space is rejected across all A and AAAA records. The host is re-resolved immediately before connecting, to defend against DNS rebinding.
- The pinned transport binds DNS and TLS. With a response factory and pins configured, the bridge binds the connection to the vetted IPs, enforces SPKI pinning, verifies peer and host, and refuses to follow redirects to an unvetted host.
- Input is bounded. HTML over
maxHtmlSize(default 5 MB), an oversized base64 data URI, and any<meta http-equiv="refresh">tag are rejected before the request is sent. - Secrets are redacted and immutable.
apiTokenand R2 keys carry#[SensitiveParameter], so stack traces redact them, and the config objects arefinal readonly. Load secrets from the environment or a secrets manager; never commit them. - Never write an empty
catchblock. Each example catches the specific exception type and logs or exits with a defined code.
The full security model is on the Cloudflare security-and-operations page under See also. It covers the SSRF and DNS-rebinding defense, pinning operations, secret handling, and the relevant OWASP and RFC 7469 clauses.
Conformance
Section titled “Conformance”This guide makes no normative standards claim of its own. On the upstream Cloudflare security-and-operations and configuration pages, the bridge’s all-records DNS resolution and TOCTOU re-check map to OWASP SSRF prevention guidance, and its TLS-public-key pinning and backup-pin recovery map to RFC 7469. This cookbook page restates the usage and defers those citations to those pages. The bridge performs no signing and makes no signature-conformance claim.
See also
Section titled “See also”- Render HTML to PDF with the Artisan Chrome renderer — the in-process renderer used as the local fallback here.
- Cloudflare quickstart — the first edge render and the result model.
- Cloudflare security and operations — SSRF, DNS-rebinding, pinning, and secret rotation.
- Cloudflare production usage — fallback wiring, telemetry, R2 archival, and API protection.