ScreenshotNeo

BlogHow-to

How to Run Ubuntu in Headless Mode for Browser Automation

Set up Ubuntu over SSH, install Playwright or Puppeteer with browser dependencies, choose the right headless mode, and fix common launch failures.

By the ScreenshotNeo team1 October 202610 min read

Direct answer: Run Ubuntu Server without a desktop environment, administer it over SSH with keys, install a browser automation framework and its Linux dependencies, then launch Chromium in the framework’s supported headless mode. The operating system can be headless while the browser is either headless or headed inside a virtual display. For most CI and server jobs, Playwright with npx playwright install --with-deps chromium is the shortest supported setup. Puppeteer is a good alternative when you want its Chrome for Testing management.

What “headless Ubuntu” means

Two separate decisions are often confused:

  • Headless Ubuntu: The machine has no local desktop session or monitor. You connect over SSH and run services from a terminal.
  • Headless browser: Chromium or Chrome renders pages without opening a visible window.

A headless Ubuntu server can still run a headed browser under Xvfb for debugging. Conversely, a desktop Ubuntu installation can launch a browser headlessly. This guide uses Ubuntu Server and a real headless browser unless a debugging step says otherwise.

1. Prepare an Ubuntu host

Choose a supported Ubuntu Server release appropriate for your cloud VM, virtual machine, container host, or physical board. Ubuntu documentation currently lists Server guides for 22.04 LTS, 24.04 LTS, and 26.04 LTS; verify support status and package names for the release you actually deploy.

Before installing automation, record:

  • Ubuntu release: cat /etc/os-release
  • CPU architecture: dpkg --print-architecture
  • Available memory and disk: free -h and df -h
  • Whether the host is a VM, container, cloud instance, or physical machine
  • The browser and framework versions you intend to pin

Browser downloads and temporary profiles need disk space. Keep several hundred megabytes available for the browser, libraries, fonts, caches, and temporary screenshots; the exact requirement changes with framework and browser versions.

2. Connect securely over SSH

Create a normal administrator account during provisioning and use an SSH public key for unattended access. Canonical’s headless-board guidance recommends leaving password-based SSH authentication disabled because default or guessable passwords are a common entry point.

# From your workstation: create a key if you do not have one
ssh-keygen -t ed25519 -C "browser-automation"

# Copy the public key to the host (if password login is temporarily enabled)
ssh-copy-id automation@SERVER_IP

# Connect using the key
ssh automation@SERVER_IP

# Confirm the session and Ubuntu release
whoami
hostnamectl
cat /etc/os-release

On a local network, mDNS/Avahi may expose a .local hostname. Otherwise use the address assigned by your router or cloud provider. Restrict inbound SSH with your firewall or security group and keep the private key off the server.

3. Install base packages

sudo apt update
sudo apt install -y ca-certificates curl git unzip build-essential

# Check that Node.js and npm are available
node --version
npm --version

Install Node.js using the method approved for your organization, then pin the major version in CI. Distribution packages can lag behind current Node releases; the important requirement is a supported Node/npm pair for the framework version you select.

Playwright can install Chromium and the Linux libraries it needs in one command. In a new project:

mkdir -p ~/browser-job
cd ~/browser-job
npm init -y
npm install playwright
npx playwright install --with-deps chromium

The regular Playwright headless path uses a Chromium headless shell. If you need the newer Chrome headless implementation, select the chromium channel and install without the old shell:

npx playwright install --with-deps --no-shell chromium

Use --only-shell when the standalone headless shell is all you need and you want to avoid downloading the full browser. The exact options are versioned, so check the Playwright browser guide for the version in your lockfile.

Minimal Playwright capture

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1
  });

  await page.goto('https://example.com', {
    waitUntil: 'networkidle',
    timeout: 60_000
  });
  await page.screenshot({ path: 'example.png', fullPage: true });
  await browser.close();
})().catch(error => {
  console.error(error);
  process.exit(1);
});

For deterministic jobs, set an explicit viewport, timezone, locale, timeout, and browser version. Use waitUntil: 'domcontentloaded' for pages with long polling; networkidle can never settle on some applications.

5. Puppeteer setup

Installing puppeteer normally downloads a compatible Chrome for Testing build and a chrome-headless-shell. The default cache is $HOME/.cache/puppeteer.

mkdir -p ~/puppeteer-job
cd ~/puppeteer-job
npm init -y
npm install puppeteer

If your package manager blocks install scripts, the browser download may be skipped. Install it explicitly:

npx puppeteer browsers install

puppeteer-core does not download a browser. Use it when Chrome is managed by your base image, operating system, or a remote browser service, and pass an executable path or connection endpoint yourself.

Minimal Puppeteer capture

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
  await page.goto('https://example.com', {
    waitUntil: 'networkidle2',
    timeout: 60_000
  });
  await page.screenshot({ path: 'example.png', fullPage: true });
  await browser.close();
})().catch(error => {
  console.error(error);
  process.exit(1);
});

Puppeteer distinguishes its default headless mode, headless: 'shell', and headed mode. Choose deliberately when pixel output or browser behavior must match a particular Chrome release.

6. Choose the browser implementation deliberately

Goal Choice Reason
Fast CI screenshots with Playwright defaults Bundled Chromium headless shell Versioned with Playwright and designed for automation.
Behavior matching current Chrome headless Playwright chromium channel with --no-shell Uses the newer Chrome headless implementation.
Public-browser regression testing Installed branded Chrome or Edge Branded browsers can differ in codecs, policies, and release timing.
Legacy shell-specific behavior Standalone headless-shell binary Since Chrome 132, old headless-shell functionality is no longer part of the Chrome binary.

Do not assume that bundled Chromium, branded Chrome, Chrome for Testing, and headless-shell produce identical pixels. Pin the framework, browser, OS image, fonts, and locale when screenshot diffs matter.

7. Make captures reproducible

Wait for the right condition

  • Use a selector wait when a specific component must exist.
  • Use a short fixed delay only for known animation or hydration windows.
  • Use network idle only on pages that eventually become quiet.
  • Disable animations with injected CSS when visual comparisons require stable frames.
await page.goto('https://example.com/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-ready="true"]', { timeout: 30_000 });
await page.addStyleTag({ content: `
  *, *::before, *::after {
    animation-duration: 0s !important;
    transition-duration: 0s !important;
    caret-color: transparent !important;
  }
` });

Control environment inputs

Set viewport and device scale factor explicitly. Fix timezone and locale where your framework supports them. Install the fonts your pages require; missing fonts cause line wrapping and screenshot differences even when the browser launches correctly. Use a dedicated temporary profile per job to prevent extensions, cookies, and service workers from leaking between tests.

Handle lazy content and long pages

For full-page screenshots, scroll or trigger the page’s lazy loaders before capture. In Playwright, fullPage: true captures the full layout, but application code may still load images only after scrolling. A controlled scroll loop can force those requests before the final screenshot.

8. Diagnose launch failures

“Browser failed to launch” or missing shared library

The browser binary may exist while one of its native libraries does not. Inspect dependencies with ldd and read the first “not found” line:

# Find the executable path printed by your framework
ldd /path/to/chrome | grep 'not found'

Install the missing packages for your Ubuntu release and browser build. Common families include NSS, GBM, GTK, X11, Pango, fontconfig, and audio libraries, but do not copy an old package list blindly: the required set changes with Ubuntu and Chrome versions. Playwright’s --with-deps command is safer when it supports your release.

Sandbox errors

Keep Chromium’s sandbox enabled whenever possible. Puppeteer’s troubleshooting guidance strongly discourages running without a sandbox. Run the process as an appropriate unprivileged user, verify user namespaces and kernel policy, and configure the host or container rather than reflexively adding --no-sandbox. Use that flag only for a tightly isolated job processing content you absolutely trust.

Ubuntu 23.10 and later may apply an AppArmor profile to Chrome stable binaries that prevents downloaded Chrome for Testing builds from using user namespaces. Check the current Ubuntu, Chrome, and Puppeteer troubleshooting guidance for the release-specific remedy.

Browser downloaded, but the executable is missing

  • For Puppeteer, run npx puppeteer browsers install and check $HOME/.cache/puppeteer.
  • For Playwright, rerun npx playwright install chromium and confirm the command runs as the same user as the job.
  • In containers, make sure the cache is not discarded between image build and runtime.
  • Check that a package manager did not disable lifecycle scripts.

Blank page, timeout, or navigation never completes

Separate browser startup from page loading. First navigate to a static URL such as https://example.com. If that works, inspect DNS, outbound firewall rules, TLS certificates, proxy settings, authentication, and the target application’s JavaScript errors. Replace an unconditional networkidle wait with a selector or application readiness signal when the page uses websockets, analytics, or polling.

Fonts, emojis, or CJK text render incorrectly

Install the required fonts in the image, rebuild the font cache, and use the same font files in every environment. A browser can launch successfully while rendering fallback glyphs that change line breaks and image dimensions.

Works manually but fails in a service

Compare the service user, working directory, environment variables, HOME directory, proxy, PATH, and filesystem permissions. A systemd unit or container often has a different HOME, so the browser cache and profile path may not be where an interactive SSH session created them.

9. Run under systemd or CI

Use a dedicated unprivileged account, a fixed working directory, and explicit environment variables. Capture stdout and stderr so browser diagnostics survive after the job exits.

[Unit]
Description=Browser automation worker
After=network-online.target

[Service]
Type=simple
User=automation
WorkingDirectory=/opt/browser-job
ExecStart=/usr/bin/node /opt/browser-job/capture.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now browser-worker
sudo journalctl -u browser-worker -f

In CI, cache the framework’s browser directory only when the cache key includes the framework and browser versions. Reinstall or invalidate the cache after upgrading either one.

10. Performance, reliability, and cost

  • Reuse browsers: Launch one browser per worker and create short-lived contexts or pages. Repeated process startup is expensive.
  • Limit concurrency: More pages increase CPU, memory, file descriptors, and network pressure. Measure your target host before selecting a worker count.
  • Use smaller captures when possible: A fixed viewport and element screenshot use less memory than a very tall full-page image.
  • Set timeouts: Use separate launch, navigation, selector, and overall job timeouts so one stalled page cannot consume a worker forever.
  • Retry selectively: Retry transient DNS, connection-reset, and overloaded-origin failures. Do not blindly retry deterministic selector errors or authentication failures.
  • Record versions: Keep lockfiles and record Ubuntu, browser, framework, and font versions with screenshot artifacts.
  • Budget for infrastructure: Self-hosting costs the VM or container, storage, bandwidth, maintenance, and engineering time. Browser downloads and repeated cold starts add overhead.

Or skip the browser setup

If your goal is a clean website screenshot rather than operating Chromium yourself, ScreenshotNeo provides a single HTTP request and an MCP server for AI agents.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

See the ScreenshotNeo API documentation for the full parameter list. It supports PNG, JPEG, WebP, and PDF; full-page and element captures; dark mode; device presets and custom viewports; retina scale; custom CSS and JavaScript; clicks; selector, delay, and network-idle waits; request blocking; headers, cookies, user agents, and Authorization; timezone and geolocation; transparent backgrounds; resizing; caching with a chosen TTL; signed image links; asynchronous jobs with signed webhooks; bulk capture for up to 100 URLs per call; a usage API; and an OpenAPI specification.

Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot, with each cleanup step configurable. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers report the page verdict and whether it was billed. The MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with 1,000 screenshots per month without adding a card.

FAQ

Do I need Ubuntu Desktop for Playwright or Puppeteer?

No. Ubuntu Server is sufficient when the browser runs headlessly. Install a desktop or Xvfb only when you need a visible debugging session.

Should I use Playwright or Puppeteer?

Use Playwright when you want its bundled browser matrix and dependency installer. Use Puppeteer when its Chrome for Testing workflow and API fit your project. Pin whichever stack you choose.

Can I run a headed browser over SSH?

Yes, with X forwarding or a virtual display such as Xvfb, but this is a debugging arrangement. Normal automation can remain headless.

Why does the same page produce different screenshots?

Browser versions, fonts, viewport, device scale factor, timezone, locale, animations, network timing, and personalized state can all change pixels. Control those inputs and wait for an explicit readiness condition.

Is --no-sandbox required in a container?

No. First configure an unprivileged user, user namespaces, and the container security policy. Disable the sandbox only for a tightly isolated workload processing trusted content.