# Private browser stack: Firefox in a container whose only way to the internet is a # WireGuard VPN tunnel (via Gluetun). Gluetun's firewall is the kill switch: if the # tunnel drops, it blocks the internet and the LAN, but Gluetun always allows the # Docker network this stack sits on. See README.md, "What has been tested". services: gluetun: # Patched stable source: pinned upstream/base/toolchain and locked modules. # Built locally; deliberate VPN updates require the checks in gluetun-patched/. build: context: ./gluetun-patched target: candidate image: private-gluetun:v3.41.3-pb3 pull_policy: never container_name: gluetun-proton restart: unless-stopped cap_add: - NET_ADMIN devices: - /dev/net/tun:/dev/net/tun # Keep Docker DNS upstream sockets in this namespace, behind the VPN firewall. # Inheriting a host loopback resolver lets dockerd forward outside the tunnel. dns: - 1.1.1.1 - 1.0.0.1 environment: VPN_SERVICE_PROVIDER: protonvpn # any Gluetun-supported provider works VPN_TYPE: wireguard WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY} WIREGUARD_ADDRESSES: ${WIREGUARD_ADDRESSES} SERVER_COUNTRIES: Singapore # change to your preferred exit country FIREWALL: "on" FIREWALL_OUTBOUND_SUBNETS: "" # no LAN exceptions (the Docker network stays allowed) DNS_SERVER: "on" DNS_UPSTREAM_RESOLVERS: cloudflare BLOCK_MALICIOUS: "off" # ON can break DNS on some providers; see README # Control server (:8000): Gluetun v3.41 leaves some routes open by default, # including PUT /v1/vpn/status (stops the VPN). This role puts EVERY route behind # an API key from .env; nothing in this stack calls the API. Per its source, an empty key # makes Gluetun refuse to start (fails closed). See README Setup. HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE: '{"auth":"apikey","apikey":"${GLUETUN_API_KEY}"}' # No healthcheck override on purpose: Gluetun's built-in healthcheck actually # tests tunnel connectivity. A custom wget against the control server would only # prove the HTTP server is up (it returns 200 even when the tunnel is down). ports: - "127.0.0.1:7814:5800" # VNC web UI (localhost only) volumes: - ./gluetun:/gluetun # Block browser-initiated connections to host/LAN/other Docker apps. - ./local-app-post-rules.txt:/iptables/post-rules.txt:ro firefox: build: context: . dockerfile: Dockerfile.firefox image: private-firefox:mozilla container_name: private-firefox # Enforced on engine auto-restart too, before GUI/browser initialization. entrypoint: ["/bin/sh", "/run/private-browser-startup.sh"] restart: unless-stopped depends_on: gluetun: condition: service_healthy # Compose readiness; startup-guard.sh also covers auto-restart network_mode: "service:gluetun" # borrows Gluetun's netns; no network of its own security_opt: - no-new-privileges:true environment: TZ: Etc/UTC # container logs only; RFP forces the JS-visible TZ to UTC USER_ID: 1000 GROUP_ID: 1000 DISPLAY_WIDTH: 1920 DISPLAY_HEIGHT: 1080 SECURE_CONNECTION: 1 WEB_AUTHENTICATION: 1 # HTTPS login page; bcrypt creds in webauth-htpasswd (see below) VNC_LISTENING_PORT: "-1" # no raw VNC port (:5900 had no password); web UI is unaffected FF_OPEN_URL: about:blank # set to https://ipinfo.io/json for an IP check on launch # Hardening is intentionally minimal: RFP + the tmpfs/namespace architecture do the # work here, not a long user.js. See the README design notes. FF_PREF_RFP: "privacy.resistFingerprinting=true" FF_PREF_LETTERBOX: "privacy.resistFingerprinting.letterboxing=true" FF_PREF_WEBGL: "webgl.disabled=true" FF_PREF_TELEMETRY: "toolkit.telemetry.enabled=false" FF_PREF_DATAREPORT: "datareporting.healthreport.uploadEnabled=false" FF_PREF_PREFETCH: "network.prefetch-next=false" FF_PREF_SPECULATE: "network.http.speculative-parallel-limit=0" FF_PREF_SEARCHSUGGEST: "browser.search.suggest.enabled=false" # Mozilla's own build ships Google Safe Browsing keys. Switched off here # (see README design notes, "Why is Safe Browsing off?"). FF_PREF_SB_MALWARE: "browser.safebrowsing.malware.enabled=false" FF_PREF_SB_PHISHING: "browser.safebrowsing.phishing.enabled=false" FF_PREF_SB_DOWNLOADS: "browser.safebrowsing.downloads.enabled=false" # Mozilla's built-in VPN button; the tunnel here is Gluetun. FF_PREF_IPPROTECTION: "browser.ipProtection.enabled=false" # Blank New Tab: no sponsored tiles or widgets. FF_PREF_NEWTAB: "browser.newtabpage.enabled=false" FF_PREF_SPONSORED: "browser.newtabpage.activity-stream.showSponsored=false" FF_PREF_SPONSORED_TOPSITES: "browser.newtabpage.activity-stream.showSponsoredTopSites=false" # No persistent profile volume: downloads land in the tmpfs profile and are wiped # on stop, like the rest of the profile. This is deliberate -- the profile is amnesic. # (The webauth-htpasswd mount below is a single credential file, not profile data.) tmpfs: - "/config:size=512m,uid=1000,gid=1000,mode=0755" # ephemeral profile; keep the quotes # Web-auth credentials: a bcrypt htpasswd file you generate on the host (see README Setup). # Mounted read-write on purpose -- jlesage's init chmods it at startup, which fails on a # read-only mount and restart-loops the container. The file holds only a bcrypt hash. volumes: - ./webauth-htpasswd:/config/webauth-htpasswd # Shared directory so the updater can atomically block new web sessions. - ./maintenance:/run/private-browser-maintenance:ro - ./startup-guard.sh:/run/private-browser-startup.sh:ro # Optional self-hosted CreepJS fingerprint tester. Disabled by default. # Enable: docker compose --profile test up -d creepjs # Populate ./creepjs/docs with a CreepJS build first (see README). # The container browser reaches it at http://localhost:8080 inside Gluetun's # namespace -- no host port publish is needed for that. creepjs: build: context: . dockerfile: Dockerfile.creepjs image: private-creepjs:patched container_name: creepjs-server profiles: ["test"] restart: unless-stopped depends_on: gluetun: condition: service_healthy network_mode: "service:gluetun" security_opt: - no-new-privileges:true volumes: - ./creepjs/docs:/usr/share/nginx/html:ro - ./nginx-8080.conf:/etc/nginx/conf.d/default.conf:ro