Drive and Record a Sandboxed Browser

Drive a sandboxed Chrome container with Playwright and record the session.

Introduction

30 Second Summary

Letting a script take over your everyday browser is a bad idea. It has your logins, your cookies, and your open tabs, and you have no record of what the script actually did.

In this project, you will run a sandboxed Chrome inside a container on your own laptop and drive it from a script. You will watch it work in a live view and record the whole session to a video file.

What You'll Build

You will open a browser tab, see a full Chrome desktop sitting inside it, and watch pages turn on their own while your hands stay off the keyboard.

By the end of this project, you'll have:

  • A live view you can open in a tab and watch a real Chrome desktop running inside a container.
  • A Playwright script that connects over the Chrome DevTools Protocol and makes that browser navigate while you watch it happen.
  • A recorded video of the session you can download and play back, proving a script did every click.
  • Secret Mission: Drive the recorded browser through a multi-page journey so your video captures a whole scripted flow instead of a single page load.

Are there any prerequisites?

You need Docker installed with at least 8GB of memory allocated to it. You also need Node or Python so you can run the automation script.

Everything runs locally on your own machine, so there is no account to create and nothing to pay for.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Naming the goal now makes every command in the steps ahead land with a reason behind it.

Build the Chromium Headful Image

You've got Docker running and an empty automation project sitting on your machine. There's no browser for that project to drive yet.

The browser you'll drive comes from kernel-images, an open source repo that packages headful Chromium into a container image. In this step you build that image on your own machine.

Building it locally means the browser runs inside a container, well away from your everyday Chrome profile, cookies and logins.

In this step, get ready to:
  • Clone the kernel-images repo onto your machine.
  • Build the chromium-headful image with the repo's own build script.
  • Confirm the finished image appears in your local image list.
Clone the kernel-images repo

Everything that builds the browser lives in one public repo. You pull it down once and build from it locally.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Download the repo by running this command:
git clone https://github.com/kernel/kernel-images.git
  • Confirm the repo landed by running this command:
ls kernel-images

You should see an images folder listed in the output.

Clone didn't work?

  • Check your internet connection. The clone has to reach GitHub to pull anything down.
  • If git tells you the destination path already exists, you cloned the repo earlier. Move straight on to the next substep.
  • Still stuck? Why did my git clone command fail?
Move into the chromium-headful build folder

The repo ships several images. The one you want is chromium-headful, and it comes with its own scripts.

  • Move into the build folder by running these commands:
cd kernel-images
cd images/chromium-headful
  • See what the folder gives you by running this command:
ls

You should see build-docker.sh and run-docker.sh in the output.

Two scripts, two jobs

build-docker.sh turns the Dockerfile in this folder into an image on your machine. You run it once.

run-docker.sh starts that image as a live container. That one is the next step's job.

Build the image

This build runs for a long stretch

The image packs a full Chromium desktop, so the build pulls and compiles a lot of pieces. Your terminal will scroll constantly.

Long silences between lines are normal. The build hasn't hung, so leave it running.

  • Start the build by running this command:
IMAGE=kernel-docker ./build-docker.sh

What does this command do?

  • ./build-docker.sh is the repo's own build script, so you don't have to write a docker build command yourself.
  • IMAGE=kernel-docker sets the name your finished image gets locally. That's the name you'll hunt for in a moment.

Once the build finishes, the image is on your machine. Before you check, what name do you expect it to be listed under?

  • List every image on your machine by running this command:
docker images

You should see a row where the REPOSITORY column reads kernel-docker. That row is the browser you're about to start.

Build stopped before it finished?

  • Check that Docker is still running. The build talks to the Docker engine the whole way through and fails the moment it goes away.
  • Raise Docker's memory limit if you capped it below 8GB. This image needs at least that much to behave.
  • Run the build command again after a failure. Docker reuses the layers that already built, so a rerun picks up rather than starting from scratch.
  • Need a hand reading the error? Why did my chromium-headful build fail?

You now have a whole browser sitting in your local image list, built from source. Next up, you'll start it as a container and watch a Chrome desktop appear in a browser tab.

Start the Container and Open the Live View

You built the chromium-headful image in the last step, and docker images proved it landed on your machine. An image is only a template though, so right now nothing is running and there is nothing to watch.

Starting a container from that image gives you a full Chrome desktop running inside Docker, with ports opened onto your laptop. In this step you open the first of those doors and watch Chrome boot in a browser tab.

In this step, get ready to:
  • Give Docker enough memory to run a full browser desktop.
  • Start the chromium-headful container with its live view turned on.
  • Open the live view and watch a Chrome desktop appear in your browser tab.
Give Docker enough memory

This image runs an X server, a window manager and a full Chromium build all at once. The repo sets the floor at 8GB of memory, and the run script asks Docker for exactly that.

  • Check how much memory Docker can use by running this command:
docker info
  • Find the Total Memory line in the output.

✔️ I see 8GiB or more

Docker has room for a browser desktop. Go straight on to starting the container.

ⓧ I see less than 8GiB

Docker Desktop defaults to half your host memory, which is often under the floor this image needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker Desktop and press Enter.
  • Select the Settings icon in the Docker Desktop Dashboard.
  • Select Resources in the left sidebar.
  • Open the Advanced tab.
  • Drag the Memory limit slider to at least 8 GB.
  • Apply the change so Docker Desktop restarts with the new limit.
  • Confirm the new limit by running this command again:
docker info

The Total Memory line now reads 8GiB or more.

No memory line at all?

If the command errors instead of printing a report, it usually means the Docker engine is not reachable yet. Wait for Docker Desktop to finish starting, then run it again.

Still stuck? Why can't my terminal reach the Docker daemon?

Start the container with the live view turned on

The repo ships a run script that wires up every port, flag and mount the browser needs. You switch it into WebRTC mode with one environment variable.

  • Move into the image folder inside your cloned kernel-images repo and name the image you built, by running these commands:
cd ~/Desktop/kernel-images/images/chromium-headful
export IMAGE=kernel-docker

This next command takes over your terminal

The container runs in the foreground and streams its startup logs into this tab. It keeps running until you press Ctrl+C.

You'll open a second terminal tab straight afterwards for everything else in this step.

  • Start the container with the live view and the recording server switched on by running this command:
WITH_KERNEL_IMAGES_API=true ENABLE_WEBRTC=true ./run-docker.sh

What does this command do?

  • WITH_KERNEL_IMAGES_API=true switches on the recording server, which you call in a later step.
  • ENABLE_WEBRTC=true picks the WebRTC live view instead of the noVNC one.
  • ./run-docker.sh assembles the long docker run command for you, including the port mappings and the 8GB memory limit.
  • Open a second terminal tab with Cmd+T (macOS) or Ctrl+Shift+T (Windows).
  • Confirm the container is up by running this command in the new tab:
docker ps

One row comes back, named chromium-headful-test.

Its PORTS column lists 0.0.0.0:8080->8080/tcp and 0.0.0.0:9222->9222/tcp. Those are the live view and the Chrome DevTools Protocol, now reachable from your laptop.

Container exited straight away?

Memory is the usual culprit. Below 8GB the browser processes get killed before the desktop ever appears, and the logs in your first tab stop mid-startup.

The other culprit is the image name. Check that kernel-docker is the image you actually built in the last step.

Still exiting? How do I read the logs from a container that keeps dying?

Open the live view

The container streams its whole desktop out on port 8080. Anything with a browser can watch it, starting with you.

Before you load that address, what do you expect to fill the page: a terminal, a blank tab, or a whole desktop?

  • Open a new tab in your own browser and go to http://localhost:8080.

A Chrome window fills the page, sitting on a plain desktop with a taskbar. You are watching a browser that lives inside the container, streamed to you frame by frame.

Live view stuck on a blank page?

The desktop services take a few seconds longer to come up than the container itself. Reload the tab and give it another moment.

If the page asks you to sign in instead, the repo's neko.yaml sets the default user password to neko.

Still nothing? Why would localhost:8080 load but show me an empty stream?

  • Click inside the live view and drag the Chrome window by its title bar.

The window moves. The stream is two-way, so your clicks and keystrokes land on the desktop inside the container.

Whose browser is this?

This Chrome has its own profile inside the container. None of your bookmarks, cookies or logged-in sessions exist in there.

That separation is the whole point. A script driving this browser cannot reach your bank tab or your work email.

  • Confirm the automation door is open too by running this command in your second terminal tab:
curl http://localhost:9222/json/version

Back comes a small block of JSON containing a webSocketDebuggerUrl field. That URL is what your script connects to in the next step.

Why WebRTC instead of noVNC?

The image ships both live view clients. noVNC is the one most people reach for first, and it does give you read and write access to the desktop.

The repo describes WebRTC as much faster than VNC, and it adds window resizing and copy and paste. When you are watching a script click through pages in real time, that speed is what makes the view worth having.

You have a browser running where nothing of yours can be touched, and a window onto it. Next up, you point a script at port 9222 and watch that desktop start clicking without you.

Connect Your Script to the DevTools Protocol

You have a Chrome desktop running inside a container and a live view tab showing it. So far every click in that browser has been yours.

The whole point of a sandboxed browser is that a script drives it instead of a person. That handover happens over the Chrome DevTools Protocol, the wire protocol Chrome speaks to anything that wants to control it.

In this step you point a Playwright script at port 9222 and watch the live view turn a page on its own.

In this step, get ready to:
  • Check that the DevTools Protocol port is answering from your host machine.
  • Write a script that attaches to the browser already running in the container.
  • Drive that browser to a page and watch it happen in the live view.
Check the devtools protocol port is answering

Before you write a line of script, prove the door is open. Chrome publishes a small JSON page that tells any client how to talk to it.

  • Open a new terminal window so your container keeps running in the one you already have.
  • Ask the container's Chrome for its connection details by running this command:
curl http://localhost:9222/json/version

What is port 9222 doing here?

Port 9222 is exposed via ncat, so anything that speaks the DevTools Protocol can reach the Chrome inside the container.

The /json/version path is Chrome's front desk. It hands back the browser version and, more usefully, a WebSocket address your script connects to.

You should see a block of JSON containing a webSocketDebuggerUrl field. That address is what your script will dial.

Nothing came back, or the connection was refused?

Check your container is still running. If it stopped, the port stops answering with it.

Check you started the container with the DevTools Protocol port published to your host. Without that mapping, 9222 only exists inside the container.

Why is localhost:9222 refusing my connection?

Write a script that attaches to the running browser

Most automation scripts launch their own browser. Yours does the opposite. It attaches to a browser that is already alive, already visible, and already streaming to your tab.

  • In your terminal, switch back to your automation script folder, the one holding your package.json.
  • Create your script file by running this command:
touch drive-browser.js
  • Confirm the file exists by running:
ls

You should see drive-browser.js listed alongside package.json.

  • Open drive-browser.js in your code editor.
  • Paste in this code:
const { chromium } = require("playwright");

async function main() {
  // Ask the container's DevTools Protocol port for its WebSocket endpoint
  const response = await fetch("http://localhost:9222/json/version");
  const { webSocketDebuggerUrl } = await response.json();

  // Attach Playwright to the Chrome already running inside the container
  const browser = await chromium.connectOverCDP(webSocketDebuggerUrl);
  console.log("Connected to", browser.version());

  await browser.close();
}

main();

What does this code do?

The fetch call reads the same JSON you just saw in your terminal, and pulls out the webSocketDebuggerUrl field.

The connectOverCDP call attaches to that browser instead of starting a new one. Nothing new opens on your machine.

The browser.close() call ends the WebSocket connection. The Chrome in the container keeps running.

  • Save drive-browser.js.
  • Run your script by running this command:
node drive-browser.js

You should see Connected to followed by a Chrome version number. Your script is now talking to the container's browser.

Using Puppeteer instead?

The handshake is the same. Puppeteer takes the same address through puppeteer.connect({ browserWSEndpoint: webSocketDebuggerUrl }).

Script threw an error instead of connecting?

If the error says fetch isn't defined, your Node.js version is too old to have it built in. Upgrade Node.js and run the script again.

If the error mentions a missing playwright module, you are running the script from a folder that isn't your script project. Check ls shows your package.json.

Why did my connectOverCDP script fail?

Drive the browser to a page and watch it in the live view

Connecting proves the wire works. Navigating proves the browser obeys. This is the moment the live view stops being a screenshot and starts being a robot.

  • In drive-browser.js, find these lines:
  console.log("Connected to", browser.version());

  await browser.close();
  • Replace them so the block now looks like this:
  console.log("Connected to", browser.version());

  // Reuse the tab that is already open in the live view
  const context = browser.contexts()[0];
  const page = context.pages()[0];

  await page.goto("https://news.ycombinator.com");
  console.log("Navigated to", await page.title());

  await browser.close();

Why grab an existing tab?

The Chrome in your container already has a window open, and that window is what the live view is streaming.

Reading contexts()[0] and pages()[0] hands your script that same tab. Open a brand new page instead and the navigation happens somewhere you cannot see.

  • Save drive-browser.js.
  • Arrange your windows so you can see the live view tab and your terminal at the same time.

Before you run this, what do you think happens in the live view? Does the page load somewhere hidden, or on the desktop you are watching?

  • Run your script again by running this command:
node drive-browser.js

The Chrome window in your live view navigates to Hacker News while you watch, and your terminal prints the page title it read back.

You never touched that browser. Your script did every part of it.

Live view didn't move?

If the error mentions calling goto on an undefined value, the container's Chrome had no tab open for your script to take over. Click once inside the live view to bring up a window, then run the script again.

If the terminal printed a title but the view looked frozen, refresh the live view tab. The stream reconnects to the same desktop.

Why didn't my live view update when the script navigated?

✔️ Awesome, I've got everything!

Great, double check you've saved drive-browser.js.

ⓧ I'd like to double check the full code

Your finished drive-browser.js should look like this:

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

async function main() {
  // Ask the container's DevTools Protocol port for its WebSocket endpoint
  const response = await fetch("http://localhost:9222/json/version");
  const { webSocketDebuggerUrl } = await response.json();

  // Attach Playwright to the Chrome already running inside the container
  const browser = await chromium.connectOverCDP(webSocketDebuggerUrl);
  console.log("Connected to", browser.version());

  // Reuse the tab that is already open in the live view
  const context = browser.contexts()[0];
  const page = context.pages()[0];

  await page.goto("https://news.ycombinator.com");
  console.log("Navigated to", await page.title());

  await browser.close();
}

main();

You have a browser being driven by code and a live view proving it. Next up, you'll get the container to record the whole thing to a video file you can keep.

Record Your Script's Session

Your script drove the browser and you watched it happen in the live view. That evidence lasted exactly as long as you were looking at the tab.

Anyone building a web agent needs to see what it actually did, after the fact. The container ships a recording server on port 10001 that captures the session to a video file.

In this step you bracket a script run between a start call and a stop call, so the recording server captures exactly the automation you care about.

In this step, get ready to:
  • Start a recording by calling the recording API on the container.
  • Run your automation script again while the recording is live.
  • Stop the recording so the container finishes writing the file.
Start a recording

The recording server sits inside the container alongside Chromium. You talk to it over HTTP on port 10001, the same way your script talks to the Chrome DevTools Protocol on port 9222.

  • Open a new terminal window, separate from the one running your container.
  • Start the recording by running this command:
curl -X POST http://localhost:10001/start_recording

What does this command do?

  • The -X POST flag sends a POST request, which is how you ask the recording server to change state rather than just read it.
  • The localhost:10001 address reaches the recording server inside the container, because you published that port to your host when you started it.
  • From this moment the container is capturing the browser screen, so anything the desktop shows lands in the video.
  • Read the response your terminal prints back. A successful start returns a response from the server rather than an error.
  • Switch back to your live view tab and confirm the Chrome desktop is still there.

Getting a connection refused error?

Check your container is still running with docker ps. A container that crashed on memory takes the recording server down with it.

Check that port 10001 appears in the ports column of that same output. Without a published port your host cannot reach the server.

Why can't curl reach the recording server in my container?

Run your script while recording

The recording is live but the browser is sitting still. Everything your script does from here is what ends up in the video.

  • Return to the terminal you used for your automation script earlier.
  • Run your script again, using the same command you used the first time.
  • Switch back to your live view tab while the script runs.

The Chrome desktop navigates to your target page again, with nobody touching the keyboard.

Why run the script a second time?

The recording server captures the screen from the moment you start it. Your first run happened before the recording existed, so none of it was captured.

Bracketing the run between a start and a stop is the pattern you reuse for any agent you build. Start, act, stop, keep the evidence.

Script finished but the live view didn't change?

Your script may have navigated to the page it was already on, so there is nothing visibly different. Edit the target URL in your script to a different site and run it again.

Check your script connected over the DevTools Protocol rather than launching its own browser. A locally launched browser never appears in the live view.

Why is my script running without the live view showing any change?

Stop the recording

A recording that is still running has no finished file behind it. The server needs a stop call before it closes out the video.

Before you run the next command, what do you think the server sends back? A confirmation, or the video itself?

  • Switch back to the terminal you used for the start call.
  • Stop the recording by running this command:
curl -X POST http://localhost:10001/stop_recording
  • Read the response in your terminal. The server answers the stop call rather than staying silent, which tells you the capture has been closed out on the container.
  • Leave your container running. The finished recording lives inside it and you collect it in the next step.

Why the stop call matters

Video files carry an index that maps timestamps to frames. That index is written when the capture closes.

Killing the container mid-recording skips that write, and you end up with a file no player will scrub through. The stop call is what turns a running capture into a video you can keep.

Stop call returning an error?

Check you started a recording first. Stopping a recording that was never started gives the server nothing to close.

Check you sent the request to port 10001 and not to the DevTools Protocol port. The two servers listen on different ports and only one of them records.

Why is my stop request to the recording server failing?

You have a finished recording sitting inside your container. Next up, you pull that file onto your own machine and watch your script drive the browser all over again.

Download and Watch Your Recording

Your recording is finished, but it is still sitting inside the container. Nothing on your laptop knows the file exists yet.

The recording API you started and stopped the capture with has a third endpoint that hands the file back over the same port. Pull it down and you have a video you own, not a stream you watched.

In this step, get ready to:
  • Download the finished recording from the recording API to your machine.
  • Play the video back in a player that handles H.264.
  • Confirm the video shows your script driving the browser.
Download the recording from the container

The container has its own filesystem, and your recording landed there. The recording API is the only door between that file and your machine.

  • Switch back to the terminal window you ran your automation script from.
  • Pull the recording onto your machine by running this command:
curl http://localhost:10001/recording/download --output recording.mp4

What does this command do?

The /recording/download endpoint streams the finished capture back over port 10001, the same port you used to start and stop it.

The --output flag writes that stream to a file called recording.mp4 in whichever folder your terminal is currently in.

  • Confirm the file arrived by running this command:
ls -lh recording.mp4

You should see recording.mp4 listed with a real size next to it, not zero.

File missing or zero bytes?

  • An empty file usually means the capture never got stopped, so there was nothing finished to hand back.
  • A connection error means nothing is listening on port 10001 any more, so the container has stopped.
  • My download from the recording API came back empty. What went wrong?
Play the recording back

Before you open the file, make a call on what the first few seconds show. An empty Chrome desktop, or the page your script navigated to?

macOS

  • Open the video in your default player by running this command:
open recording.mp4

Windows

  • Open the video in your default player by running this command:
start recording.mp4

The video opens on the Chrome desktop, framed exactly as the live view showed it. Play forward and the page your script asked for loads on its own.

Video won't play, or the window stays black?

  • The file is encoded as H.264/MPEG-4 AVC, and QuickTime has known playback issues with it. Try a different media player before you assume the recording is broken.
  • Drag recording.mp4 into a new browser tab and see whether it plays there.
  • Why won't my H.264 MP4 file play?
Match the video against your script's run

A video of a browser proves nothing on its own. It becomes evidence the moment you can point at the frame where the script took over.

  • Scrub to the very start of the video.
  • Note what the Chrome window shows before anything happens.
  • Play forward until the page your script navigated to appears.
  • Watch the mouse pointer across that transition.

Nothing on that desktop was clicked. The page changed because the navigation arrived over the Chrome DevTools Protocol, not from your hands.

Why a recording beats a screenshot

When an agent does something unexpected on a page, a screenshot shows you the wreckage. The video shows you the decision that caused it.

That is why the recording server sits alongside the live view rather than replacing it. One is for watching in the moment, the other is for arguing about later.

Secret mission

Record a Multi-Page Tour With Stills

Your script visits one page and stops. Send it on a three-stop tour instead, saving a screenshot at every stop, and record the whole run. You finish with a video of the tour and a set of stills that line up with it.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Everything here runs locally in Docker on your own machine, so there are no ongoing costs.

Resources you used:

  • The chromium-headful container you started with WebRTC enabled.
  • The chromium-headful Docker image you built from the cloned repo.
  • The kernel-images repository folder you cloned.
  • Your automation script project folder.
  • The recording file you downloaded from the recording API.

Keep everything running

No action needed. Choose this if you're still actively building on your sandboxed browser.

Your container keeps holding memory and keeps listening on the live view port, the Chrome DevTools Protocol port 9222, and the recording port 10001.

  • See what is still running by running this command:
docker ps

Your chromium-headful container is listed with its port mappings. Your script and your recording stay untouched on disk.

Pause - I'll come back to this later

Shut down the running container to free up memory and release the ports. Your image, your cloned repo, your script, and your recording all stay on disk.

  • Find your container's name by running this command:
docker ps
  • Stop the container by running this command:
docker stop [[CONTAINER_NAME="your container name"]]
  • Confirm nothing is still listening by running this command:
docker ps

The output no longer lists your chromium-headful container. Reloading the live view tab now fails, which is the sign the sandbox is properly stopped.

Coming back is one command

The image is already built, so starting a fresh container later takes seconds. You never have to sit through the long build again.

Delete - I don't want to use this again

Remove all project resources and start fresh. The chromium-headful image is large, so this is the option that gives you real disk space back.

  • Find your container's name by running this command:
docker ps -a
  • Stop the container by running this command:
docker stop [[CONTAINER_NAME="your container name"]]
  • Remove the container by running this command:
docker rm [[CONTAINER_NAME="your container name"]]
  • Find your image's name by running this command:
docker images
  • Delete the image by running this command:
docker rmi [[IMAGE_NAME="your chromium-headful image name"]]
  • Confirm both are gone by running these commands:
docker ps -a
docker images

Neither list shows your chromium-headful container or image any more.

Image won't delete?

Docker refuses to remove an image while a container built from it still exists. Check docker ps -a for a leftover container and remove it first.

Still stuck on the image removal?

  • Delete the cloned repo, your script project, and your recording on macOS or Linux by running these commands:
rm -rf [[KERNEL_IMAGES_FOLDER="path to your kernel-images folder"]]
rm -rf [[SCRIPT_FOLDER="path to your automation script folder"]]
rm [[RECORDING_FILE="path to your recording file"]]
  • Delete the same three items on Windows by running these commands:
rmdir /s /q [[KERNEL_IMAGES_FOLDER="path to your kernel-images folder"]]
rmdir /s /q [[SCRIPT_FOLDER="path to your automation script folder"]]
del [[RECORDING_FILE="path to your recording file"]]

Your machine is back to how it started, minus Docker, Git, and Node.js, which stay installed for your next project.

Nice Work!

Nice Work

Nice work. You've built a sandboxed Chromium that runs inside a container, takes orders from a script, and hands you back a video of everything it did.

That browser never touched your own profile, cookies or logins. You watched it work in a tab and you have the recording to prove a script did the clicking.

You've learned how to:

  • Build the chromium-headful image from the kernel-images repo and run it as a container with a live view you watch in a browser tab.
  • Drive that browser from a Playwright script by connecting to the Chrome DevTools Protocol on port 9222, then watch the pages turn without touching the keyboard.
  • Start and stop a session recording through the recording API on port 10001, then download the video and play back the run.
  • Secret Mission: Push your sandboxed browser further with an optional challenge that builds on the container and script you already have.

Ready to quiz yourself?