Headless Console Sorcery: Running Off Browser Scripts with Puppeteer inside Docker


Automating a bit of JavaScript in the browser console can save hours of repetitive clicking, but doing it reliably in a containerised CI or server environment introduces a fresh set of head-scratchers.
This post walks through the approach I landed on after “Dockerizing” a Puppeteer helper that injects arbitrary snippets, waits for their results, and fails gracefully when something goes wrong. You’ll find:
A minimal Dockerfile that keeps images light yet Chromium-ready
Launch flags that make Chrome behave inside a container (and why they matter)
A reusable
evaluateInBrowser()
utility, complete with fall-back HTML dumps for post-mortem debuggingA checklist of gotchas (shared memory, sandboxing, time-outs, and more)
1. Why Docker complicates Puppeteer
Running Puppeteer locally is pretty painless: npm i puppeteer
downloads its own Chromium build, you call launch()
, and off it goes.
Inside Docker you must also contend with:
Issue | Symptom | Fix |
Sandbox security | Chrome exits immediately with “No usable sandbox!” | Run with --no-sandbox and --disable-setuid-sandbox |
Shared memory | Random page crashes; blank screenshots | Add --disable-dev-shm-usage or mount a larger /dev/shm |
Zombie processes | Stale Chrome instances after container stop | Use Docker’s --init flag or an entry-point that reaps processes |
Huge image sizes | 1-GB-plus fat images | Install only the libraries Chromium needs & clean the apt cache |
Headless timing quirks | Works locally, hangs in CI | Pick the right waitUntil strategy (networkidle2 rarely lies) |
The official Puppeteer documentation lists the up-to-date matrix of required Linux packages and flags, worth bookmarking.
2. A lean Dockerfile
Below is a Node 18 Alpine image that weighs in under 300 MB yet happily launches Chrome:
# docker/Dockerfile
FROM mcr.microsoft.com/devcontainers/javascript-node:18-alpine
# Puppeteer needs these system libs
RUN apk add --no-cache \
udev ttf-freefont \
chromium \
nss
# Don’t let npm re-download its own Chromium
ENV PUPPETEER_SKIP_DOWNLOAD=1 \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENTRYPOINT ["dumb-init", "--"] # keeps child processes in line
CMD ["node", "index.js"]
Notes
Alpine’s
chromium
package matches Puppeteer’s API most of the time. Pin versions if you need pixel-perfect rendering.PUPPETEER_SKIP_DOWNLOAD
avoids doubling your image size with an extra Chromium bundle.dumb-init
(or Docker’s--init
) cleans up Chrome zombies on SIGTERM.
3. The evaluateInBrowser
helper
const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');
async function evaluateInBrowser(url, scripts, headless = true) {
const browser = await puppeteer.launch({
headless,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, // works locally & in Docker
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage'
],
timeout: 120000
});
const page = await browser.newPage();
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36'
);
await page.goto(url, { waitUntil: 'networkidle2', timeout: 120000 });
// Run each snippet in the page context and collect its result
const results = await page.evaluate(async (snippets) => {
const run = (code) =>
eval(`(async () => { ${code} })()`); // caller supplies async/sync code
const pairs = await Promise.all(
Object.entries(snippets).map(async ([k, v]) => ({ [k]: await run(v) }))
);
return Object.assign({}, ...pairs);
}, scripts);
// Optional: if nothing came back, dump the HTML to debug later
if (Object.values(results).every((v) => v == null || v === '')) {
const title = (await page.title()).replace(/[\\/:*?"<>|]/g, '_');
await fs.writeFile(path.join('debug', `${title}.html`), await page.content());
}
await browser.close();
return results;
}
module.exports = { evaluateInBrowser };
Why these particular changes?
Change | Why it matters |
Replaced new Function with eval | Keeps the code shorter and preserves this if needed (still runs in page context, use with care) |
networkidle2 wait-strategy | Works better for SPAs that keep a long-polling websocket open; stops after 500 ms of silence |
Recent desktop UA string | Some sites block obvious headless browsers; spoofing buys time until they tighten detection |
HTML dump on empty results | Saves the rendered DOM so you can replay bugs offline, especially useful when the CI job is gone |
4. Running it
docker build -t console-runner .
docker run --rm \
-e PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
console-runner \
"https://example.com" \
'{"title":"return document.title;","h1":"return document.querySelector(\"h1\").textContent;"}'
Behind the scenes the container launches Chromium headlessly with the right flags, each snippet is evaluated in the page context, and the combined JSON results are streamed back to Node for further processing.
5. Troubleshooting checklist
Hangs at
goto()
• Try switchingwaitUntil
todomcontentloaded
for heavily client-rendered apps.ERR_INSUFFICIENT_RESOURCES
• Increase shared memory:docker run --shm-size=1gb …
Font-related “tofu” characters
• Installttf-freefont
or the specific fonts your page requires.Chromium not found
• PinPUPPETEER_EXECUTABLE_PATH
to your system Chrome, or let Puppeteer download its own build by removing the env variable.
6. Final thoughts
With the right launch flags and a disciplined Dockerfile, Puppeteer becomes a rock-solid way to replay those “one-off” console hacks programmatically, whether you’re scraping data, generating reports, or just pushing a button hidden behind ten clicks.
Subscribe to my newsletter
Read articles from Amir Yurista directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
