| 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. |
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.
- A docker container running the
lscr.io/linuxserver/chromiumimage, 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
/configinside 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).
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).
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
EOFapache2-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.
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
EOFbrowser-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.
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
EOFNotes on these env vars (same roles as the Fly variant):
PUID/PGID=1000— required, image refusesPUID=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 nginxlocation.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'sinit.exec/services-block dance entirely. Docker's own restart policy plussystemctl enable docker(done in the one-time setup) is enough to survive both container crashes and full instance reboots. There is noinit.exechere 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.
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.
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.
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.
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.