How to Fix Black Screenshots from PHP exec() on a Server
Diagnose black PHP screenshots by checking the worker environment, Chrome output, navigation timing, permissions, and ImageMagick policies.
A black screenshot from PHP exec() usually means the command ran but produced no rendered page, or that the command failed and your code ignored the error. Diagnose it layer by layer: log the exact command and environment, run the renderer as the PHP worker user, use absolute paths, capture stderr and the exit code, verify the output file and pixels, then add browser options one at a time.
This guide shows a local Chrome/Chromium workflow, PHP examples, ImageMagick checks, security practices, and a hosted alternative.
1. Confirm what “black” means
Before changing browser flags, determine whether you have:
- A missing file or a zero-byte file.
- A valid image with zero width or height.
- A valid image whose pixels are all (or nearly all) black.
- A screenshot of a page that loaded a black canvas, video, or protected content.
- A valid browser screenshot that became black during ImageMagick conversion.
exec() only executes a command; it does not guarantee that a renderer created a valid image. PHP documents the output-array and result-code arguments in the exec() reference.
2. Add observability before changing the command
Use a private temporary directory and record the effective user, environment, output path, stderr, and numeric exit code. The following diagnostic endpoint is intentionally explicit so the first failing layer is visible.
<?php
declare(strict_types=1);
$url = 'https://developer.chrome.com/';
$base = '/var/www/app/tmp/screenshot-debug';
$output = $base . '/screenshot.png';
$log = $base . '/renderer.log';
if (!is_dir($base) && !mkdir($base, 0700, true)) {
throw new RuntimeException('Cannot create private temporary directory');
}
if (!is_writable($base)) {
throw new RuntimeException('Temporary directory is not writable');
}
$chrome = '/usr/bin/google-chrome'; // Replace with the absolute path on this host.
$command = implode(' ', [
escapeshellcmd($chrome),
'--headless',
'--disable-gpu',
'--no-sandbox', // Only use when your deployment requires it; see the security section.
'--screenshot=' . escapeshellarg($output),
'--window-size=412,892',
escapeshellarg($url),
]);
$start = microtime(true);
$stdout = [];
$exitCode = -1;
exec($command . ' 2>&1', $stdout, $exitCode);
$elapsedMs = (int) round((microtime(true) - $start) * 1000);
$diagnostic = [
'command' => $command,
'stdout_and_stderr' => $stdout,
'exit_code' => $exitCode,
'user' => function_exists('posix_geteuid') ? posix_geteuid() : 'posix extension unavailable',
'user_name' => function_exists('posix_getpwuid') && function_exists('posix_geteuid')
? (posix_getpwuid(posix_geteuid())['name'] ?? 'unknown')
: 'unknown',
'path' => getenv('PATH') ?: '',
'home' => getenv('HOME') ?: '',
'display' => getenv('DISPLAY') ?: '',
'cwd' => getcwd(),
'output' => $output,
'exists' => is_file($output),
'bytes' => is_file($output) ? filesize($output) : 0,
'elapsed_ms' => $elapsedMs,
];
file_put_contents(
$log,
json_encode($diagnostic, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
if ($exitCode !== 0 || !is_file($output) || filesize($output) === 0) {
http_response_code(502);
echo 'Renderer failed; inspect the protected diagnostic log.';
exit;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($output);
if ($mime !== 'image/png') {
http_response_code(502);
echo 'Renderer returned an unexpected file type: ' . htmlspecialchars((string) $mime);
exit;
}
header('Content-Type: image/png');
readfile($output);
Do not expose the raw command, environment, URL, or stderr to an untrusted browser. Store logs outside the public document root, restrict permissions, and rotate them.
3. Run the same command manually as the PHP worker
An SSH shell commonly has a different user, PATH, HOME, current directory, temporary directory, and DISPLAY than PHP-FPM or Apache. Find the service account in your process manager configuration, then run the renderer as that account.
# Identify the PHP-FPM pool user (inspect your pool configuration first)
ps aux | grep '[p]hp-fpm'
# Create a private directory owned by that account
sudo install -d -m 700 -o www-data -g www-data /var/www/app/tmp/screenshot-debug
# Run with an absolute executable and absolute output path
sudo -u www-data /usr/bin/google-chrome \
--headless \
--disable-gpu \
--screenshot=/var/www/app/tmp/screenshot-debug/manual.png \
--window-size=412,892 \
https://developer.chrome.com/
Replace www-data and the Chrome path with values from your host. If this fails, PHP is not the root cause yet: fix the account, executable, libraries, sandbox, DNS, certificates, proxy, or filesystem access first.
4. Establish a minimal Chrome baseline
Chrome’s documented baseline is a headless screenshot with an explicit viewport:
chrome --headless --screenshot --window-size=412,892 https://developer.chrome.com/
The command writes screenshot.png in the current directory. The headless-shell documentation also shows the supported pattern with --disable-gpu:
chrome --headless --disable-gpu --screenshot --window-size=412,892 https://developer.chrome.com/
Use an absolute output path when invoking it from PHP. Capture stderr and check the exit code rather than assuming a file means success.
Common flags to add only after the baseline works
| Need | Typical option or action | What to verify |
|---|---|---|
| Viewport | --window-size=1440,900 |
The page uses responsive breakpoints expected by your application. |
| Full page | Use a browser automation library or DevTools protocol capture. | Lazy-loaded images appear after scrolling or an explicit load step. |
| JavaScript | Wait for navigation or a known selector before capture. | Framework hydration and API calls finish before the screenshot. |
| Fonts | Install required fonts for the service image. | Font files are reachable and writable caches are available. |
| Authentication | Pass cookies or headers through your automation layer. | Credentials are not placed in shell strings or logs. |
| Sandbox | Prefer the Chrome sandbox; only change it when your runtime requires it. | The browser runs with the least privilege your deployment supports. |
5. Wait for the page to render
A browser can exit successfully before the first meaningful paint. Pages that depend on CSS, fonts, images, JavaScript, or API responses need a navigation wait and often a readiness condition.
The chrome-php library exposes waitForNavigation(), screenshot formats, clipping, and full-page capture. A minimal library-based example is:
<?php
require __DIR__ . '/vendor/autoload.php';
use HeadlessChromium\BrowserFactory;
$browserFactory = new BrowserFactory('/usr/bin/google-chrome');
$browser = $browserFactory->createBrowser([
'headless' => true,
'noSandbox' => false,
'windowSize' => [1440, 900],
]);
try {
$page = $browser->createPage();
$page->navigate('https://developer.chrome.com/')->waitForNavigation();
// If the page has a stable readiness selector, wait for it in your page logic.
$page->screenshot([
'format' => 'png',
'captureBeyondViewport' => true,
])->saveToFile('/var/www/app/tmp/screenshot-debug/library.png');
} finally {
$browser->close();
}
When diagnosing, begin with a static page. Then add JavaScript waits, fonts, authentication, full-page capture, and post-processing one at a time. This isolates whether the failure is navigation, rendering, or conversion.
6. Check the server environment
| Check | Failure symptom | Fix |
|---|---|---|
| Executable path | exec() returns quickly and no image exists. |
Use an absolute Chrome path; set a controlled PATH only if needed. |
| PHP-FPM user | Works over SSH, fails through the web request. | Run manually as the PHP worker account and grant only required directory access. |
| Working directory | Output is created somewhere unexpected. | Use absolute paths and log getcwd(). |
HOME |
Browser profile or cache cannot be created. | Set a private writable home or temporary directory for the worker. |
DISPLAY |
Non-headless tools report display errors. | Use a true headless mode, or configure an X server only when the tool requires one. |
| DNS, TLS, proxy | Blank page, timeout, or certificate error. | Test the URL as the service account and fix egress, trust store, DNS, or proxy settings. |
| Fonts and libraries | Blank or malformed layout, missing glyphs. | Install runtime dependencies and fonts in the server image; verify readable paths. |
| Temp space | Intermittent failures under load. | Monitor disk and inode usage; clean old profiles and screenshots safely. |
7. Diagnose ImageMagick black output
If Chrome creates a correct image but a later ImageMagick command turns it black, isolate conversion. ImageMagick operations can depend on an X server or DISPLAY, alter channels, or create black canvases. Its command-line documentation describes display behavior and image operations.
Read the active policy.xml. ImageMagick policies can restrict delegates and coders, paths, memory, disk space, pixel dimensions, image count, and runtime. The security policy documentation explains these limits and the resulting policy errors.
# Inspect the active policy and version
identify -list policy
magick -version
# Inspect the source image without converting it
identify -verbose /var/www/app/tmp/screenshot-debug/manual.png
# Convert with stderr captured
magick /var/www/app/tmp/screenshot-debug/manual.png \
-alpha on \
/var/www/app/tmp/screenshot-debug/converted.png \
2>/var/www/app/tmp/screenshot-debug/imagemagick.err
Look for explicit policy-denied coder, delegate, memory, disk, or pixel-cache errors. Fix the specific policy or operation in a controlled deployment; do not weaken global policy simply to hide an error.
8. Inspect dimensions and pixels
Dimensions and a few sampled pixels distinguish a missing render from a legitimate dark page.
<?php
$file = '/var/www/app/tmp/screenshot-debug/screenshot.png';
if (!is_file($file) || filesize($file) === 0) {
throw new RuntimeException('No image output');
}
$info = getimagesize($file);
if ($info === false) {
throw new RuntimeException('Output is not a readable image');
}
$image = imagecreatefrompng($file);
$points = [
[0, 0],
[intdiv($info[0], 2), intdiv($info[1], 2)],
[$info[0] - 1, $info[1] - 1],
];
foreach ($points as [$x, $y]) {
$rgb = imagecolorat($image, $x, $y);
printf("%d,%d => #%06x\n", $x, $y, $rgb & 0xFFFFFF);
}
echo "dimensions={$info[0]}x{$info[1]} bytes=" . filesize($file) . PHP_EOL;
A black page can be valid application output, so compare with a known static URL and inspect the browser’s stderr and navigation errors before treating pixels as proof of a browser failure.
9. Secure PHP command construction
PHP’s manual warns: “When allowing user-supplied data to be passed to this function, use escapeshellarg() or escapeshellcmd() to ensure users cannot trick the system into executing arbitrary commands.”
- Allow-list URL schemes, hosts, flags, viewport values, and output formats.
- Use
escapeshellarg()for each data argument andescapeshellcmd()only for a fixed executable path. - Never concatenate an unvalidated URL into shell syntax.
- Keep output directories outside the public root and use unpredictable filenames.
- Run Chrome with the least privilege available; avoid disabling the sandbox unless required by the deployment.
- Limit request time, output size, temporary disk usage, and concurrent browser processes.
- Redact cookies, authorization headers, and signed URLs from logs.
- Redirect stderr to a protected, rotated log.
10. A production-oriented PHP wrapper
This wrapper fails closed on non-zero exit codes, missing files, zero-byte output, and unexpected MIME types.
<?php
declare(strict_types=1);
function captureScreenshot(string $url, string $output): void
{
if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new InvalidArgumentException('Only valid HTTP(S) URLs are allowed');
}
$chrome = '/usr/bin/google-chrome';
$directory = dirname($output);
if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
throw new RuntimeException('Cannot create output directory');
}
$args = [
escapeshellcmd($chrome),
'--headless',
'--disable-gpu',
'--screenshot=' . escapeshellarg($output),
'--window-size=1440,900',
escapeshellarg($url),
];
$lines = [];
$code = -1;
exec(implode(' ', $args) . ' 2>&1', $lines, $code);
if ($code !== 0) {
error_log('Screenshot renderer failed: exit=' . $code . ' stderr=' . implode("\n", $lines));
throw new RuntimeException('Screenshot renderer failed');
}
if (!is_file($output) || filesize($output) === 0) {
throw new RuntimeException('Screenshot output is missing or empty');
}
if ((new finfo(FILEINFO_MIME_TYPE))->file($output) !== 'image/png') {
throw new RuntimeException('Screenshot output is not PNG');
}
}
captureScreenshot(
'https://developer.chrome.com/',
'/var/www/app/tmp/screenshots/example.png'
);
11. Performance, reliability, and cost
Performance
- Reuse a browser process or a managed browser pool when your library supports it; launching Chrome for every request adds cold-start time.
- Set a navigation timeout and an overall request timeout.
- Use a smaller viewport and avoid full-page capture when the use case needs only the visible viewport.
- Wait for a meaningful selector instead of sleeping for an unnecessarily long fixed delay.
- Limit concurrent browsers according to CPU, memory, file descriptors, and temporary disk capacity.
- Cache deterministic captures when the page and options are unchanged.
Reliability
- Record URL, option set, browser version, exit code, elapsed time, output bytes, dimensions, and a redacted error.
- Retry transient DNS, connection, and navigation failures with bounded backoff; do not blindly retry invalid URLs or policy errors.
- Use atomic output publication: write to a temporary filename, validate it, then rename it into the final location.
- Clean abandoned browser profiles and temporary files.
- Keep the browser, fonts, PHP extension set, and ImageMagick versions pinned in the deployment image.
Cost
Self-hosting shifts cost into CPU, memory, storage, operations, browser updates, and network egress. A hosted API trades installation and browser maintenance for per-capture pricing. Measure your actual page mix, wait times, concurrency, and retry rate before choosing.
12. Troubleshooting checklist
| Symptom | Likely cause | Action |
|---|---|---|
| Works in SSH, black through PHP | Different user, PATH, HOME, cwd, DISPLAY, or permissions. | Log those values and run the exact absolute command as the PHP worker. |
| Exit code is non-zero | Executable, dependency, sandbox, URL, DNS, TLS, or policy failure. | Read protected stderr; fix the first reported layer. |
| Exit code is zero, file is missing | Relative output path or renderer wrote elsewhere. | Use an absolute output path and log the current directory. |
| File is zero bytes | Write permission, disk/inode exhaustion, or interrupted process. | Check ownership, free space, and process logs. |
| Valid image but blank | Capture occurred before navigation, hydration, fonts, or images completed. | Wait for navigation and a readiness selector; test a static page. |
| Only authenticated pages fail | Cookies or authorization are absent in the worker context. | Pass credentials through a browser API securely; never put secrets in logs. |
| Only large/full-page captures fail | Memory, pixel, timeout, or temporary disk limits. | Reduce viewport/page size, inspect policy limits, and monitor resources. |
| Chrome image is correct; converted image is black | ImageMagick operation, channel handling, display dependency, or policy. | Inspect the original with identify, then run conversion with stderr captured. |
| Errors mention a display | A tool expects X11 instead of true headless mode. | Use the tool’s headless mode or configure the required display deliberately. |
| Intermittent failures under load | Process, memory, file descriptor, or temp-space exhaustion. | Cap concurrency, reuse browsers, clean temp files, and monitor limits. |
13. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It removes cookie and consent banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Responses identify the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for the complete option list. A minimal call is:
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', data);
ScreenshotNeo also supports full-page capture with lazy images loaded, CSS-element capture, dark mode, device presets or custom viewports, retina scale, PDF settings, HTML/CSS rendering, custom JavaScript and CSS, clicks, selector waits, delays, network-idle waits, request blocking, custom headers and cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan.
Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge and no card.
14. FAQ
Why is the PNG black but not empty?
The browser may have captured before first paint, failed to load page assets, rendered a black canvas, or produced a correct image that a later conversion damaged. Check navigation stderr, dimensions, sampled pixels, and the unconverted file.
Should I set --no-sandbox?
Only when your runtime cannot use the Chrome sandbox and you understand the privilege implications. Prefer fixing the container or service account so the sandbox remains enabled.
Is DISPLAY required for Chrome screenshots?
True headless Chrome does not need an interactive desktop display. A display error usually indicates that a different tool or mode expects X11.
How do I know whether ImageMagick is involved?
Save and inspect Chrome’s output before any conversion. If that file is correct, inspect the ImageMagick command, channels, active policy, and stderr.
What is the fastest isolation test?
As the PHP worker user, capture a known static URL with an absolute Chrome path, explicit viewport, absolute output path, stderr capture, and exit-code validation.


