Try describing what you need:

E2B logo

E2B

★ 1.8k
Apache License 2.0Open Source — No Paywall21 Containers — 21 containers — heavy hosting footprintCompose · K8s · SourceNeeds Postgres5 documented maintainers

About

E2B Runtime is the backend infrastructure behind E2B Cloud, designed for running agent workloads inside isolated Firecracker microVMs. It provides the control plane, orchestration layer, edge...

Community Ratings

No ratings yet
E2B Runtime

Features

  • Firecracker microVM sandboxes
  • pause and resume
  • fork running sandboxes
  • template building from Docker images
  • REST API
  • gRPC orchestration
  • port forwarding and sandbox URLs
  • persistent volumes
  • secrets and workload identity
  • OpenTelemetry observability

Details

Last Updated
Oct 28, 2026
Created
Aug 10, 2023
Install Methods
docker-composekubernetessource
Requirements
Linux with KVMPostgreSQLRedisClickHouseobject storage
Authentication
local
API
RESTGRPC
Privacy & Independence
No TelemetryCloud: Optional
Deployment

Deployment: Docker Compose ✓21 Containers — heavy hosting footprintRuns as 21 containers

Run E2B with Docker Compose

Compose file from the E2B project repository.

21 services · database: postgres · infra: postgres, redis · 10 unclassified (clickhouse-server, vector, opentelemetry-collector-contrib, ${e2b_db_migrator_image, ${e2b_clickhouse_migrator_image, ${e2b_seed_image, ${e2b_api_image, ${e2b_dashboard_api_image, ${e2b_dashboard_image, ${e2b_client_proxy_image)

no resource limits declared

needs persistent storage (11 volumes)

embed/compose/compose.yaml · @ 9a9db27 · scanned Oct 28, 2026

# The install is this file plus .env, side by side, and nothing else.
#
# Everything .env pins is interpolated as ${VAR:?...}, so a missing or partial
# .env fails `docker compose config` and `up` at parse time, naming the
# variable. Without it Compose renders a blank image name or a blank kernel
# version and only warns, and the operator meets either a confusing "neither
# an image nor a build context" error or a fetch-artifacts failure after the
# host has already been mutated. The nine knobs that are genuinely optional and
# are absent from .env (TEAM_API_KEY, ADMIN_TOKEN, SANDBOX_ACCESS_TOKEN_HASH_SEED,
# E2B_DASHBOARD_HOST and E2B_OTEL_COLLECTOR_GRPC_ENDPOINT only as commented
# lines) keep the ${VAR:-...} form: HUGEPAGES, NBDS_MAX, PF_MIN_FREE_GIB,
# FORCE_REBUILD, E2B_DASHBOARD_HOST and E2B_OTEL_COLLECTOR_GRPC_ENDPOINT carry a
# working default (the last an empty one, which exports no telemetry), while
# TEAM_API_KEY, ADMIN_TOKEN and SANDBOX_ACCESS_TOKEN_HASH_SEED carry none — the
# stack generates all three per install (seed for the key, api-secrets for the
# api's two), and a value set in .env or the shell overrides the generated one.
name: e2b

x-tools: &tools
  image: ${E2B_TOOLS_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}

x-node-e2b: &node-e2b
  image: ${E2B_NODE_E2B_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}

# Docker's json-file driver keeps a container's log forever by default. On a
# full disk a service that fails on every write, ClickHouse above all, writes
# error output faster than anything frees space, and the disk stays full until
# someone steps in. Every service aliases this cap: five files of 50 MiB,
# 250 MiB a service, and `docker compose logs` reads the rotated files as
# before. The driver is named so the options are valid on a daemon whose
# default driver is another one.
x-logging: &logging
  driver: json-file
  options:
    max-size: "50m"
    max-file: "5"

services:
  postgres:
    logging: *logging
    image: postgres:18.6-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
      PGDATA: /var/lib/postgresql/18/docker
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
      interval: 5s
      timeout: 3s
      retries: 20

  redis:
    logging: *logging
    image: redis:7.4.6
    restart: unless-stopped
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 20

  clickhouse:
    logging: *logging
    image: clickhouse/clickhouse-server:25.8.30.16
    restart: unless-stopped
    environment:
      CLICKHOUSE_DB: default
      CLICKHOUSE_USER: clickhouse
      CLICKHOUSE_PASSWORD: clickhouse
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
      # sha256 of config/clickhouse/config.xml, same purpose as
      # VECTOR_CONFIG_SHA256 on the vector service.
      CLICKHOUSE_CONFIG_SHA256: 48f50a1778bd986fdfb74c3f1e796816dbaa78d56c26443b604cbba205504f83
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:8123:8123"
    volumes:
      - clickhouse:/var/lib/clickhouse
    configs:
      - source: clickhouse-config
        target: /etc/clickhouse-server/config.d/config.xml
    healthcheck:
      test: ["CMD-SHELL", "clickhouse-client --user clickhouse --password clickhouse --query 'SELECT 1'"]
      interval: 5s
      timeout: 3s
      retries: 30

  vector:
    logging: *logging
    image: timberio/vector:0.51.1-alpine
    restart: unless-stopped
    depends_on:
      clickhouse:
        condition: service_healthy
    environment:
      VECTOR_CONFIG: /etc/vector/vector.toml
      VECTOR_LOG: warn
      # sha256 of config/vector/vector.toml. Inert for Vector; it makes the
      # service hash change with the inline config so `up` recreates the
      # container (compose does not hash `configs:` content). Kept in step by
      # tests/config-hashes.bats.
      VECTOR_CONFIG_SHA256: a4ca48367c4ae06f81fa08d9f8cbcace70f25a631f00496a44d96282e469b7a3
    ports:
      - "127.0.0.1:30006:30006"
    configs:
      - source: vector-config
        target: /etc/vector/vector.toml
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:44313/health >/dev/null"]
      interval: 10s
      timeout: 2s
      retries: 12

  # The built-in OpenTelemetry collector, run only with the otel profile:
  # COMPOSE_PROFILES=otel in .env, which Compose reads itself. It receives
  # OTLP/gRPC on 127.0.0.1:4317, the address to give
  # E2B_OTEL_COLLECTOR_GRPC_ENDPOINT, and writes the e2b.* metrics into
  # ClickHouse (config/otel/otel-collector.yaml says what it keeps). The image
  # has no shell, so there is no healthcheck; `curl -s 127.0.0.1:13133` on the
  # host is the check. No service depends on it, `ready` included.
  otel-collector:
    logging: *logging
    image: otel/opentelemetry-collector-contrib:0.163.2
    profiles: [otel]
    restart: unless-stopped
    network_mode: host
    depends_on:
      clickhouse-migrator:
        condition: service_completed_successfully
    environment:
      # sha256 of config/otel/otel-collector.yaml, same purpose as
      # VECTOR_CONFIG_SHA256 on the vector service.
      OTEL_CONFIG_SHA256: 96621f22e4e8ab2b420a6bc659c0b47239535f90627951aba449a309f614ce5f
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    configs:
      - source: otel-collector-config
        target: /etc/otelcol-contrib/config.yaml

  db-migrator:
    logging: *logging
    image: ${E2B_DB_MIGRATOR_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: "no"
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      POSTGRES_CONNECTION_STRING: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable

  clickhouse-migrator:
    logging: *logging
    image: ${E2B_CLICKHOUSE_MIGRATOR_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: "no"
    depends_on:
      clickhouse:
        condition: service_healthy
    environment:
      GOOSE_DBSTRING: clickhouse://clickhouse:clickhouse@clickhouse:9000/default

  seed:
    logging: *logging
    image: ${E2B_SEED_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: "no"
    depends_on:
      db-migrator:
        condition: service_completed_successfully
    environment:
      POSTGRES_CONNECTION_STRING: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
      OIDC_ISSUER: http://localhost:4444/
      OIDC_SUBJECT: local-dev-user
      # The team API key is per install. The seed generates one on the first
      # run and keeps it in the seed-state volume, where a re-run finds it again
      # and base-template, smoke and ready read it. TEAM_API_KEY in .env or the
      # shell (e2b_ plus at least 32 hex characters) pins it instead, and
      # changing it later rotates: the next up inserts the new key and revokes
      # the seed's earlier one (the api's team-auth cache keeps the old key
      # working for up to five minutes). Removing the key file, or `down -v`,
      # which drops the volume with the databases, makes the next up generate
      # a fresh key the same way.
      SEED_TEAM_API_KEY: ${TEAM_API_KEY:-random}
      SEED_TEAM_API_KEY_FILE: /run/e2b/team-api-key
    volumes:
      - seed-state:/run/e2b

  preflight:
    <<: *tools
    logging: *logging
    restart: "no"
    network_mode: host
    pid: host
    privileged: true
    environment:
      PF_MIN_FREE_GIB: ${PF_MIN_FREE_GIB:-20}
    command: ["/bin/sh", "-c", "exec nsenter -t 1 -m -u -i -n -p -- /bin/bash -s < /opt/e2b/scripts/preflight.sh"]

  host-setup:
    <<: *tools
    logging: *logging
    restart: "no"
    network_mode: host
    pid: host
    privileged: true
    depends_on:
      preflight:
        condition: service_completed_successfully
    environment:
      HUGEPAGES: ${HUGEPAGES:-2048}
      NBDS_MAX: ${NBDS_MAX:-64}
    command: ["/bin/sh", "-c", "exec nsenter -t 1 -m -u -i -n -p -- /bin/bash -s < /opt/e2b/scripts/host-setup.sh"]

  fetch-artifacts:
    <<: *tools
    logging: *logging
    restart: "no"
    network_mode: host
    depends_on:
      host-setup:
        condition: service_completed_successfully
    environment:
      HOST_ROOT: /host
      E2B_ORCHESTRATOR_VERSION: ${E2B_ORCHESTRATOR_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      E2B_ENVD_VERSION: ${E2B_ENVD_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      E2B_KERNEL_VERSION: ${E2B_KERNEL_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      E2B_FIRECRACKER_VERSION: ${E2B_FIRECRACKER_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      E2B_BUSYBOX_VERSION: ${E2B_BUSYBOX_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
    volumes:
      - /:/host
    command: ["/bin/bash", "/opt/e2b/scripts/fetch-artifacts.sh"]

  # The api's two secrets are per install, the way the team API key is. This
  # generates both on the first start and keeps them in the seed-state volume,
  # where a re-run finds them again and the api's entrypoint reads them.
  # ADMIN_TOKEN or SANDBOX_ACCESS_TOKEN_HASH_SEED in .env or the shell
  # overrides one of them without touching the other. Removing the file, or
  # `down -v`, which drops the volume with the databases, makes the next up
  # generate a fresh pair: a new hash seed invalidates the tokens of running
  # `secure` sandboxes and a new admin token invalidates admin clients.
  # preflight is the only gate — nothing here touches the host, so the pair is
  # written long before the api needs it — and the values are never printed.
  api-secrets:
    <<: *tools
    logging: *logging
    restart: "no"
    depends_on:
      preflight:
        condition: service_completed_successfully
    volumes:
      - seed-state:/run/e2b
    command:
      - /bin/sh
      - -c
      - |
        set -eu
        if [ -s /run/e2b/api.env ]; then
          echo "api-secrets: reusing /run/e2b/api.env"
          exit 0
        fi
        umask 077
        hex() { head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
        tmp=/run/e2b/api.env.tmp
        printf 'GEN_ADMIN_TOKEN=%s\nGEN_SANDBOX_ACCESS_TOKEN_HASH_SEED=%s\n' \
          "$$(hex)" "$$(hex)" > "$$tmp"
        chmod 600 "$$tmp"
        mv "$$tmp" /run/e2b/api.env
        echo "api-secrets: generated /run/e2b/api.env"

  orchestrator:
    <<: *tools
    logging: *logging
    restart: unless-stopped
    network_mode: host
    pid: host
    cgroup: host
    privileged: true
    environment:
      ORCHESTRATOR_SERVICES: orchestrator,template-manager
      NODE_ID: orchestrator
      NODE_IP: 127.0.0.1
      ENVIRONMENT: local
      GRPC_PORT: "5008"
      PROXY_PORT: "5007"
      PPROF_PORT: "6061"
      REDIS_URL: 127.0.0.1:6379
      CLICKHOUSE_CONNECTION_STRING: clickhouse://clickhouse:clickhouse@127.0.0.1:9000/default
      LOGS_COLLECTOR_ADDRESS: http://127.0.0.1:30006
      # Where this service and api, client-proxy and dashboard-api export their
      # metrics, traces and logs over OTLP/gRPC. Empty unless .env sets
      # E2B_OTEL_COLLECTOR_GRPC_ENDPOINT, and empty exports nothing.
      OTEL_COLLECTOR_GRPC_ENDPOINT: ${E2B_OTEL_COLLECTOR_GRPC_ENDPOINT:-}
      TEMPLATE_STORAGE_URL: file:///var/lib/e2b/storage/templates
      # A pause the disk cannot hold is refused before it starts, keeping this
      # many MiB free, instead of failing midway: snapshots and their uploads
      # both live on this host's filesystem. This is the orchestrator's
      # pause-admission-disk-headroom-mib flag, which has no LaunchDarkly to
      # come from here; orchestrator releases that predate the knob ignore it.
      PAUSE_ADMISSION_DISK_HEADROOM_MIB: "1024"
      # A create that arrives while a killed sandbox is still returning its
      # hugepages is refused for the api to retry on this host, instead of
      # failing; with the pool sized for the sandboxes you run at once, that
      # can be any create sent right after a kill. This is the orchestrator's
      # create-memory-refusal flag, which has no LaunchDarkly to come from
      # here; orchestrator releases that predate the knob ignore it.
      CREATE_MEMORY_REFUSAL: "true"
      BUILD_CACHE_STORAGE_URL: file:///var/lib/e2b/storage/build-cache
      ARTIFACTS_REGISTRY_PROVIDER: Local
      LOCAL_UPLOAD_BASE_URL: http://127.0.0.1:5008
      FIRECRACKER_VERSIONS_DIR: /fc-versions
      HOST_KERNELS_DIR: /fc-kernels
      HOST_BUSYBOX_DIR: /fc-busybox
      BUSYBOX_VERSION: ${E2B_BUSYBOX_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      HOST_ENVD_PATH: /fc-envd/envd
      ORCHESTRATOR_BASE_PATH: /orchestrator
      SANDBOX_DIR: /fc-vm
      # Warm-device buffer, kept below NBDS_MAX: at parity the refill loop spins and warns.
      NBD_POOL_SIZE: "32"
      NETWORK_VERSION: "1"
      DEFAULT_KERNEL_VERSION: ${E2B_KERNEL_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      DEFAULT_FIRECRACKER_VERSION: ${E2B_FIRECRACKER_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
    depends_on:
      fetch-artifacts:
        condition: service_completed_successfully
      clickhouse-migrator:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
      vector:
        condition: service_healthy
    # orchestrator-launch.sh is PID 1: it sweeps sandbox cgroups on SIGTERM,
    # because Compose pre_stop hooks do not fire and the orchestrator itself
    # only drains. It sweeps again when the binary dies on its own, so the
    # restart policy above cannot bring up a fresh orchestrator beside a
    # crashed one's Firecrackers. See scripts/orchestrator-launch.sh.
    command: ["/bin/bash", "/opt/e2b/scripts/orchestrator-launch.sh"]
    stop_grace_period: 60s
    healthcheck:
      test: ["CMD-SHELL", "curl -sf --max-time 2 http://127.0.0.1:5008/health"]
      interval: 10s
      timeout: 3s
      retries: 60
      start_period: 30s

  api:
    logging: *logging
    image: ${E2B_API_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: unless-stopped
    network_mode: host
    environment:
      # Empty means the operator set neither: api-secrets generated a value
      # per install for each and the entrypoint below fills it in. A value in
      # .env or the shell arrives here and wins, one variable at a time. The
      # team API key is per install the same way (seed service above).
      ADMIN_TOKEN: ${ADMIN_TOKEN:-}
      SANDBOX_ACCESS_TOKEN_HASH_SEED: ${SANDBOX_ACCESS_TOKEN_HASH_SEED:-}
      NODE_ID: api
      ENVIRONMENT: local
      SERVICE_DISCOVERY_PROVIDER: local
      LOCAL_ORCHESTRATOR_ADDRESS: 127.0.0.1:5008
      POSTGRES_CONNECTION_STRING: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable
      REDIS_URL: 127.0.0.1:6379
      CLICKHOUSE_CONNECTION_STRING: clickhouse://clickhouse:clickhouse@127.0.0.1:9000/default
      # Sandbox and build logs are read from ClickHouse.
      # LOGS_READ_CONFIG fixes the api's logs-read-config flag, which has no
      # LaunchDarkly to come from here, and the api runs without LOKI_URL.
      # Both arrived with api v0.11.0; v0.8.0 exits on the missing
      # LOKI_URL, and tests/pins.bats refuses that pin.
      LOGS_READ_CONFIG: "true"
      # A pause the node refuses — it is still persisting the sandbox's parent
      # — keeps the sandbox running and answers 503 to retry, instead of
      # killing it. This is the api's
      # pause-refusal-restore flag, which has no LaunchDarkly to come from
      # here; api releases that predate the knob ignore it, so this takes
      # effect with the first api pin that reads it.
      PAUSE_REFUSAL_RESTORE: "true"
      LOGS_COLLECTOR_ADDRESS: http://127.0.0.1:30006
      OTEL_COLLECTOR_GRPC_ENDPOINT: ${E2B_OTEL_COLLECTOR_GRPC_ENDPOINT:-}
      API_INTERNAL_GRPC_PORT: "5009"
      API_EDGE_GRPC_PORT: "5109"
      PPROF_PORT: "6060"
      VOLUME_TOKEN_ENABLED: "false"
      AUTH_PROVIDER_CONFIG: '{"jwt":[]}'
      DEFAULT_KERNEL_VERSION: ${E2B_KERNEL_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
      DEFAULT_FIRECRACKER_VERSION: ${E2B_FIRECRACKER_VERSION:?fetch the .env that ships beside compose.yaml (README, Install)}
    env_file:
      # optional additional api variables (git-ignored); keys already set above
      # cannot be overridden here — change them in compose.yaml, or for
      # ADMIN_TOKEN / SANDBOX_ACCESS_TOKEN_HASH_SEED in .env or the shell
      # (both are generated per install when unset).
      #
      # Do not simplify this to the short `- env/api.local.env` form. The long
      # `path:`/`required:` syntax is the file's own enforcement of the Compose
      # 2.24 floor: 2.23 and older reject `required:` at
      # parse time, before anything starts, which turns the documented floor
      # into an error message instead of a silent misbehaviour in `--wait`.
      # The short form would also make the file mandatory, so a two-file
      # install with no env/ directory would fail on every `up`.
      - path: env/api.local.env
        required: false
    volumes:
      - seed-state:/run/e2b:ro
    # The image's entrypoint is ./api; this wrapper runs it with whichever of
    # the two secrets the operator left unset filled in from the file
    # api-secrets wrote, which Compose cannot read into a variable itself.
    # `sh -c` takes the word after the script as $0, hence the "api"
    # placeholder at the end: without it the first word of `command:` below
    # would become $0 and never reach the binary. `exec` keeps the api as
    # PID 1, so signals and its exit code pass through. Debugging with a
    # shell now needs the entrypoint named explicitly, as in
    # `docker compose run --rm --entrypoint sh api`.
    entrypoint:
      - sh
      - -c
      - |
        test -s /run/e2b/api.env || {
          echo "api: api-secrets left no /run/e2b/api.env" >&2
          echo "FIX: run docker compose up api-secrets and read its output" >&2
          exit 1
        }
        . /run/e2b/api.env
        [ -n "$${GEN_ADMIN_TOKEN:-}" ] && [ -n "$${GEN_SANDBOX_ACCESS_TOKEN_HASH_SEED:-}" ] || {
          echo "api: /run/e2b/api.env is incomplete" >&2
          echo "FIX: docker compose run --rm --no-deps --entrypoint rm api-secrets /run/e2b/api.env, then docker compose up -d" >&2
          exit 1
        }
        export ADMIN_TOKEN="$${ADMIN_TOKEN:-$$GEN_ADMIN_TOKEN}"
        export SANDBOX_ACCESS_TOKEN_HASH_SEED="$${SANDBOX_ACCESS_TOKEN_HASH_SEED:-$$GEN_SANDBOX_ACCESS_TOKEN_HASH_SEED}"
        exec ./api "$$@"
      - api
    command: ["--port", "3000"]
    depends_on:
      api-secrets:
        condition: service_completed_successfully
      seed:
        condition: service_completed_successfully
      orchestrator:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health >/dev/null"]
      interval: 10s
      timeout: 3s
      retries: 30
      start_period: 20s

  # The dashboard's backend. It reads the same databases as the api and the
  # same admin token: the wrapper below is the api's, reading GEN_ADMIN_TOKEN
  # from the file api-secrets writes, so both processes hold the one token
  # this install generated. It starts without an identity provider; the
  # /v1/management routes then answer 401, and nothing here calls them.
  dashboard-api:
    logging: *logging
    image: ${E2B_DASHBOARD_API_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: unless-stopped
    network_mode: host
    environment:
      ADMIN_TOKEN: ${ADMIN_TOKEN:-}
      PORT: "3010"
      NODE_ID: dashboard-api
      ENVIRONMENT: local
      POSTGRES_CONNECTION_STRING: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable
      REDIS_URL: 127.0.0.1:6379
      CLICKHOUSE_CONNECTION_STRING: clickhouse://clickhouse:clickhouse@127.0.0.1:9000/default
      OTEL_COLLECTOR_GRPC_ENDPOINT: ${E2B_OTEL_COLLECTOR_GRPC_ENDPOINT:-}
      AUTH_PROVIDER_CONFIG: '{"jwt":[]}'
    volumes:
      - seed-state:/run/e2b:ro
    entrypoint:
      - sh
      - -c
      - |
        test -s /run/e2b/api.env || {
          echo "dashboard-api: api-secrets left no /run/e2b/api.env" >&2
          echo "FIX: run docker compose up api-secrets and read its output" >&2
          exit 1
        }
        . /run/e2b/api.env
        [ -n "$${GEN_ADMIN_TOKEN:-}" ] || {
          echo "dashboard-api: /run/e2b/api.env is incomplete" >&2
          echo "FIX: docker compose run --rm --no-deps --entrypoint rm api-secrets /run/e2b/api.env, then docker compose up -d" >&2
          exit 1
        }
        export ADMIN_TOKEN="$${ADMIN_TOKEN:-$$GEN_ADMIN_TOKEN}"
        exec ./dashboard-api "$$@"
      - dashboard-api
    depends_on:
      api-secrets:
        condition: service_completed_successfully
      db-migrator:
        condition: service_completed_successfully
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      clickhouse:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3010/health >/dev/null"]
      interval: 10s
      timeout: 3s
      retries: 30
      start_period: 20s

  # The web UI. A browser signs in with this install's team API key, which
  # lands in an httpOnly cookie; DASHBOARD_COOKIE_SECURE=false keeps that
  # cookie over plain http. The server reaches the two APIs on loopback; the
  # browser reaches sandboxes at PUBLIC_SANDBOX_URL, so that address has to be
  # one the browser can open, which is what the E2B_DASHBOARD_HOST knob sets.
  # PUBLIC_E2B_DOMAIN is required by the image and only feeds SDK domain
  # routing, which PUBLIC_SANDBOX_URL overrides here. The image refuses to
  # start on E2B_SANDBOX_URL or any NEXT_PUBLIC_* variable, so none is set.
  # /api/health is 503 until dashboard-api answers, which makes it the right
  # readiness for `up --wait`; the image has node but no wget or curl.
  dashboard:
    logging: *logging
    image: ${E2B_DASHBOARD_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: unless-stopped
    network_mode: host
    environment:
      PORT: "3001"
      HOSTNAME: 0.0.0.0
      PUBLIC_E2B_DOMAIN: localhost
      E2B_INFRA_API_URL: http://127.0.0.1:3000
      E2B_DASHBOARD_API_URL: http://127.0.0.1:3010
      PUBLIC_SANDBOX_URL: http://${E2B_DASHBOARD_HOST:-localhost}:3002
      DASHBOARD_COOKIE_SECURE: "false"
    depends_on:
      api:
        condition: service_healthy
      dashboard-api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s

  client-proxy:
    logging: *logging
    image: ${E2B_CLIENT_PROXY_IMAGE:?fetch the .env that ships beside compose.yaml (README, Install)}
    restart: unless-stopped
    network_mode: host
    environment:
      NODE_ID: client-proxy
      ENVIRONMENT: local
      REDIS_URL: 127.0.0.1:6379
      API_INTERNAL_GRPC_ADDRESS: 127.0.0.1:5009
      PROXY_PORT: "3002"
      HEALTH_PORT: "3003"
      OTEL_COLLECTOR_GRPC_ENDPOINT: ${E2B_OTEL_COLLECTOR_GRPC_ENDPOINT:-}
    depends_on:
      api:
        condition: service_healthy
    stop_grace_period: 90s
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/ >/dev/null"]
      interval: 10s
      timeout: 3s
      retries: 12
      start_period: 5s

  # Starts once the base template exists and client-proxy is healthy, prints the
  # SDK variables and idles. It is the always-on dependent that makes
  # `docker compose up -d --wait` wait for the template build (Compose waits
  # for a one-shot as "completed" only when an active service depends on it),
  # and the one service whose presence means "the stack is usable".
  ready:
    <<: *tools
    logging: *logging
    restart: unless-stopped
    network_mode: host
    depends_on:
      base-template:
        condition: service_completed_successfully
      client-proxy:
        condition: service_healthy
      dashboard:
        condition: service_healthy
    volumes:
      - seed-state:/run/e2b
    # /run/e2b/sdk.env holds the four exports for this install, so a shell
    # can take them with: eval "$(docker compose exec ready cat /run/e2b/sdk.env)"
    # It is re-rendered whenever the seed rotates the key (TEAM_API_KEY changed
    # or the key file removed, then `up`): `up` re-runs the seed but does not
    # recreate this service, so a one-shot render would go stale.
    command:
      - /bin/sh
      - -c
      - |
        E2B_HOST=localhost
        LOG_HINT='docker compose logs seed'
        EPILOGUE='Same, in one line:  eval "$$(docker compose exec ready cat /run/e2b/sdk.env)"
        Smoke test:  docker compose --profile test run --rm smoke
        Tear down, in this order:
          1. docker compose down       (keeps data and /var/lib/e2b)
          2. docker compose down -v    (also drops the databases)
          3. then and only then, to undo the host changes:
             docker compose --profile purge run --rm host-teardown
             (refuses to run while the orchestrator is up, so it needs step 2 first)'
        test -s /run/e2b/team-api-key || {
          echo "ready: the seed left no /run/e2b/team-api-key" >&2
          echo "FIX: E2B_SEED_IMAGE must be a release whose seed knows SEED_TEAM_API_KEY_FILE (the published seed images do); run $$LOG_HINT" >&2
          exit 1
        }
        last=""
        while :; do
          key="$$(cat /run/e2b/team-api-key 2>/dev/null)"
          if [ -n "$$key" ] && [ "$$key" != "$$last" ]; then
            printf 'export E2B_API_KEY=%s\nexport E2B_API_URL=http://%s:3000\nexport E2B_SANDBOX_URL=http://%s:3002\nexport E2B_DASHBOARD_URL=http://%s:3001\n' "$$key" "$$E2B_HOST" "$$E2B_HOST" "$$E2B_HOST" > /run/e2b/sdk.env
            if [ -z "$$last" ]; then
              echo "E2B stack is up. Point the SDK at it (this install's own key). Open E2B_DASHBOARD_URL in a browser and paste E2B_API_KEY into its key form."
            else
              echo "Team API key rotated; the earlier key stops working within five minutes. The SDK variables are now:"
            fi
            sed 's/^/  /' /run/e2b/sdk.env
            if [ -z "$$last" ]; then
              printf '%s\n' "$$EPILOGUE"
            fi
            last="$$key"
          fi
          sleep 5
        done

  base-template:
    <<: *node-e2b
    logging: *logging
    restart: "no"
    network_mode: host
    environment:
      E2B_API_URL: http://127.0.0.1:3000
      FORCE_REBUILD: ${FORCE_REBUILD:-0}
    volumes:
      - seed-state:/run/e2b
    depends_on:
      api:
        condition: service_healthy
      # Direct, not only through api: on a rotation `up` api is already healthy,
      # and without this edge base-template would race the re-run seed for the
      # key file and fail with the message meant for an old seed image.
      seed:
        condition: service_completed_successfully
    command:
      - /bin/sh
      - -c
      - |
        test -s /run/e2b/team-api-key || {
          echo "build-base-template: the seed left no /run/e2b/team-api-key" >&2
          echo "FIX: E2B_SEED_IMAGE must be a release whose seed knows SEED_TEAM_API_KEY_FILE (the published seed images do); run docker compose logs seed" >&2
          exit 2
        }
        E2B_API_KEY="$$(cat /run/e2b/team-api-key)" exec node /app/build-base-template.mjs

  smoke:
    <<: *node-e2b
    logging: *logging
    profiles: [test]
    restart: "no"
    network_mode: host
    environment:
      E2B_API_URL: http://127.0.0.1:3000
      E2B_SANDBOX_URL: http://127.0.0.1:3002
    volumes:
      - seed-state:/run/e2b
    depends_on:
      base-template:
        condition: service_completed_successfully
      client-proxy:
        condition: service_healthy
    command:
      - /bin/sh
      - -c
      - |
        test -s /run/e2b/team-api-key || {
          echo "smoke: the seed left no /run/e2b/team-api-key" >&2
          echo "FIX: E2B_SEED_IMAGE must be a release whose seed knows SEED_TEAM_API_KEY_FILE (the published seed images do); run docker compose logs seed" >&2
          exit 2
        }
        E2B_API_KEY="$$(cat /run/e2b/team-api-key)" exec node /app/smoke.mjs

  host-teardown:
    <<: *tools
    logging: *logging
    profiles: [purge]
    restart: "no"
    network_mode: host
    pid: host
    cgroup: host
    privileged: true
    command: ["/bin/sh", "-c", "exec nsenter -t 1 -m -u -i -n -p -C -- /bin/bash -s < /opt/e2b/scripts/host-teardown.sh"]

volumes:
  postgres:
  # Only the five-minute team cache lives here, so nothing is lost either way.
  # The name is what makes `down -v` collect it: the image declares
  # VOLUME /data, so without one every container takes an anonymous volume,
  # and `down` before `down -v` strands one of those per install-remove cycle.
  redis:
  clickhouse:
  # The seed's team API key, the api secrets api-secrets writes beside it and
  # ready's sdk.env derived from the key. `down -v` drops it together with the
  # databases, so the secrets and the database stay in step.
  seed-state:

configs:
  clickhouse-config:
    content: |-
      <?xml version="1.0"?>
      <clickhouse>
          <!-- this is undocumented but needed to enable waiting for for shutdown for a custom amount of time  -->
          <!-- see https://github.com/ClickHouse/ClickHouse/pull/77515 for more details  -->
          <shutdown_wait_unfinished>60</shutdown_wait_unfinished>
          <shutdown_wait_unfinished_queries>1</shutdown_wait_unfinished_queries>

          <!-- Use up 80% of available RAM to be on the safer side, default is 90% -->
          <max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>

          <default_replica_path>/var/lib/clickhouse/tables/{shard}/{database}/{table}</default_replica_path>

          <remote_servers replace="true">
              <cluster>
                  <shard>
                      <replica>
                          <host>localhost</host>
                          <port>9000</port>
                          <user>clickhouse</user>
                          <password>clickhouse</password>
                      </replica>
                  </shard>
              </cluster>
          </remote_servers>

          <listen_host>0.0.0.0</listen_host>

          <tcp_port>9000</tcp_port>
      </clickhouse>
  vector-config:
    content: |
      data_dir = "alloc/data/vector/"

      [api]
      enabled = true
      address = "0.0.0.0:44313"

      [sources.http_server]
      type = "http_server"
      address = "0.0.0.0:30006"
      encoding = "ndjson"
      path_key = "_path"

      [transforms.add_source_http_server]
      type = "remap"
      inputs = ["http_server"]
      source = """
      del(."_path")
      if (!exists(.sandboxID) || .sandboxID == null) && exists(.instanceID) && .instanceID != null {
        .sandboxID = .instanceID
      }
      .timestamp = parse_timestamp(.timestamp, format: "%+") ?? now()
      # Normalize keys
      if exists(.sandbox_id) {
        .sandboxID = .sandbox_id
      }
      if exists(.build_id) {
        .buildID = .build_id
      }
      if exists(.env_id) {
        .envID = .env_id
      }
      if exists(.team_id) {
        .teamID = .team_id
      }
      if exists(."template.id") {
        .templateID = ."template.id"
        del(."template.id")
      }
      if exists(."sandbox.id") {
        .sandboxID = ."sandbox.id"
        del(."sandbox.id")
      }
      if exists(."build.id") {
        .buildID = ."build.id"
        del(."build.id")
      }
      if exists(."env.id") {
        .envID = ."env.id"
        del(."env.id")
      }
      if exists(."team.id") {
        .teamID = ."team.id"
        del(."team.id")
      }

      # Apply defaults if not already set
      if !exists(.envID) {
        .envID = "unknown"
      }
      if !exists(.category) {
        .category = "default"
      }
      if !exists(.teamID) {
        .teamID = "unknown"
      }
      if !exists(.sandboxID) {
        .sandboxID = "unknown"
      }
      if !exists(.buildID) {
        .buildID = "unknown"
      }
      if !exists(.service) {
        if exists(.service_name) && .service_name != null {
          .service = .service_name
        } else {
          .service = "envd"
        }
      }
      """

      [transforms.internal_routing]
      type = "route"
      inputs = [ "add_source_http_server" ]

      [transforms.internal_routing.route]
      internal = '.internal == true'

      [transforms.remove_internal]
      type = "remap"
      inputs = [ "internal_routing._unmatched" ]
      source = '''
      del(.internal)
      '''

      # Enable debugging of logs to the console
      # [sinks.console_logs]
      # type = "console"
      # inputs = ["remove_internal"]
      # encoding.codec = "json"

      # One row of the ClickHouse `sandbox_logs` table per line, the same eleven
      # columns and the same rules as the collector that writes it in E2B's own
      # deployments. The api reads this table for sandbox and template-build logs
      # (LOGS_READ_CONFIG=true); there is no Loki in this stack.
      # Rows are dropped when the team id is not a UUID, or when a line has no
      # sandbox id unless it is a template-manager build line, which the api looks
      # up by template and build id and stores under the "unknown" sandbox.
      [transforms.sandbox_logs_rows]
      type = "remap"
      inputs = [ "remove_internal" ]
      drop_on_error = true
      drop_on_abort = true
      source = '''
      raw = encode_json(.)

      # A UUID, or the row is dropped. The regex has no end anchor on purpose: compose
      # interpolation would need that character doubled in the inline copy of this
      # file, so the length check closes the match instead.
      team_id = string(.teamID) ?? ""
      if length(team_id) != 36 || !match(team_id, r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}') {
        abort
      }

      service = string(.service) ?? ""
      if service == "" { service = string(.service_name) ?? "" }
      if service == "" { service = "envd" }
      category = string(.category) ?? ""
      if category == "" { category = "default" }

      template_id = string(.template_id) ?? ""
      if template_id == "" { template_id = string(.templateID) ?? "" }
      if template_id == "" { template_id = string(.envID) ?? "" }
      if template_id == "unknown" { template_id = "" }
      build_id = string(.buildID) ?? ""
      if build_id == "unknown" { build_id = "" }

      sandbox_id = string(.sandbox_id) ?? ""
      if sandbox_id == "" { sandbox_id = string(.sandboxID) ?? "" }
      if sandbox_id == "" { sandbox_id = string(.instanceID) ?? "" }
      if sandbox_id == "" || sandbox_id == "unknown" {
        if service != "template-manager" || build_id == "" { abort }
        sandbox_id = "unknown"
      }

      message = string(.message) ?? ""
      if message == "" { message = raw }
      level = downcase(string(.level) ?? "")
      # sandbox_logs.timestamp is DateTime64(9), 1900-01-01 to 2262-04-11. A producer
      # clock outside that range would make ClickHouse reject the whole batch, which
      # the sink retries forever and every later line waits behind; such a line keeps
      # its content and takes the collector's time.
      ts = timestamp(.timestamp) ?? now()
      secs = to_unix_timestamp(ts)
      if secs > 9223372036 || secs < -2208988800 {
        ts = now()
      }

      promoted = ["timestamp", "message", "level", "team_id", "teamID", "sandbox_id", "sandboxID", "instanceID",
                  "template_id", "templateID", "envID", "env_id", "build_id", "buildID", "service", "service_name", "category"]
      fields = {}
      for_each(object(.)) -> |key, value| {
        if !includes(promoted, key) {
          fields = set!(fields, [key], to_string(value) ?? encode_json(value))
        }
      }

      . = {
        "timestamp": ts,
        "team_id": team_id,
        "sandbox_id": sandbox_id,
        "template_id": template_id,
        "build_id": build_id,
        "service": service,
        "category": category,
        "level": level,
        "message": message,
        "raw": raw,
        "fields": encode_json(fields),
      }
      '''

      [sinks.clickhouse_sandbox_logs]
      type = "clickhouse"
      inputs = [ "sandbox_logs_rows" ]
      endpoint = "http://clickhouse:8123"
      database = "default"
      table = "sandbox_logs"
      auth.strategy = "basic"
      auth.user = "clickhouse"
      auth.password = "clickhouse"
      # The row carries an RFC 3339 timestamp; ClickHouse parses it into
      # DateTime64(9) with best-effort date/time input. Unknown keys are refused by
      # design (the remap emits exactly the table's columns), so skipping them would
      # only hide a bug.
      date_time_best_effort = true
      skip_unknown_fields = false
      compression = "gzip"
      batch.timeout_secs = 1
  otel-collector-config:
    content: |
      # The built-in OpenTelemetry collector. The services export to it once their
      # collector endpoint is 127.0.0.1:4317. It keeps the e2b.* metrics and writes
      # them into the stack's own ClickHouse, into the metrics_gauge and metrics_sum
      # tables the clickhouse-migrator creates; the api's metrics endpoints read
      # what those two feed. Traces, logs and the other metrics are dropped; the
      # commented otlp/upstream exporter below forwards traces and logs instead.
      # Every listener binds loopback: the collector shares the machine's network
      # with the services, and nothing off the machine has a reason to reach it.
      receivers:
        otlp:
          protocols:
            grpc:
              endpoint: 127.0.0.1:4317
              max_recv_msg_size_mib: 100

      processors:
        # Batches traces and logs on their way to nop, or to otlp/upstream once
        # that forwards them.
        batch:
          timeout: 5s

        batch/clickhouse:
          timeout: 5s
          send_batch_size: 50000

        filter/external_metrics:
          metrics:
            include:
              match_type: regexp
              metric_names:
                - "e2b.*"

      extensions:
        # `curl -s 127.0.0.1:13133` on the machine answers while the collector runs.
        # The image has no shell, so there is nothing to check from inside it.
        health_check:
          endpoint: 127.0.0.1:13133

      exporters:
        clickhouse:
          endpoint: tcp://127.0.0.1:9000
          database: default
          username: clickhouse
          password: clickhouse
          async_insert: true
          create_schema: false
          metrics_tables:
            gauge:
              name: metrics_gauge
            sum:
              name: metrics_sum

        # Accepts traces and logs and discards them. Without a pipeline for them the
        # receiver would answer Unimplemented, and every service would log each
        # batch it refused.
        nop:

        # To forward traces and logs to a collector of your own, uncomment this
        # exporter, put that collector's OTLP/gRPC address in its endpoint, and
        # name it in place of nop in the traces and logs pipelines at the end.
        # otlp/upstream:
        #   endpoint: collector.example.com:4317
        #   tls:
        #     insecure: true

      service:
        telemetry:
          logs:
            level: warn
          # No internal metrics: by default the collector serves its own on
          # 127.0.0.1:8888, one more listener on the machine, and refuses to start
          # when something else holds that port.
          metrics:
            level: none
        extensions: [health_check]
        pipelines:
          metrics/external:
            receivers: [otlp]
            processors: [filter/external_metrics, batch/clickhouse]
            exporters: [clickhouse]
          traces:
            receivers: [otlp]
            processors: [batch]
            exporters: [nop]
          logs:
            receivers: [otlp]
            processors: [batch]
            exporters: [nop]

This file may be out of date. Check the E2B documentation for current setup steps.

Track your self-hosted stack

Bookmark software to try, rate tools you've used, and keep your collection in one place.

Metadata extracted from README on Oct 28, 2026

Alternatives to E2B

runtm logo

runtm

302
Runtm is an open-source platform for spinning up isolated sandboxes where coding agents can build, test, and deploy software.
Self Hosting SolutionsAI AgentsAI Tooling+1
4 Containers
Details
OpenSandbox logo

OpenSandbox

16.2k
OpenSandbox is a platform for running AI agents in isolated sandboxes on local machines or Kubernetes clusters.
Automation ToolsAI AgentsAI Tooling+2
2 Containers
Details
daytona logo

daytona

71.5k
Daytona is a secure infrastructure runtime for running AI-generated code and agent workflows in isolated sandboxes.
Artificial IntelligenceAI AgentsAI Tooling+2
Details
agentos logo

agentos

4.8k
agentOS is a library for running AI agents inside an application process with VM-like isolation, persistence, permissions, and orchestration.
AI AgentsArtificial IntelligenceAI Tooling
Single Container
Details
Coder logo

Coder

17.2k
Coder is a self-hosted platform for cloud development environments and AI coding agents.
IDE ToolsPlatform as a ServiceRemote Access+1
Open Core
Details

Related Software

agent-vault logo

agent-vault

2.4k
An open-source HTTP credential proxy and vault that brokers API credentials for AI agents without exposing the secrets directly to the agents.
Secret ManagementProxy ServersSelf Hosting Solutions+2
2 Containers
Details
open-terminal logo

open-terminal

3.4k
Open Terminal is a self-hosted terminal service for AI agents and automation tools that provides command execution, file management, and code running through a REST API.
Self Hosting SolutionsAutomation ToolsAI Agents+2
Details
omnara logo

omnara

3.3k
Omnara is an open-source managed agent platform for running AI agents on any model and any machine, with built-in tools, secrets, permissions, and streaming.
AI AgentsAI ToolingPlatform as a Service+3
10 ContainersFully FOSS
Details
Codeman logo

Codeman

820
Codeman is a self-hosted dashboard for managing AI coding agents in persistent terminal sessions, with live web access, mobile-friendly controls, and background automation.
AI Coding AssistantAI AgentsAutomation Tools+2
Single Container
Details
openrig logo

openrig

8.3k
OpenRig is a CLI tool for defining, launching, and managing a team of AI coding agents in a persistent shared rig.
AI AgentsAI Coding AssistantAI Harness+1
Details
onecli logo

onecli

3.6k
OneCLI is an open-source gateway that sits between AI agents and external services to inject and manage API credentials securely.
AI AgentsSecret ManagementModel Context Protocol+1
2 Containers
Details
NanoClaw logo

NanoClaw

31.0k
An AI assistant that runs agents in isolated containers and can be customized through Claude Code skills.
Claws & ClankersAI AgentsAI Coding Assistant+2
Details
ouijit logo

ouijit

177
Ouijit is a customizable task and terminal session manager for agentic development workflows, with lifecycle hooks, scripts, worktree management, and VM sandboxing.
Task RunnersAI Coding AssistantAI Agents
Details
ZeroClaw logo

ZeroClaw

24
A small Rust-based AI assistant infrastructure and autonomous runtime with pluggable providers, channels, memory, tools, and security policies.
Claws & ClankersAI AgentsAI Tooling+2
Details
Pi logo

Pi

118.3k
Pi Agent Harness is a mono-repo for a self-extensible coding agent, including an interactive coding agent CLI, agent runtime, and a unified multi-provider LLM API.
AI HarnessAI AgentsAI Coding Assistant+1
Details
centaur logo

centaur

1.4k
Centaur is a self-hosted platform for teams to run shared AI agents in Slack or through an API, with isolated sandboxes and durable workflows.
Artificial IntelligenceAI AgentsAutomation Tools+2
Details
wagmios logo

wagmios

57
WAGMIOS is a self-hosted Docker management platform that lets scoped API keys and AI agents install apps, manage containers, and control a homelab through a REST API.
Self Hosting SolutionsAutomation ToolsAI Agents
2 Containers
Details