Skip to content
FreeToolNet
Go back

Watch Playwright tests run live on a VPS with noVNC and nginx

Playwright tests on a server normally run headless. When one fails, you get a trace and a video afterwards. That is fine for CI. It is slow when you are writing a test and want to see where the browser gets stuck right now.

I built a small tool on my VPS that runs tests in a real, headed Chrome and shows that browser live inside a web page. I can click into it while it runs. This post is about the part that is reusable in any project: putting a virtual screen, a VNC server and a web VNC client in one container, and making sure the screen is only visible to a signed-in user.

The layout

browser ──HTTPS──▶ nginx ──/──────▶ FastAPI app (:8400)       tests, files, runs
                         └─/vnc/──▶ noVNC/websockify (:8401)  only after nginx auth_request /auth/check
                                         │
                       container: Xvfb :99 ◀── Chrome started by `playwright test` / `pytest`

Five processes share one container:

  • Xvfb: a virtual X display, :99. Chrome draws on it as if it were a monitor.
  • fluxbox: a minimal window manager, so windows can be maximised.
  • x11vnc: serves display :99 over VNC, on localhost only.
  • websockify + noVNC: turns the VNC stream into a WebSocket and serves the browser-based VNC client.
  • uvicorn: the web app that stores tests and starts runs.

The image

Playwright’s official image already has Chromium, Node and the system libraries browsers need. I add the screen tools on top:

# Playwright's image already has Chromium, Node and the browser system libraries.
FROM mcr.microsoft.com/playwright:v1.64.1-noble

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
 && apt-get install -y --no-install-recommends xvfb fluxbox x11vnc xdotool novnc websockify supervisor python3-venv procps \
 && rm -rf /var/lib/apt/lists/*

# Node side: @playwright/test, symlinked into every run directory as node_modules
RUN mkdir -p /opt/pw && cd /opt/pw && npm init -y >/dev/null && npm install --no-audit --no-fund @playwright/test@1.64.1

# Python side: the web app plus pytest-playwright for .py tests
COPY requirements.txt /opt/testlab/requirements.txt
RUN python3 -m venv /opt/venv \
 && /opt/venv/bin/pip install --no-cache-dir -r /opt/testlab/requirements.txt \
 && /opt/venv/bin/python -m playwright install chromium

The image tag, the @playwright/test version and the Python playwright version are all 1.64.1. Keep them in step. Updating Playwright means changing the version in those three places and rebuilding.

One small touch makes the live view much nicer. Fluxbox normally draws a toolbar and window borders. I turn them off and maximise every window, so the browser fills the whole screen:

# Fluxbox without its toolbar, so the browser gets the whole screen
RUN mkdir -p /home/pwuser/.fluxbox \
 && printf 'session.screen0.toolbar.visible: false\nsession.screen0.defaultDeco: NONE\n' > /home/pwuser/.fluxbox/init \
 && printf '[app] (name=.*)\n  [Maximized] {yes}\n  [Deco] {NONE}\n[end]\n' > /home/pwuser/.fluxbox/apps \
 && chown -R pwuser:pwuser /home/pwuser/.fluxbox

The container runs as pwuser, the non-root user that ships with the Playwright image.

supervisord runs the five processes

; The shared screen every test browser opens on
[program:xvfb]
command=Xvfb :99 -screen 0 1280x800x24 -nolisten tcp
priority=10
autorestart=true

[program:fluxbox]
command=fluxbox
environment=DISPLAY=":99",HOME="/home/pwuser"
priority=20
autorestart=true
startsecs=3

; VNC only inside the container; websockify turns it into the noVNC page nginx serves at /vnc/
[program:x11vnc]
command=x11vnc -display :99 -localhost -shared -forever -nopw -quiet -rfbport 5900
priority=30
autorestart=true
startsecs=3

[program:novnc]
command=websockify --web /usr/share/novnc 6080 localhost:5900
priority=40
autorestart=true

[program:app]
command=/opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*
directory=/opt/testlab
priority=50
autorestart=true

Things to notice:

  • priority sets the start order: screen first, then window manager, then VNC.
  • Xvfb has -nolisten tcp. The display is reachable through the local socket only.
  • x11vnc runs with -nopw, which means no VNC password. That is only acceptable because of -localhost: it accepts connections from inside the container and nowhere else. The real access control is in nginx, shown below.
  • -shared -forever lets several viewers connect and keeps the server running after one disconnects.
  • websockify --web /usr/share/novnc 6080 localhost:5900 does two jobs. It serves the noVNC HTML client on port 6080 and bridges WebSocket connections to the VNC port.

Pointing Playwright at the screen

A test process only needs one thing to appear on the live screen: DISPLAY=:99 in its environment, and headed mode. The runner builds the environment for each run like this (trimmed):

env = {**os.environ, "DISPLAY": ":99", "FORCE_COLOR": "0", "NO_COLOR": "1", "PLAYWRIGHT_HTML_OPEN": "never"}
hidden = ["TESTLAB_SECRET", "TESTLAB_PASSWORD_HASH"]
for secret in hidden:
    env.pop(secret, None)

The second half matters. A test script is arbitrary code. The web app’s session secret and password hash are in the container’s environment, so they are removed before the test process starts.

For JavaScript and TypeScript tests, each run gets a generated config. This is the Chromium version:

const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
  testDir: "tests",
  timeout: 5 * 60 * 1000,
  workers: 1,
  retries: 0,
  outputDir: "results",
  reporter: [["list"], ["html", { open: "never", outputFolder: "report" }]],
  use: {
    video: "on",
    trace: "on",
    screenshot: "on",
    headless: false,
    viewport: { width: 1280, height: 720 },
    launchOptions: {
      args: [
        "--start-maximized",
        "--window-position=0,0",
        "--window-size=1280,800",
      ],
    },
  },
});

The window is 1280x800 to match the Xvfb screen. The viewport is 1280x720, which leaves room for the browser’s own toolbar. workers: 1 because there is one screen.

Python tests use pytest-playwright with the same idea:

pytest --headed --browser chromium --video on --tracing on --screenshot on

One screen means one run at a time

Two headed browsers on one display would cover each other, so runs go through a single queue. Other runs wait in order. The limits are simple:

  • Each test times out after 5 minutes.
  • A whole run is stopped after 15 minutes.
  • Each test keeps its last 20 runs. Older run folders are deleted.

Stopping a run cleanly took some care. The test command is started with start_new_session=True, which puts it in its own process group. Stop sends SIGTERM to the whole group, waits two seconds, then sends SIGKILL. Killing only the parent would leave the browser alive. After every run, any leftover browser processes are killed as well, because a browser left behind by a killed process would stay on the shared screen in front of the next test.

The container

# Not on the automation/autoclip networks: test scripts only get the internet, not the other services.
docker rm -f testlab >/dev/null 2>&1 || true
docker run -d --name testlab --restart unless-stopped \
  --memory 2g --cpus 1.5 --pids-limit 512 --shm-size 1g \
  -p 127.0.0.1:8400:8000 -p 127.0.0.1:8401:6080 \
  --env-file /etc/testlab.env -v /srv/testlab/data:/data $IMG >/dev/null
  • Both ports are published on 127.0.0.1. Nothing is reachable from outside except through nginx.
  • --shm-size 1g because Chrome uses shared memory heavily and Docker’s default is small.
  • The memory, CPU and process limits stop a runaway test from affecting the rest of the server.
  • The container is on Docker’s default bridge network, not on the networks my other services use. Test scripts can reach the internet and cannot reach those services.

Gotcha: the live screen is a second way in

The web app has a login. noVNC does not. It is a separate port with a separate protocol, and I had deliberately given VNC no password. If nginx simply proxied /vnc/ to it, anyone who guessed the URL could watch and control the browser, including any site a test had just logged in to.

nginx has a module for this: auth_request. Before serving a protected location, nginx makes a sub-request to a URL you choose. A 2xx answer lets the request through. A 401 blocks it.

# The live screen only opens for a signed-in session
location = /auth/check {
    internal;
    proxy_pass http://127.0.0.1:8400/auth/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Forwarded-Proto $scheme;
}
location /vnc/ {
    auth_request /auth/check;
    error_page 401 = @login;
    proxy_pass http://127.0.0.1:8401/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
    proxy_read_timeout 3600s;
}
location @login {
    return 302 /login;
}

The endpoint in the app is four lines. It checks the same session cookie the rest of the site uses:

@app.get("/auth/check")
def auth_check(request: Request):
    """nginx auth_request for the live screen at /vnc/."""
    ok = auth.valid_session(request.cookies.get(auth.COOKIE))
    return Response(status_code=204 if ok else 401)

Details that are easy to get wrong:

  • internal on /auth/check in nginx means only nginx’s own sub-requests can use that location.
  • proxy_pass_request_body off and an empty Content-Length are needed because the sub-request has no body to send.
  • The Upgrade and Connection "upgrade" headers are required for the WebSocket that carries the VNC stream. Without them the noVNC page loads and then never connects.
  • The trailing slash in proxy_pass http://127.0.0.1:8401/; strips the /vnc/ prefix before the request reaches websockify.
  • error_page 401 = @login sends a signed-out visitor to the login page instead of showing a bare 401.

The page embeds the screen in an iframe:

<iframe
  id="vnc"
  title="Live browser screen"
  src="/vnc/vnc.html?autoconnect=1&resize=scale&reconnect=1&reconnect_delay=1000&path=vnc/websockify"
></iframe>

path=vnc/websockify is the piece that is not obvious. noVNC opens its WebSocket at /websockify by default. Behind a sub-path that would miss the protected location, so the client is told to use /vnc/websockify.

The setup script checks the gate every time it runs. It requests the live screen without a session through nginx, and requests noVNC directly on the local port, and prints both status codes side by side:

echo "== live screen, signed out: HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "Host: $DOMAIN" http://127.0.0.1/vnc/vnc.html)"
echo "== noVNC behind nginx: HTTP $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8401/vnc.html)"

The login itself

Since the session cookie now guards a remote-controlled browser, the login had to be solid:

  • The password is stored as an scrypt hash (n=16384, r=8, p=1) and compared with hmac.compare_digest.
  • More than 5 failed attempts in a minute locks sign-in until the minute passes.
  • The session is a signed cookie made with itsdangerous, valid for 7 days. It includes the last 12 characters of the current password hash, so changing the password signs out every other session.
  • A Cloudflare Turnstile check on the login form is optional. It stays off until both keys are set.

Limits

  • One screen, so one run at a time. For parallel runs you would need one display per worker, or headless mode.
  • The live view is for watching and debugging. The video, screenshots, HTML report and trace are still saved for every run, and they are what I use after the fact.
  • Test scripts run arbitrary code inside the container. The resource caps and network separation limit the damage. I am the only user, and I would not open this to people I do not trust.

Summary

  • Xvfb gives a headless server a screen. Set DISPLAY and run Playwright headed.
  • x11vnc with -localhost -nopw, plus websockify and noVNC, puts that screen in a web page.
  • Never expose noVNC directly. Guard it with nginx auth_request against your app’s own session check.
  • Pass path= to noVNC when it lives under a sub-path, and forward the WebSocket upgrade headers.
  • Kill the whole process group when stopping a test, and clear leftover browsers after each run.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-10-29 on Docker container on a Linux VPS, nginx on the host; image based on mcr.microsoft.com/playwright:v1.64.1-noble (Playwright 1.64.1, @playwright/test 1.64.1, pytest 8.3.4, pytest-playwright 0.7.0, FastAPI 0.115.6, uvicorn 0.34.0, itsdangerous 2.2.0).



Previous Post
Sandboxing an AI agent web app in Docker behind nginx basic auth
Next Post
Generate n8n workflows from a Python script and deploy by API