Skip to content

Render at the edge with Cloudflare, with local fallback

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/cloudflare are 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 the bridge, a PSR-18 client, and PSR-17 factories.

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

For local fallback, install a local renderer that the bridge can call.

Terminal window
composer require nextpdf/artisan

Load the Worker bearer token and any R2 credentials from environment variables or a secrets manager. Never commit them.

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.

// 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 = []): CloudflareRenderResult
CloudflareHtmlRenderer::isAvailable(): bool

render() 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.

Create the config, build the renderer, render, and write the bytes.

edge-quickstart.php
<?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.

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.

ArtisanLocalRendererFactory.php
<?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.

build the production 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.

  • A Worker error is not a reachability failure. A Worker that returns an HTTP error or malformed body raises CloudflareRenderException and 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: true but no factory wired, an unreachable Worker raises CloudflareNotAvailableException and names the missing factory. Wire the factory.
  • isAvailable() is a hint, not a guarantee. It sends an authenticated HEAD and returns true for a status below 500; the following POST can 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.
  • fontFiles needs an R2 bucket. The fontFiles argument matters only when the config sets r2FontBucket; 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.

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.

  • 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. apiToken and R2 keys carry #[SensitiveParameter], so stack traces redact them, and the config objects are final readonly. Load secrets from the environment or a secrets manager; never commit them.
  • Never write an empty catch block. 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.

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.