Skip to content

Instantly share code, notes, and snippets.

@eladb
Created July 14, 2026 12:46
Show Gist options
  • Select an option

  • Save eladb/139fcd33b22278f0c3aecceafaac1531 to your computer and use it in GitHub Desktop.

Select an option

Save eladb/139fcd33b22278f0c3aecceafaac1531 to your computer and use it in GitHub Desktop.
Remote browser
name browser-sidekick-ec2
description Set up a shared, always-on remote Chrome browser on an EC2 instance that an agent can drive over CDP while a human watches/takes over the same session in a web UI. Use whenever the ask is "give the agent a real browser it can control, reachable over SSH to an EC2 host" — as opposed to the Fly Machine variant of this same pattern. Covers docker + --network host setup, the Selkies/CDP auth-reverse-proxy, profile persistence on EBS, and the Wayland-socket and Google-OAuth-popup gotchas that are shared with the Fly variant plus several that are EC2/Docker-specific.

Browser sidekick variant: remote Chrome on EC2 for agent + human (SSH access)

This is the EC2 sibling of the fly-sidekick browser recipe. Same end goal — one shared Chromium instance that an agent drives over CDP and a human watches/interacts with through the same live session — but built from plain SSH access to an EC2 host instead of the Fly Machines API. Read this whole doc before running anything; several of the Fly-specific gotchas do not apply here, and there are new ones that only show up because of Docker's networking model.

Still based on lscr.io/linuxserver/chromium (Chromium + Selkies WebRTC/WebSocket streaming, s6-overlay init) — don't hand-roll Xvfb+x11vnc+noVNC, this image already does the hard part.

Architecture

  • A docker container running the lscr.io/linuxserver/chromium image, on the host's own network namespace (--network host — see gotcha #1, this is not optional). Its internal s6 init starts Chromium (CDP bound to loopback, always, regardless of --remote-debugging-address), Selkies, and the image's own internal nginx serving the Selkies UI on ports 3000/3001.
  • A system nginx running directly on the EC2 host (not in a container) listening on 8080, reverse-proxying /127.0.0.1:3000 (the Selkies UI) and /cdp/127.0.0.1:9222 (Chrome's CDP), with HTTP Basic Auth in front of both. Only 8080 (or 443, see gotcha #6) is opened in the EC2 security group.
  • A bind-mounted directory on the instance's EBS root or a data volume, mapped to /config inside the container, holding the Chrome profile.

Because --network host puts the container's loopback and the host's loopback in the same namespace, 127.0.0.1:9222 inside the container is 127.0.0.1:9222 on the host — the system nginx can reach it directly with no extra port-mapping. This is the one deliberate architectural difference from the Fly variant, and it's what makes several of that variant's gotchas disappear (see gotcha #1 below).

Sizing

t3.large (2 vCPU / 8 GB) or equivalent (m5.large, c5.large) has been reliable. This mirrors the Fly variant's shared-cpu-2x/4096MB — a real browser plus a video encode/streaming pipeline needs real headroom, so don't go below 2 vCPU/4GB, and prefer 8GB if the box will also run an agent process alongside the browser. Use a gp3 EBS root volume of at least 20GB (Chrome profile + swap headroom).

One-time host setup (over SSH)

Assumes a fresh Ubuntu EC2 instance and a working ssh user@host connection (key-based, sudo-capable user). Run:

ssh user@host <<'EOF'
set -e
sudo apt-get update
sudo apt-get install -y docker.io nginx apache2-utils openssl
sudo systemctl enable --now docker
sudo mkdir -p /data/browser-config
sudo chown -R 1000:1000 /data/browser-config
EOF

apache2-utils gives you htpasswd; 1000:1000 matches the PUID/PGID the image expects (see below — same requirement as the Fly variant, the image's init refuses to launch Chromium under PUID=0).

Open only port 8080 (or 443 if you set up TLS — strongly recommended, see gotcha #6) in the instance's security group. Do not open 3000, 3001, or 9222 to the internet; those should only ever be reached via the host's own loopback through the auth proxy.

Generating credentials and the auth-proxy config

Do this once per deployment, over SSH:

ssh user@host <<'EOF'
set -e
BROWSER_USER=agent
BROWSER_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c24)
echo "Password: $BROWSER_PASS"   # save this, you'll need it to connect

sudo mkdir -p /etc/nginx-authproxy
htpasswd -bc /etc/nginx-authproxy/.htpasswd "$BROWSER_USER" "$BROWSER_PASS" \
  | sudo tee /dev/null   # htpasswd writes the file itself; the pipe just suppresses stdout noise
sudo mv /etc/nginx-authproxy/.htpasswd /etc/nginx-authproxy/.htpasswd 2>/dev/null || true
EOF

(If htpasswd -bc complains about permissions, write to a temp path as your unprivileged user and sudo mv it into place — simpler than fighting file ownership over a one-liner.)

Then push the nginx config. Because this is system nginx (not fighting a container for a pidfile — see gotcha #3), it's fine to drop this straight into sites-available:

scp browser-authproxy.conf user@host:/tmp/browser-authproxy.conf
ssh user@host <<'EOF'
set -e
sudo mv /tmp/browser-authproxy.conf /etc/nginx/sites-available/browser-authproxy.conf
sudo ln -sf /etc/nginx/sites-available/browser-authproxy.conf /etc/nginx/sites-enabled/browser-authproxy.conf
sudo nginx -t
sudo systemctl reload nginx
EOF

browser-authproxy.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 8080;

    auth_basic "browser sidekick";
    auth_basic_user_file /etc/nginx-authproxy/.htpasswd;

    location / {
        proxy_buffering off;
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 3600;
    }

    location /cdp/ {
        proxy_pass http://127.0.0.1:9222/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host 127.0.0.1;
        proxy_read_timeout 3600;
    }
}

proxy_buffering off on the / block is not optional — see gotcha #4, it's carried over unchanged from the Fly variant and the failure mode is identical.

Launching the browser container

ssh user@host <<'EOF'
set -e
sudo docker run -d \
  --name browser \
  --network host \
  --restart unless-stopped \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=UTC \
  -e SUBFOLDER=/browser/ \
  -e CHROME_CLI="--remote-debugging-port=9222 --remote-allow-origins=* --no-first-run --disable-features=Translate" \
  -e WAYLAND_DISPLAY=wayland-1 \
  -e SELKIES_WAYLAND_SOCKET_INDEX=1 \
  -v /data/browser-config:/config \
  lscr.io/linuxserver/chromium
EOF

Notes on these env vars (same roles as the Fly variant):

  • PUID/PGID=1000 — required, image refuses PUID=0.
  • SUBFOLDER=/browser/ — makes the Selkies UI self-aware of being served under /browser/; match this to wherever you mount it if you change the nginx location.
  • WAYLAND_DISPLAY=wayland-1 + SELKIES_WAYLAND_SOCKET_INDEX=1 — fixes the blank-video bug, see gotcha #2. This is the single most important line in this whole doc.
  • --restart unless-stopped — replaces the Fly variant's init.exec/services-block dance entirely. Docker's own restart policy plus systemctl enable docker (done in the one-time setup) is enough to survive both container crashes and full instance reboots. There is no init.exec here and no PID-1 problem to work around — see gotcha #1.

BROWSER_USER/BROWSER_PASS are not container env vars in this variant — they only exist transiently in the credential-generation step above and end up baked into .htpasswd, since the auth proxy lives on the host, not inside the container.

Seven gotchas — four shared with Fly, three EC2/Docker-specific

1. (EC2/Docker-specific — replaces the Fly variant's PID-1 problem entirely.) --network host is required, and there is no s6-overlay PID-1 issue here at all. On Fly Machines, /fly/init occupies the guest's true PID 1 and the image's own /init lands at PID 2+, which s6-overlay refuses to run as. On EC2 with a normal docker run, Docker gives the container its own PID namespace and the image's /init is PID 1 inside it — s6-overlay is happy immediately, no unshare --pid --fork --mount-proc wrapper needed. The tradeoff: Docker's default network mode gives the container its own network namespace too, and Chrome's CDP binds only to 127.0.0.1:9222 — which, in default bridge mode, is a loopback the host can't reach even with -p 9222:9222 (-p only forwards to addresses the container listens on non-loopback). --network host collapses the container's network namespace into the host's, making the container's 127.0.0.1:9222 the same loopback the host nginx is already on. If you skip --network host for isolation reasons, you'll need a small in-container relay (e.g. socat TCP-LISTEN:9222,fork,reuseaddr TCP:127.0.0.1:9222 bound to 0.0.0.0 and published via -p) — more moving parts, only bother if you have a specific reason to avoid host networking.

2. A blank live-view video stream, while auth/Chrome/CDP all work, is the same Wayland-socket mismatch as the Fly variant — unchanged, still the top gotcha. startwm_wayland.sh starts the labwc compositor on socket wayland-1, but Selkies' pixelflux capture code ignores the WAYLAND_DISPLAY env var for this purpose and defaults its own wayland_socket_index to 0 — a socket nothing creates in this config. Confirmed via selkies --help: --wayland-socket-index / env SELKIES_WAYLAND_SOCKET_INDEX. Fix: SELKIES_WAYLAND_SOCKET_INDEX=1 (already in the docker run above — don't drop it if you customize the command). If you still get a blank screen with that set, restart just the Selkies service inside the container to test without a full recreate: docker exec browser s6-svc -r /run/service/svc-selkies. Also carry over the Fly variant's note that a blank screen in Safari specifically is a red herring pointing at WebCodecs support — check Chrome too before assuming it's a codec issue; if it's blank there as well, it's this server-side bug, not the client.

3. Unlike the Fly variant, there's no nginx-vs-nginx pidfile collision to worry about here, because the auth proxy runs on the host, not inside a container sharing the image's filesystem. The Fly recipe had to give its auth-proxy nginx its own config/pidfile/log paths and start it by invoking the binary directly, because both nginxes lived inside the same image and service nginx start would grab the image's own /run/nginx.pid first. Here, system nginx on the host and the image's internal nginx (in its own container) are two completely separate installations with no shared filesystem — a normal sudo systemctl reload nginx on the host is fine. The only thing to actually watch is port overlap: don't put system nginx on 3000/3001, since those are already claimed by --network host from inside the container.

4. Large static assets (Selkies' frontend JS bundle) can get silently truncated over the public URL — same nginx-buffering bug as the Fly variant, unchanged. Symptoms: curl: (18) transfer closed with N bytes remaining to read on HTTP/1.1, Failed to load resource: The network connection was lost. in the browser console, and a page that never finishes loading — which looks identical to gotcha #2's blank screen, so rule this one out first by re-fetching the same asset from the box itself over SSH (ssh user@host curl -s -o /dev/null -w '%{size_download}\n' http://127.0.0.1:8080/...) before assuming it's a Wayland issue. Root cause is nginx's response buffering not suiting this particular upstream. Fix: proxy_buffering off; on the location / block (already in the config above). Verify with 5-10 repeated fetches before declaring it fixed, and re-verify after a full container/nginx restart, not just after a live edit — the same low-load-masks-the-bug trap as the Fly variant applies.

5. Profile persistence: survives instance stop/start and reboot, not instance termination — same shape of caveat as the Fly variant, different mechanism. The Chrome profile lives at /config/.config/chromium/Default inside the container, bind-mounted from /data/browser-config on the host's EBS volume. EBS volumes persist across a stop/start and reboot of the instance (as long as "delete on termination" isn't set and the instance isn't terminated) — logins survive those. They do not survive if you terminate the instance without detaching/reattaching the volume, or if you point a fresh docker run at a different host path. Tell the user this once they've logged into something, same as the Fly variant — it's not obvious that "the browser" and "the login" have different persistence guarantees.

6. (EC2-specific.) Basic Auth over plain HTTP on port 8080 sends credentials unencrypted across the internet — put TLS in front of this before real use. The Fly variant gets HTTPS for free from the platform's edge; on a bare EC2 host you don't. Cheapest fix: run certbot --nginx against a domain pointed at the instance (adds a listen 443 ssl block automatically) and redirect 8080→443, or terminate TLS at an ALB/NLB in front of the instance instead. Until TLS is in place, treat this as a same-network-only setup and prefer an SSH tunnel (ssh -L 8080:localhost:8080 user@host) over exposing 8080 to the internet at all.

7. Google's OAuth popup gets stuck blank — identical to the Fly variant, browser-level bug, not an EC2/Docker thing. accounts.google.com/gsi/select?...ux_mode=popup... opens as a real second page (title becomes "Sign In - Google Accounts") but the content area never renders, in any remote-streamed/automated Chrome context. Workaround: don't try to fix it — close the popup and use the site's regular email/password form. From an agent session: find the popup by URL substring accounts.google.com, .close() it, .reload() the main page, and use direct-login fields instead.

Debugging a container that won't come up

Over SSH:

ssh user@host 'sudo docker logs browser --tail 100'
ssh user@host 'sudo docker exec browser curl -sf http://127.0.0.1:3000 -o /dev/null && echo UI_OK'
ssh user@host 'curl -sf http://127.0.0.1:9222/json/version && echo CDP_OK'   # run on the host, works because of --network host
ssh user@host 'sudo nginx -t && sudo systemctl status nginx --no-pager'

If Chromium itself won't launch, check ownership on the mounted config dir first — ls -la /data/browser-config should show 1000:1000, not root:root; a bad chown from a previous run is the most common cause of a silent exit here.

Connecting an agent to the browser

Same CDP-over-auth-proxy pattern as the Fly variant, unchanged:

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

const authHeader = "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
const versionRes = await fetch(`https://${host}/cdp/json/version`, { headers: { Authorization: authHeader } });
const { webSocketDebuggerUrl } = await versionRes.json();
const wsPath = new URL(webSocketDebuggerUrl).pathname;

const browser = await chromium.connectOverCDP(`wss://${user}:${pass}@${host}/cdp${wsPath}`);
const context = browser.contexts()[0] ?? (await browser.newContext());
const page = context.pages()[0] ?? (await context.newPage());

(Node's built-in fetch rejects URLs with embedded credentials — use an Authorization header for the HTTP call; connectOverCDP's WebSocket URL can carry user:pass@ directly since it goes through the ws package, not fetch.) Use ws:///http:// instead of wss:///https:// if you haven't set up TLS yet and are tunneling in over SSH per gotcha #6.

The human-facing URL is https://<host>/browser/ — the browser's own Basic Auth prompt handles credentials, no client library needed.

One-off fixes and maintenance

This is the one place the EC2 variant is strictly simpler than Fly: there's no runtime /exec HTTP endpoint with shlex-tokenization quirks to work around. It's a real shell, over a real SSH session — heredocs, &&, pipes, all work exactly as you'd expect:

ssh user@host 'sudo docker exec browser cat /var/log/some.log | tail -50'

For anything touching more than a line or two, prefer scp-ing a script up and running it, rather than inlining a long one-liner — plain readability, not a tokenizer workaround.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment