
Run Firefox in a rootless Podman container with dropped capabilities, isolated networking, and ephemeral storage to contain sandbox escapes and prevent host compromise.
Run Firefox in a rootless Podman container for security isolation. Your browser runs with almost no Linux capabilities, in its own user and network namespace, isolated from the host — while still having full GPU acceleration, audio, and DRM support.
Firefox already has a multi-process sandbox that isolates web content renderers using Linux namespaces and seccomp-bpf. For most threats, this is effective. foxcage adds a second wall: if an attacker exploits a vulnerability that escapes Firefox's sandbox (which happens — there are CVEs for this), they land inside a locked-down container instead of your full user session.
~/.ssh, ~/.gnupg, browser profiles for other browsers, password manager databases, documents, source code. In foxcage, the attacker sees only what you've explicitly mounted in.@tmp ephemeral cage leaves zero trace on disk after the window closes — including extensions, HSTS state, TLS session cache, and DNS cache that Firefox's Private Browsing still persists. Multiple @tmp cages run concurrently without interfering with each other.~/.config/autostart, ~/.bashrc, cron, or anywhere else to survive a reboot. foxcage's ephemeral container (--rm) means nothing persists unless you've bind-mounted it.localhost. On bare Firefox, a sandbox escape has full network access. (Use [network] mode = "host" if a cage needs localhost access, e.g. for local development — but see the caveat under "Networking": host mode also exposes the host's abstract Unix sockets.)CAP_SYS_CHROOT and blocks new privilege acquisition. Setuid binaries, kernel exploits via obscure syscalls, and similar escalation paths are cut off.profile, downloads_dir, extra bind mounts) is fully accessible to a compromised browser. If you mount a host profile directory, an attacker can tamper with it just as on bare Firefox.The container runs with:
CAP_SYS_CHROOT added back for Firefox's content sandbox; CAP_SETUID/CAP_SETGID added temporarily when init.root is configured)no-new-privileges to prevent privilege escalation--userns keep-id)/dev/shm (not shared with host) — configurable size via shm_sizenetwork.dns)XDG_RUNTIME_DIR are bind-mounted in (Wayland, PulseAudio, PipeWire, and the filtered D-Bus proxy) — the full host runtime directory is never exposedxdg-dbus-proxy running on the host. Only org.freedesktop.Notifications, , , and (for forks) the fork's own namespace (e.g. ) are reachable — session services like the keyring and the SSH/GPG agent are blockedEach [network] and [mounts] option you enable trades some isolation for convenience. The defaults are the most restrictive configuration that still gives you a usable browser.
sudo apt install passt) — unless network.mode = "host"sudo apt install xdg-dbus-proxy)/dev/dri foxcage warns and Firefox renders in softwareRun foxcage as your normal desktop user, not as root or via sudo — the sandbox maps your user into the container, and running as root removes the isolation foxcage exists to provide. It refuses to start as root.
Tested environment: Debian 13 (Trixie) with GNOME 3. Other Linux distributions and Wayland compositors may work but have not been tested.
foxcage is a single Python script with no dependencies outside the Python standard library. Copy it to a directory in your PATH:
sudo cp foxcage /usr/local/bin/foxcage
Or for a user-local install:
cp foxcage ~/.local/bin/foxcage
Make sure the script is executable (chmod +x foxcage).
Check which revision you have with foxcage --version — useful when reporting a problem, since foxcage is installed by copying a single file.
./foxcage
On first run the script builds the container image (downloads Firefox from Mozilla, installs minimal Debian dependencies) and then starts Firefox. On subsequent runs, foxcage checks for Firefox updates and rebuilds the image automatically when a new version is available. The image is also rebuilt periodically (every 7 days by default) to pick up system package updates. If the update check fails (network error, timeout), a warning is logged and the existing image is used — startup is never blocked.
Pass arguments through to Firefox:
./foxcage https://example.com
Combine a named cage with Firefox flags:
./foxcage @work --kiosk https://example.com
If a cage is already running, the URL opens in a new tab in the existing browser instead of starting a second container. Running foxcage (or foxcage @cage) with no URL against a running cage exits cleanly with a "cage is already running" message — foxcage can't raise an existing Wayland window from outside the container, so it doesn't try.
Per-launch flags do not apply when a cage is already running. --dns, --ipv4-only, --lifetime, --color and --fork are consumed when the container starts, and a running container's settings can't be changed from outside, so they are ignored with a warning. Close the cage and re-run to apply them.
Use the
private_browsingconfig key for private-mode sessions — not Firefox's raw--private-windowCLI flag. The config key sets session-wide private mode (browser.privatebrowsing.autostart), so subsequentfoxcage @cage URLinvocations can reopen in tabs.--private-windowas a Firefox passthrough would make only the first window private and break the reopen-in-tab behaviour above.Heads up: sessions enabled via
private_browsing = truedon't show Firefox's usual private-window UI cues (purple accent bar, mask icon, "(Private Browsing)" in the title). That's because every window in the session is private, so Firefox has no non-private window to visually contrast against — it suppresses the indicator. The session is genuinely private; verify if you want by visitingabout:privatebrowsingin the cage (shows the standard Private Browsing info page) orabout:configand checkingbrowser.privatebrowsing.autostart = true.
@tmpFor one-off links that should leave no trace, use the reserved tmp cage:
./foxcage @tmp https://somewhere-suspicious.example
Every @tmp launch is a fresh, throwaway Firefox with no persistent profile. When the window closes, everything is gone — cookies, cache, history, extensions, HSTS state, TLS session cache, DNS cache, saved tab state. This goes further than Firefox Private Browsing, which still persists extensions and a fair bit of on-disk state.
Multiple @tmp cages run concurrently, each isolated from the others. The menu bar shows FoxCage - tmp (<short id>) so you can tell concurrent ephemeral windows apart.
Ephemeral cages open a blank page on startup and blank new tabs — the default Firefox home page and new-tab content (top sites, Pocket recommendations, activity stream) are pure noise on a fresh profile that's about to be thrown away, so they're suppressed. Persistent cages keep Firefox's defaults.
If you want a meaningful name on a throwaway session (say, a research rabbit hole you'll want to reopen in a new tab), use @tmp-<name>:
./foxcage @tmp-research https://example.com # first call → new window
./foxcage @tmp-research https://another.example # second call → new tab in the existing window
@tmp-<name> is still ephemeral — when you close the window, everything goes. The difference from bare @tmp is that second launches with the same name reuse the existing window (same as persistent cages), so you can add more tabs later without starting a parallel copy. Bare @tmp keeps its "every launch a fresh throwaway" behaviour.
The menu-bar label shows the name you picked (FoxCage - tmp-research) so the window is labelled meaningfully.
Create ~/.config/foxcage/tmp.toml to set defaults for all ephemeral cages (both bare @tmp and every @tmp-<name>). For example:
private_browsing = true
lifetime = "30m"
[network]
dns = "cloudflare"
Every ephemeral launch now gets a private window, Cloudflare DoH, and auto-closes after 30 minutes — with full ephemerality intact. Named ephemerals inherit tmp.toml by default; if you want to override per name, create ~/.config/foxcage/tmp-<name>.toml. That file then applies instead of tmp.toml — no merging, the more-specific file wins outright. Copy shared defaults into it if you want them.
Anything you can set in a regular cage's config works here, except the one key that would defeat ephemerality itself:
profile — hard error.It points at a persistent on-host profile directory, which directly contradicts what @tmp is for. If you want a sandboxed cage with a persistent profile, use a regular named cage (@work, @research, etc.) that doesn't start with tmp-.
The --dns flag (and the equivalent network.dns config key) accepts three forms:
./foxcage @tmp --dns 1.1.1.1 https://example.com # IP
./foxcage @tmp --dns cloudflare https://example.com # alias
./foxcage @tmp --dns https://dns.nextdns.io/<id> # custom DoH URI
When the value matches a known provider (by alias or by IP), foxcage automatically enables forced DNS over HTTPS to that provider. Firefox's TRR gets set to mode 3 (strict, no cleartext fallback) with the bootstrap address filled in so there's no unencrypted resolution leak at startup. You see a one-line notice on stderr like Enabling DNS over HTTPS via Cloudflare.
Built-in aliases:
An IP that isn't in the table (e.g. your LAN's Pi-hole) stays cleartext only — no DoH is enabled, since foxcage doesn't know the corresponding DoH endpoint. Use the URI form for that: --dns https://pi.hole/dns-query (with a valid cert) enables DoH and leaves container DNS alone.
The URI form skips setting the container's cleartext DNS, so anything inside the container that isn't Firefox still uses the host's DNS. This is deliberate — --dns URI means "make Firefox use this DoH resolver", full stop.
--dns is incompatible with network.mode = "host", which already has full host network access.
Every named cage gets an accent colour in the menu bar so you can tell windows apart at a glance. You don't need to configure anything — the colour is deterministically derived from the cage name (SHA256-hashed into a hue, with fixed saturation and lightness). @banking, @work, @personal, @tmp-research all get distinct, stable colours without you lifting a finger.
The default (anonymous) cage keeps the baked-in orange.
If you want to override the auto-derived colour, set it explicitly:
# ~/.config/foxcage/banking.toml
color = "#dc2626" # red — overrides the auto-derived colour
./foxcage @experiment --color "#10b981" https://example.com # teal, one-off
Accepts standard CSS hex: #rgb, #rrggbb, or #rrggbbaa (with alpha). The auto-derived colours are tuned to be visible on both light and dark menubars (lightness fixed at 55%, saturation at 75%), so you shouldn't need to override for theme reasons.
The --lifetime flag (and equivalent lifetime config key) auto-closes a cage after a set duration. Format is <number><unit> with unit s, m, or h:
./foxcage @tmp --lifetime 10m https://example.com
./foxcage @work --lifetime 2h
The countdown starts when Firefox actually launches inside the cage — container startup and image-build time don't eat into your budget. The cage's menu-bar label shows the countdown alongside the cage identity — e.g. FoxCage - tmp (a3f2b1) | 9m — updated once per minute while there's more than a minute left, and once per second in the final minute. When the countdown hits zero Firefox closes itself and the container exits. If you close Firefox yourself before the lifetime is up, nothing unusual happens.
Set a default lifetime per cage in its config:
# ~/.config/foxcage/tmp.toml — every @tmp launch auto-closes after 15 minutes
lifetime = "15m"
private_browsing = true
--lifetime on the command line wins over any config value.
Force a full image rebuild (re-downloads Firefox and all system packages):
./foxcage --rebuild
A running container keeps whichever image it was started from, even after foxcage rebuilds the image tag. If you try to open a tab in a cage whose image has since been updated (by --rebuild, a Firefox update, or the scheduled rebuild), foxcage refuses with an error (also surfaced as a desktop notification) and asks you to quit Firefox and re-launch — which starts a fresh container on the current image. Under --rebuild with a live cage, foxcage warns up front, does the build, then applies the same check.
foxcage checks for new browser releases on each launch — Mozilla's release API for Firefox, GitLab's releases endpoint for LibreWolf. If an update is available, the container image is rebuilt automatically. The image is also rebuilt periodically (every 7 days by default) to pick up Debian security updates. The browser's built-in auto-updater is disabled since updates are handled at the image level.
If the update check fails (no network, API timeout), a warning is printed and the existing image is used — you can always browse.
Update cadence lives at the top level of the config; version and channel pinning live in the per-fork section:
rebuild_days = 14 # rebuild for base-image updates every 14 days (0 to disable)
[firefox]
channel = "beta" # track the beta channel instead of stable (firefox only)
version = "149" # pin to Firefox 149.x (latest patch release)
Pinning an ESR version needs the channel too. Mozilla's version index lists ESR releases without the esr suffix their downloads carry, so a bare version = "140" on the default channel resolves to a release that doesn't exist. Set both:
[firefox]
channel = "esr"
version = "140" # → 140.13.0esr
A pin that matches no release is now an error naming the pin, rather than quietly falling back to the latest release. A temporary failure to reach Mozilla's API still warns and carries on with the existing image, so a flaky network never blocks startup.
Suffixed pins must be fully qualified — "140.13.0esr" and "150.0b9" work, "140esr" and "150b9" are rejected at config load because no release can ever match them. The same applies to LibreWolf revisions: "146.0.1-1" works, "146-1" does not.
To force an immediate full rebuild: ./foxcage --rebuild
foxcage refuses to install a browser build whose signature was made by a signing subkey the upstream project revoked as compromised (RFC 4880 revocation reason 0x02). gpg --verify does not do this on its own: it prints a warning and exits 0, so without the extra check a leaked signing key would still authenticate a tampered download.
A refusal looks like this, and fails the build rather than installing:
foxcage: REFUSING /tmp/SHA512SUMS - signed by 09BEED63F3462A2DFFAB3B875ECB6497C1A20256,
which its owner revoked as compromised. This build cannot be trusted; wait for
upstream to re-sign this release with a current key.
There is nothing to configure and no override. If you hit this, the fix is upstream's: either pin a release signed with a current key, or wait for the affected release to be re-signed.
Routine key rotation is treated differently. A subkey revoked as superseded, retired, or with no reason given does not invalidate signatures made before the revocation, so those releases install with a warning. A signature dated after any revocation is refused whatever the stated reason.
Mozilla's August 2026 key rotation. Mozilla revoked the signing subkey 09BEED63…C1A20256 on 2026-08-06 after an unencrypted copy was committed to a private GitHub repository, and replaced it with 827E6586…76767AA3. Firefox releases signed with the old subkey — everything between 2025-03-13 and 2026-08-06, which at the time of writing still includes the current ESR (140.13.0esr) and any version pin into that window — are refused by the check above. Release and beta channels are unaffected. foxcage fetches keys from keys.openpgp.org rather than keyserver.ubuntu.com because the latter served neither the replacement subkey nor the revocation for days after the rotation; a stale keyserver would both break builds outright and silently reduce the revocation check to a no-op.
foxcage can run a privacy-oriented Firefox fork in place of upstream Firefox:
fork = "librewolf" # default is "firefox"
[librewolf]
version = "146.0.1-1" # optional pin; partial pins ("146", "146.0.1") also work
Or per-launch via CLI:
foxcage @tmp --fork librewolf https://example.com
LibreWolf: privacy-hardened Firefox fork — strict tracking protection, DoH, RFP, telemetry locked off by default. Signed Linux tarball from GitLab (librewolf-community/browser/bsys6), GPG-verified against the LibreWolf Maintainers key 662E 3CDD 6FE3 2900 2D0C A5BB 4033 9DD8 2B12 EF16 with a sibling .sha256sum cross-check, under the same revocation rules as Firefox. LibreWolf's bundled librewolf.cfg is preserved; foxcage appends its own prefs on top rather than overwriting. The maintainers rotated their signing subkey on 2026-04-25 without stating a reason; current tarballs were signed before that date, so they install with a warning rather than being refused.
Channel is Firefox-only: firefox.channel = "beta" | "esr" is rejected when fork is anything other than "firefox". LibreWolf has a single release track.
Switching fork (via config or --fork) changes the Containerfile hash, which triggers a rebuild on the next launch — no manual --rebuild needed.
Use a dedicated profile per fork. The safest default is to let foxcage provision its own profile (omit
profilefrom config), or pointprofileat a directory you don't also open from the host.
compatibility.ini schema conflicts are rare. Risks: (1) only sequential use is safe (Firefox's lock file prevents concurrent opens); (2) in the short window after a Firefox stable release, running Firefox first then LibreWolf may trigger a "used by a newer version" migration dialog; (3) features LibreWolf strips (Sync, Pocket, Mozilla account) silently don't work but don't corrupt data.Run separate sandboxed instances with their own config and Firefox profile:
./foxcage @work
This loads ~/.config/foxcage/work.toml and uses a separate image (foxcage-work), container (foxcage-work), and volume (foxcage-work-profile). The config file must exist for named cages. Cage names may only contain letters, digits, hyphens, and underscores.
Config files live in $XDG_CONFIG_HOME/foxcage/ (defaults to ~/.config/foxcage/).
config.toml — default cage (optional, sensible defaults without it)<name>.toml — named cage, loaded with @<name> (required)Unknown config keys are rejected with an error. See config.toml.example for all available options with defaults.
# Bind-mount a host Firefox profile directory into the cage
profile = "~/.mozilla/firefox/xxxxxxxx.default-release"
# Allow downloading files to ~/Downloads
downloads_dir = "~/Downloads"
# Shared memory size for Firefox IPC (default: 256m)
# shm_size = "256m"
# Pass through webcam devices (/dev/video*)
# webcam = true
# Pass through host CUPS socket for locally-connected printers (e.g. USB)
# local_printers = true
# Pass through FIDO2/U2F security key devices (/dev/hidraw*)
# security_keys = true
# Always open Firefox in private browsing mode
# private_browsing = true
# Auto-close the cage after a duration (<int> with unit s, m, or h)
# lifetime = "30m"
# Accent colour for the menu-bar label. Named cages get a colour derived
# from the name automatically; set this to override it.
# color = "#4a90e2"
# Browser fork: "firefox" (default) or "librewolf"
# fork = "librewolf"
# Full image rebuild interval in days for base-image updates (default: 7, 0 to disable)
# rebuild_days = 7
[firefox]
# Firefox release channel: "release" (default), "beta", "esr".
# Only valid when fork = "firefox".
# channel = "release"
# Pin to a specific Firefox version (overrides channel).
# Partial versions like "149" or "149.0" resolve to the latest patch release.
# Suffixed versions must be fully qualified ("140.13.0esr", "150.0b9"); to
# follow the ESR line by major version, pair a numeric pin with
# channel = "esr" above.
# version = "149.0.2"
[librewolf]
# Pin to a specific LibreWolf version. Tags are "<firefox-version>-<rev>",
# e.g. "146.0.1-1". Partial pins like "146" or "146.0.1" also work.
# version = "146.0.1-1"
[network]
# "host" for full host networking (needed if the cage has to reach services
# on the host's localhost), or omit for isolated pasta (default)
# mode = "host"
# DNS server (isolated mode only, default: host DNS)
# dns = "1.1.1.1"
# Disable IPv6 in the cage (isolated mode only)
# ipv4_only = true
[mounts]
# Additional bind mounts into the container. Supported forms:
# "~/Documents" — same path in container
# "~/Documents:~/Documents" — ~ expanded on both sides
# "~/Documents:/home/user/Documents" — explicit container path
# Append :ro for read-only, e.g. "~/Documents:ro"
# nosuid,noexec are always enforced on bind mounts; an explicit "exec" or
# "suid" is rejected rather than silently dropped.
# Host paths must be absolute or start with "~/".
bind = [
"~/Documents:ro",
]
[init]
# Commands to run at image build time (as root). Changes trigger a rebuild.
# build = ["apt-get update && apt-get install -y --no-install-recommends vim"]
# Commands to run at container startup as root, before Firefox.
# root = ["chown user:user /some/path"]
# Commands to run at container startup as your user, before Firefox.
# user = ["mkdir -p ~/custom-dir"]
To share a host Firefox profile with the cage, set profile to the profile directory. Find your profile path by visiting about:profiles in Firefox on the host — or just point at a fresh empty directory if you want the cage to start with a clean profile that persists on the host.
profile = "~/.mozilla/firefox/xxxxxxxx.default-release"
Only this one directory is bind-mounted into the cage. Sibling profiles under ~/.mozilla/firefox/ and the profiles.ini registry are not exposed — a compromised cage cannot tamper with them.
If profile is unset, a named Podman volume stores the Firefox profile instead (see "What persists" below). If the same profile is already open in Firefox on the host, Firefox's per-profile lock file will cause a clash — use a dedicated profile per cage.
By default, the container uses pasta with host loopback blocked and host DNS. pasta requires podman 4.4 or newer (it has been the rootless default since podman 5.0).
Host networking removes network isolation entirely. Use this when the cage needs to reach services on the host's localhost (e.g. a local dev server, a database on 127.0.0.1):
[network]
mode = "host"
dns cannot be combined with mode = "host" — host networking already uses the host's resolver.
Host mode gives up more than localhost. It puts the cage in the host's network namespace, and abstract Unix sockets are scoped to that namespace rather than to the filesystem. So a cage in host mode can reach abstract-address sockets on the host directly — including Xwayland's
@/tmp/.X11-unix/X0if you run X11 or Xwayland (input logging, despite foxcage being Wayland-only), and a session bus configured withunix:abstract=…, which would bypass the filtered D-Bus proxy. This is inherent to sharing the network stack, not something foxcage can filter. Use host mode when you need it, and prefer a named cage you only launch for that purpose.
IPv4-only cages disable IPv6 entirely:
[network]
ipv4_only = true
Or per launch with the --ipv4-only flag (short form -4, as in ssh/curl/pasta):
./foxcage @tmp -4 https://example.com
This runs pasta in IPv4-only mode (-4), so the container has no IPv6 stack at all, and additionally sets network.dns.disableIPv6 in Firefox so it doesn't resolve AAAA records — which matters when DoH is enabled, since DoH answers bypass the container's resolver. ipv4_only cannot be combined with mode = "host" — host networking uses the host's network stack directly, so disable IPv6 on the host instead.
Run custom commands at build time or container startup via [init]:
build — runs at image build time as root. Use for installing packages or other slow setup. Changes to build commands automatically trigger an image rebuild.root — runs at container startup as root, before Firefox. Use for quick runtime root tasks (adjusting permissions, writing config files).user — runs at container startup as your user, before Firefox. Use for creating directories, setting up user-level state.[init]
build = [
"apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk",
"rm -rf /var/lib/apt/lists/*",
]
root = ["chmod 777 /tmp/shared"]
user = ["mkdir -p ~/workspace"]
All three keys are lists of shell command strings. If any command fails, the container exits without starting Firefox.
Security note: When init.root is set, the container starts as root with CAP_SETUID and CAP_SETGID added (on top of the default CAP_SYS_CHROOT) so it can drop back to the regular user. These capabilities are only held during the root init phase — after the privilege drop, the regular-user process has no extra capabilities. Without init.root, the container runs with the default minimal capability set.
Without configuration, a named Podman volume stores the Firefox profile (bookmarks, settings, extensions, Widevine DRM plugin). Everything else is ephemeral.
foxcage-profilefoxcage-<name>-profileTo start fresh, remove the volume:
podman volume rm foxcage-profile
If profile is set, the host directory is bind-mounted directly and no volume is created.
Each cage image is around 1 GB. A rebuild retags the image and leaves the previous one behind as an untagged <none> entry, so foxcage removes the image it just displaced after every successful build. It removes only that specific image, and never one a running cage is still using.
Images orphaned before this behaviour existed are not cleaned up retroactively. To reclaim them:
podman images --filter dangling=true # review first
podman image prune # then remove
Firefox updates are detected automatically on each launch. To force a full rebuild (e.g. to immediately pick up system security updates):
./foxcage --rebuild
foxcage automatically passes through the following from the host, so Firefox in the container looks and feels like a native application:
/usr/share/fonts) and user fonts (~/.local/share/fonts) are bind-mounted read-only. Font configuration from ~/.config/fontconfig is also passed through.GTK_THEME or gsettings and passed to the container. GTK configuration from ~/.config/gtk-3.0 and ~/.config/gtk-4.0 is bind-mounted read-only.TZ, the /etc/localtime symlink, or /etc/timezone) is passed into the container as TZ, and /etc/localtime is bind-mounted read-only. Both are needed: Firefox derives the JavaScript timezone from the zone name, not the file contents — without , websites would show times in UTC.Cage label. The Firefox menu bar displays "FoxCage" (or "FoxCage - name" for named cages) so you can tell at a glance that you're in a containerised session. The menu bar is always visible via enterprise policy.
The container only includes the Adwaita GTK theme. On GNOME desktops this works out of the box. On KDE or other desktops, Firefox will fall back to Adwaita if your GTK theme (e.g. Breeze) isn't installed in the container. Dark mode detection still works as long as the preference is set via gsettings or GTK_THEME.
Widevine DRM works out of the box. On first visit to a DRM-protected site, Firefox will download the Widevine CDM automatically. This may take a moment.
foxcage uses a filtered D-Bus proxy to give Firefox access to the host's XDG Desktop Portal and notification daemon. These features are safe because all access is user-mediated — the host shows native dialogs that you must interact with. A compromised browser cannot silently access host resources.
mailto:, magnet links, etc. open via host app chooserThese pass host devices directly into the container and are off by default — unlike the portal features above, there is no host-side confirmation. A compromised browser could use the hardware silently.
webcam = true # /dev/video* — webcam for video calls
local_printers = true # CUPS socket — USB printers (network printers work by default)
security_keys = true # /dev/hidraw* — FIDO2/U2F hardware keys
Some web platform features do not work in the container due to missing host integration. These are listed here for transparency.
Bluetooth, USB, serial, and NFC. The Web Bluetooth, WebUSB, Web Serial, and WebNFC APIs require device access and system services (BlueZ, udev) that are not available in the container.
Gamepads and MIDI. The Gamepad API needs /dev/input/ access. Web MIDI needs ALSA sequencer access. Neither is passed through.
PWA installation. Progressive Web Apps cannot be installed to the host desktop from inside the container.
Accessibility. Screen reader support via AT-SPI is disabled (NO_AT_BRIDGE=1) — the container has no connection to the host's accessibility bus. Web Speech API synthesis does work: speech-dispatcher with the espeak-ng engine is installed in the cage and autospawned on first use, with audio routed through the shared PulseAudio socket.
Rootless Podman may default to the vfs storage driver, which copies entire image layers instead of using overlay mounts. This makes container startup after a build much slower. To fix this, install fuse-overlayfs and add the following to ~/.config/containers/storage.conf:
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
First, make sure the foxcage script is in its permanent location (e.g. ~/bin/foxcage or /usr/local/bin/foxcage). The install command records the script's current path in the .desktop file, so moving it afterwards will break the launcher.
Then run:
foxcage --install
This creates a .desktop file pointing to the script's current location, installs the foxcage icon, and refreshes the desktop and icon databases. FoxCage should then appear in your application menu.
To set foxcage as the default web browser so that links clicked in other applications open in foxcage:
xdg-settings set default-web-browser foxcage.desktop
If a cage is already running, URLs open as a new tab in the existing browser.
To undo:
foxcage --uninstall
StartupNotify=true is set in the .desktop file, which tells the compositor to show a spinner cursor while foxcage starts. When an image build is needed (which can take several minutes), foxcage sends a desktop notification so you know Firefox is on its way. Any early-exit error (config typo, missing dependency, malformed cage name) is also surfaced as a desktop notification so desktop-launched users aren't left staring at nothing when foxcage fails without a terminal attached. Both require notify-send (from libnotify-bin on Debian/Ubuntu) — if it is not installed, notifications are silently skipped and the error still goes to stderr.
If you prefer to create the .desktop file manually, create ~/.local/share/applications/foxcage.desktop:
[Desktop Entry]
Type=Application
Name=FoxCage
Comment=Firefox in a rootless Podman container
Exec=/path/to/foxcage %u
Icon=foxcage
MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https;
Terminal=false
Categories=Network;WebBrowser;
StartupNotify=true
StartupWMClass=foxcage
Replace /path/to/foxcage with the actual path to the script. Register it:
update-desktop-database ~/.local/share/applications
The test suite uses pytest + pytest-cov, declared as dev-only dependencies in requirements-dev.txt.
pip install -r requirements-dev.txt
pytest
Tests are fully hermetic — no podman, no network, no real filesystem beyond pytest's tmp_path. The suite gates on 100% line and branch coverage (configured in pytest.ini and .coveragerc); any uncovered line, or untaken side of a conditional, fails the run. CI runs the suite on every push via .gitlab-ci.yml.
This project was developed by Mike Cardwell, with the assistance of Claude Code, Anthropic's AI coding tool.
org.freedesktop.portal.Desktoporg.mozilla.*org.librewolf.*org.freedesktop.portal.Desktop is allowed as a whole, because that is how the file picker, "open link in another app", and screen sharing work. It also exposes RemoteDesktop (synthetic keyboard/mouse for the whole session), Camera and Location. Those are gated by your desktop's own approval dialogs rather than by foxcage — and the RemoteDesktop prompt resembles the screen-share prompt, so read approval dialogs before accepting them. xdg-dbus-proxy has no "deny one interface" rule, so narrowing this means enumerating every interface Firefox needs; see docs/DESIGN.md for why that is not done by defaultprofile, downloads_dir, extra [mounts] bind) use nosuid,noexecgpg --verify, which exits 0 for a signature made by a revoked key and for any key in the keyring. foxcage additionally requires that the signature chains to the pinned primary key, and refuses any release signed by a subkey its owner revoked as compromised — see Revoked signing keys--rm) — filesystem writes are lost on exit| Alias | IP | Filtering |
|---|
cloudflare | 1.1.1.1 | none |
cloudflare-security | 1.1.1.2 | blocks malware |
cloudflare-family | 1.1.1.3 | blocks malware + adult |
google | 8.8.8.8 | none |
quad9 | 9.9.9.9 | blocks malware (Quad9 default) |
quad9-unfiltered | 9.9.9.10 | none |
adguard | 94.140.14.14 | blocks ads + trackers |
adguard-family | 94.140.14.15 | ads + trackers + adult |
opendns | 208.67.222.222 | some |
TZLANG is passed through. The host's locale is generated in the container image at build time.