Target: Ubuntu 22.04 / 24.04 servers with aaPanel
Stack: Laravel + Spatie Browsershot + Puppeteer + Chrome Headless Shell
Last Updated: May 2026
Table of Contents
- Overview
- Prerequisites
- Step 1: Install Node.js via aaPanel
- Step 2: Install Chrome System Dependencies
- Step 3: Install Puppeteer & Chrome Binary
- Step 4: Set Permissions for PHP (www user)
- Step 5: Configure Laravel Code
- Step 6: Verify Everything Works
- Troubleshooting
- Quick Setup Script
Overview
Browsershot uses Puppeteer (a Node.js library) to control a headless Chrome browser for rendering HTML to PDF. The chain is:
Laravel PHP → Browsershot (PHP) → Node.js → Puppeteer → Chrome Headless Shell → PDF
Key Paths on aaPanel
| Component | Typical aaPanel Path |
|---|---|
| Node.js binary | /www/server/nodejs/v{VERSION}/bin/node |
| NPM binary | /www/server/nodejs/v{VERSION}/bin/npm |
| Chrome binary | /root/.cache/puppeteer/chrome-headless-shell/linux-{VERSION}/chrome-headless-shell-linux64/chrome-headless-shell |
| Project root | /www/wwwroot/{your-domain}/ |
| PHP-FPM user | www (runs as /home/www/) |
[!CAUTION] The #1 gotcha: Chrome is installed into the root user's cache (
/root/.cache/puppeteer/), but PHP-FPM runs as the www user and looks in/home/www/.cache/puppeteer/— which is empty. You MUST either:
- Set
setChromePath()explicitly in code, OR- Install Chrome as the
wwwuser
Prerequisites
- Ubuntu 22.04 or 24.04
- aaPanel installed
- Node.js installed via aaPanel (App Store → Node.js)
- Laravel project with
spatie/browsershotcomposer package installed
Step 1: Install Node.js via aaPanel
- Log into aaPanel → App Store → Search Node.js → Install
- After installation, verify via SSH:
which node
which npm
node -v
npm -v
Expected output (example):
/www/server/nodejs/v22.22.2/bin/node
/www/server/nodejs/v22.22.2/bin/npm
v22.22.2
10.9.7
[!IMPORTANT] Write down these paths! You'll need them for the Laravel code configuration. aaPanel does NOT install Node at
/usr/bin/node— it uses/www/server/nodejs/v{VERSION}/bin/node.
Step 2: Install Chrome System Dependencies
Chrome headless requires X11 and other shared libraries even in headless mode. Ubuntu 24.04 renamed several packages with the t64 suffix.
Ubuntu 24.04
sudo apt-get update && sudo apt-get install -y \
libxfixes3 \
libatk1.0-0t64 \
libatk-bridge2.0-0t64 \
libcups2t64 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2t64 \
libnspr4 \
libnss3 \
libxshmfence1 \
fonts-liberation \
libappindicator3-1 \
xdg-utils
Ubuntu 22.04
sudo apt-get update && sudo apt-get install -y \
libxfixes3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libnspr4 \
libnss3 \
libxshmfence1 \
fonts-liberation \
libappindicator3-1 \
xdg-utils
[!NOTE] The difference between 22.04 and 24.04 is the
t64suffix on some packages (e.g.,libcups2vslibcups2t64). Ifaptcomplains at64package doesn't exist, try without the suffix, and vice versa.
Step 3: Install Puppeteer & Chrome Binary
3a. Find the required Chrome version
Check which Chrome version your Puppeteer expects:
cd /www/wwwroot/your-domain.com
node -e "const pkg = require('./node_modules/puppeteer-core/package.json'); console.log('Puppeteer Core:', pkg.version)"
cat node_modules/puppeteer-core/lib/cjs/puppeteer/revisions.js 2>/dev/null || \
node -e "try{const r=require('puppeteer-core/lib/cjs/puppeteer/revisions.js');console.log(r)}catch(e){console.log('Check package.json for browser version')}"
Or check the node_modules/puppeteer/package.json for "chrome-headless-shell" version.
3b. Install Chrome headless shell
Replace CHROME_VERSION with the version from step 3a (e.g., 146.0.7680.153):
cd /www/wwwroot/your-domain.com
# Method 1: Using @puppeteer/browsers (RECOMMENDED)
node -e "
const {install} = require('@puppeteer/browsers');
install({
browser: 'chrome-headless-shell',
buildId: 'CHROME_VERSION',
cacheDir: '/root/.cache/puppeteer'
}).then(r => console.log('✅ Installed at:', r.executablePath))
.catch(e => console.error('❌ Failed:', e.message))
"
# Method 2: Using npx (if puppeteer CLI works)
npx puppeteer browsers install chrome-headless-shell
# Method 3: If puppeteer CLI has permission issues
chmod +x node_modules/.bin/puppeteer
npx puppeteer browsers install chrome-headless-shell
# Method 4: Global install as last resort
npm install -g puppeteer
puppeteer browsers install chrome-headless-shell
3c. Find the installed Chrome path
find /root/.cache/puppeteer -name "chrome-headless-shell" -type f 2>/dev/null
Expected output (example):
/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell
[!IMPORTANT] Write down this path! You'll need it for
setChromePath()in the Laravel code.
Step 4: Set Permissions for PHP (www user)
PHP-FPM on aaPanel runs as the www user. By default, /root/ is not accessible to other users.
# Grant read+execute access to the Chrome binary path
chmod 755 /root
chmod 755 /root/.cache
chmod 755 /root/.cache/puppeteer
chmod -R 755 /root/.cache/puppeteer/chrome-headless-shell
Verify www user can access it
sudo -u www /root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell --version
If it prints a version number, permissions are correct.
[!WARNING] If you skip this step, you'll get one of these errors:
Could not find Chrome(Puppeteer looks in/home/www/.cache/puppeteer/which is empty)Permission denied(www user can't read/root/.cache/)
Step 5: Configure Laravel Code
In every place where Browsershot is used, set all three paths explicitly:
use Spatie\Browsershot\Browsershot;
$pdf = Browsershot::html($html)
->setNodeBinary('/www/server/nodejs/v22.22.2/bin/node') // ← from Step 1
->setNpmBinary('/www/server/nodejs/v22.22.2/bin/npm') // ← from Step 1
->setChromePath('/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell') // ← from Step 3c
->noSandbox()
->format('A4')
->margins(8, 8, 8, 8)
->showBackground()
->pdf();
Files to update in this project
| File | Method | Line (approx) |
|---|---|---|
app/Http/Controllers/School/Exam/MarkSheetController.php |
download() |
~164 |
app/Http/Controllers/School/Exam/MarkSheetController.php |
isNodeInstalled() |
~80 |
app/Jobs/GenerateBulkMarksheetJob.php |
handle() |
~129 |
[!TIP] When upgrading Node.js via aaPanel, the path changes (e.g.,
v22.22.2→v22.23.0). Search your codebase for the old version string and update all occurrences:grep -r "v22.22.2" app/
Step 6: Verify Everything Works
6a. Test Chrome directly
# As root
/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell \
--no-sandbox --disable-gpu --dump-dom https://example.com
6b. Test as the www user
sudo -u www /root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell \
--no-sandbox --disable-gpu --dump-dom https://example.com
6c. Test from Laravel (Tinker)
cd /www/wwwroot/your-domain.com
php artisan tinker
$pdf = \Spatie\Browsershot\Browsershot::html('<h1>Test PDF</h1>')
->setNodeBinary('/www/server/nodejs/v22.22.2/bin/node')
->setNpmBinary('/www/server/nodejs/v22.22.2/bin/npm')
->setChromePath('/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell')
->noSandbox()
->format('A4')
->pdf();
file_put_contents('/tmp/test.pdf', $pdf);
echo "PDF size: " . strlen($pdf) . " bytes\n";
If it prints a size > 0, everything is working.
6d. Restart services after any changes
# Restart PHP-FPM
systemctl restart php8.3-fpm # or php8.2-fpm, php8.1-fpm
# Restart queue workers (for bulk marksheet jobs)
cd /www/wwwroot/your-domain.com
php artisan queue:restart
# Or if using Supervisor
supervisorctl restart all
# Clear Laravel caches
php artisan config:clear
php artisan cache:clear
Troubleshooting
Error: libXfixes.so.3: cannot open shared object file
Cause: Missing system libraries for Chrome.
Fix: Run Step 2 (install Chrome dependencies).
To find ALL missing libraries:
ldd /root/.cache/puppeteer/chrome-headless-shell/linux-*/chrome-headless-shell-linux64/chrome-headless-shell | grep "not found"
Error: Could not find Chrome (ver. X.X.X.X) ... cache path is: /home/www/.cache/puppeteer
Cause: PHP runs as www user, Chrome installed in /root/.cache/puppeteer/.
Fix: Add ->setChromePath('/root/.cache/puppeteer/...') in code (Step 5) AND set permissions (Step 4).
Error: Failed to launch the browser process: Code: 127
Cause: Either Chrome binary missing or shared libraries missing.
Fix: Run Steps 2 and 3.
Error: Permission denied on puppeteer binary
Cause: The node_modules/.bin/puppeteer file lacks execute permission.
Fix:
chmod +x node_modules/.bin/puppeteer
Error: Cannot find module '../puppeteer.js'
Cause: Broken symlink in node_modules/.bin/.
Fix: Use the @puppeteer/browsers method instead (Step 3b, Method 1).
Error: Download failed: server returned code 404
Cause: Using stable as buildId instead of a specific version number.
Fix: Use the exact Chrome version (e.g., 146.0.7680.153).
PDF generates but images are missing
Cause: External image URLs can't be fetched by headless Chrome.
Fix: Use the HandlesPdfAssets trait to convert images to base64 data URIs before rendering.
Node.js version changed after aaPanel update
Cause: aaPanel updates Node to a new version, changing the binary path.
Fix:
which node # get new path
which npm # get new path
Then update all occurrences in the codebase.
Quick Setup Script
Save this as setup_browsershot.sh on new servers and run as root:
#!/bin/bash
set -e
# ============================================
# Browsershot Quick Setup for Ubuntu + aaPanel
# Run as root on a fresh server
# ============================================
CHROME_VERSION="146.0.7680.153"
PROJECT_DIR="/www/wwwroot/your-domain.com" # ← CHANGE THIS
echo "=== Step 1: Checking Node.js ==="
NODE_PATH=$(which node 2>/dev/null || echo "")
NPM_PATH=$(which npm 2>/dev/null || echo "")
if [ -z "$NODE_PATH" ]; then
echo "❌ Node.js not found. Install it via aaPanel first."
exit 1
fi
echo "✅ Node: $NODE_PATH ($(node -v))"
echo "✅ NPM: $NPM_PATH ($(npm -v))"
echo ""
echo "=== Step 2: Installing Chrome dependencies ==="
apt-get update -qq
apt-get install -y -qq \
libxfixes3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2t64 libnspr4 \
libnss3 libxshmfence1 fonts-liberation libappindicator3-1 xdg-utils \
2>/dev/null || \
apt-get install -y -qq \
libxfixes3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2 libnspr4 \
libnss3 libxshmfence1 fonts-liberation libappindicator3-1 xdg-utils
echo "✅ System dependencies installed"
echo ""
echo "=== Step 3: Installing Chrome Headless Shell ==="
cd "$PROJECT_DIR"
node -e "
const {install} = require('@puppeteer/browsers');
install({
browser: 'chrome-headless-shell',
buildId: '$CHROME_VERSION',
cacheDir: '/root/.cache/puppeteer'
}).then(r => {
console.log('✅ Chrome installed at:', r.executablePath);
}).catch(e => {
console.error('❌ Failed:', e.message);
process.exit(1);
});
"
echo ""
echo "=== Step 4: Setting permissions ==="
chmod 755 /root /root/.cache /root/.cache/puppeteer
chmod -R 755 /root/.cache/puppeteer/chrome-headless-shell
echo "✅ Permissions set"
echo ""
echo "=== Step 5: Verifying ==="
CHROME_PATH=$(find /root/.cache/puppeteer -name "chrome-headless-shell" -type f | head -1)
echo "Chrome path: $CHROME_PATH"
$CHROME_PATH --no-sandbox --disable-gpu --dump-dom https://example.com > /dev/null 2>&1 && echo "✅ Chrome works!" || echo "❌ Chrome failed to launch"
echo ""
echo "=== DONE ==="
echo ""
echo "Update your Laravel code with these paths:"
echo " ->setNodeBinary('$NODE_PATH')"
echo " ->setNpmBinary('$NPM_PATH')"
echo " ->setChromePath('$CHROME_PATH')"
echo ""
echo "Don't forget to restart PHP-FPM and queue workers!"
Usage
# 1. Upload to server
# 2. Edit PROJECT_DIR variable
# 3. Run:
chmod +x setup_browsershot.sh
./setup_browsershot.sh
Architecture Note
┌─────────────────────────────────────────────────────┐
│ Laravel (PHP-FPM, runs as 'www' user) │
│ │
│ Browsershot::html($html) │
│ ->setNodeBinary('/www/server/nodejs/.../node') │
│ ->setNpmBinary('/www/server/nodejs/.../npm') │
│ ->setChromePath('/root/.cache/puppeteer/...') │
│ ->noSandbox() │
│ ->pdf() │
└──────────────┬──────────────────────────────────────┘
│ spawns process
▼
┌─────────────────────────────────────────────────────┐
│ Node.js (browser.cjs from spatie/browsershot) │
│ - Reads the JSON config from PHP │
│ - Launches Puppeteer │
└──────────────┬──────────────────────────────────────┘
│ launches
▼
┌─────────────────────────────────────────────────────┐
│ Chrome Headless Shell │
│ - Renders HTML to PDF │
│ - Needs system libs (libXfixes, libgbm, etc.) │
│ - Must run with --no-sandbox as root/www │
└─────────────────────────────────────────────────────┘