The codex container¶
codex packages the OpenAI Codex CLI in a
hardened container: it runs as a non-root user on a read-only root filesystem,
has only the capabilities required for egress enforcement, and denies outbound
network access until the user supplies an allowlist.
The image is based on python:3.14-slim-trixie and Node LTS. Although Codex
is implemented in Rust, its npm package supplies the native CLI executable, so
the runtime image does not install a Rust toolchain. Python, uv, and uvx
remain available for Python-based MCP servers and project tooling.
Nested sandboxing¶
The image starts Codex with --sandbox danger-full-access by default. This
disables Codex's nested process sandbox, which cannot create the namespaces it
needs under the documented container security flags. It does not give Codex
additional access outside the container: the outer container remains the
security boundary and continues to enforce its read-only root filesystem,
explicit writable mounts, unprivileged user, dropped capabilities, and egress
policy.
The image supplies this default through its codex wrapper, so it also applies
when a Docker command overrides the image's CMD, such as codex codex login.
Pass another global --sandbox option explicitly to override the default for a
container started with different runtime permissions.
Build¶
Matching your host user's UID/GID¶
The image defaults to UID/GID 1000:1000. Everything the agent writes to
the bind-mounted project directory or the persistent home volume is owned
by whatever UID/GID the container ran as — if that doesn't match your host
account, those files aren't owned by you on the host (permission errors,
needing sudo to clean up). Rebuild with matching values instead:
docker build --build-context shared=agent-images/shared \
--build-arg UID=$(id -u) --build-arg GID=$(id -g) \
-t codex:$(whoami) agent-images/codex
Dockerfile splits into a base stage that installs everything —
apt/Node.js/uv baseline, packages-apt.txt/packages-uv.txt, the Codex
CLI itself, and the account-scoped packages-npm.txt/tools-uv.txt —
under a fixed placeholder account (UID/GID 1000:1000), and a final
stage that only remaps that account to the UID/GID build args
(usermod/groupmod plus a chown -R of what base already installed)
when they differ from the placeholder. Nothing in final reinstalls
packages, so rebuilding for a different UID after the first build is fast.
On a host shared by multiple accounts, tag the image per user
(codex:alice, codex:bob) rather than overwriting a shared codex tag —
and use that same tag, plus a per-user home volume (codex-home-alice
rather than the shared codex-home), in the run command below. The
shell-shortcut functions
do both substitutions automatically from a single CONTAINED_CODEX_TAG
variable; if invoking docker run directly instead, substitute your tag
into both the codex-home volume name and the trailing codex image
reference in the command below — copying it unchanged still runs the shared
codex:latest.
Run¶
For API-key authentication, pass the key at runtime rather than baking it into the image or an image layer. This starts Codex with API-key authentication; it does not initiate a ChatGPT login:
docker run -it --rm \
--security-opt=no-new-privileges \
--read-only --tmpfs /tmp --tmpfs /run \
--cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
-e OPENAI_API_KEY \
-e 'AGENT_ALLOWED_EGRESS=api.openai.com,auth.openai.com,chatgpt.com' \
-v codex-home:/home/codex \
-v "$PWD":"/workspace/$(basename "$PWD")" \
-w "/workspace/$(basename "$PWD")" \
codex
Hardening flags¶
| Flag | Purpose |
|---|---|
--security-opt=no-new-privileges |
Prevents escalation through setuid binaries. |
--read-only |
Keeps the root filesystem immutable. |
--tmpfs /tmp and --tmpfs /run |
Provide ephemeral writable locations for the egress-control tooling and runtime files. |
--cap-drop=ALL |
Removes Docker's default capabilities. |
NET_ADMIN and NET_RAW |
Required for the default-deny iptables/ip6tables rules and domain allowlists. |
SETUID and SETGID |
Let gosu switch from the setup user to the unprivileged codex user. |
codex-home persists Codex configuration, conversation state, authentication
material, and user-installed npm/Python tools. Mounting each project below
/workspace/<project-name> keeps per-project state distinct while retaining a
shared home volume.
Custom configuration¶
Codex owns and may rewrite /home/codex/.codex/config.toml, including while
recording project trust. Keep that mutable primary file in codex-home; do
not assume that a single-file bind mount works merely because it is not marked
read-only. See custom configuration files
for the file-update constraints and local models
for a version-recorded candidate that uses CLI overrides instead.
Authentication¶
Choose one authentication mode:
API key¶
Use OPENAI_API_KEY, as in the example above, or store a key in the
persistent home volume with:
printf '%s\n' "$OPENAI_API_KEY" | docker run -i --rm \
--security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
--cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
-e 'AGENT_ALLOWED_EGRESS=api.openai.com,auth.openai.com,chatgpt.com' \
-v codex-home:/home/codex \
codex codex login --with-api-key
ChatGPT account¶
Use the CLI's device-authorization flow. Do not rely on Codex's usual localhost callback login: the browser runs on the host, while that callback listener is isolated inside the hardened container and no port is published to the host.
docker run -it --rm \
--security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
--cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
-e 'AGENT_ALLOWED_EGRESS=api.openai.com,auth.openai.com,chatgpt.com' \
-v codex-home:/home/codex \
codex codex login --device-auth
The CLI displays a URL and user code. Open the URL on the host, complete the
sign-in, and leave the container running while it waits for completion. The
codex-home volume retains the resulting credentials. For later runs, omit
OPENAI_API_KEY and keep the identity hosts in the allowlist if Codex needs
to refresh the login. If the current CLI reports another identity host, add it
to the allowlist rather than opening unrestricted egress. The first codex
above is the image name; the second invokes the CLI because providing a Docker
command replaces the image's default command.
After login completes, start the agent in a new container without the login arguments:
docker run -it --rm \
--security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
--cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
-e 'AGENT_ALLOWED_EGRESS=api.openai.com,auth.openai.com,chatgpt.com' \
-v codex-home:/home/codex \
-v "$PWD":"/workspace/$(basename "$PWD")" \
-w "/workspace/$(basename "$PWD")" \
codex
Egress and gateway-client mode¶
The entrypoint accepts the repository-wide AGENT_ALLOWED_EGRESS variable or
a mounted /etc/agent/egress-allowlist.txt file; the file takes precedence.
Mount a host-maintained policy file read-only with
-v "/path/to/egress-allowlist.txt":/etc/agent/egress-allowlist.txt:ro.
The sample file at agent-images/codex/examples/egress-allowlist.txt includes
api.openai.com, auth.openai.com, and chatgpt.com, which are required by
the documented ChatGPT-account flow and the codex_apps MCP integration. Add
MCP server, package registry, source-control, and other explicit service hosts
as needed. Unset both inputs to retain deny-all, or use
-e 'AGENT_ALLOWED_EGRESS=*' only when unrestricted egress is deliberate.
Quoting prevents shells such as zsh from treating * as a filename pattern.
Set AGENT_GATEWAY_HOST to tunnel all traffic through
agent-gateway instead. In gateway-client mode, the
workload's local allowlist is ignored and the gateway owns the policy. The
gateway key, host-key pinning, bootstrap rule, and optional Cloudflare Access
settings use the same contract documented in
the Claude Code gateway guide.
Optional build-time tools¶
Edit packages-apt.txt, packages-npm.txt, tools-uv.txt, or
packages-uv.txt and rebuild to add general-purpose tools. tools-uv.txt
(uv tool install) is for standalone Python CLI tools, each in its own
isolated venv; packages-uv.txt (uv pip install --system) is for plain
importable libraries with no console-script of their own, installed into the
image's system Python instead. They are installed separately from the
image's required infrastructure, so edits cannot remove egress enforcement
or the privilege-drop tooling.
The image also sets UV_LINK_MODE=copy — see claude-code's uv cache
notes for why.