Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.git
.github
.vscode
.conductor
node_modules
.next
out
build
coverage
test-results
readme-assets
.env
.env.*
*.md
Dockerfile
.dockerignore
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,27 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
# NEXT_PUBLIC_INFRA_API_URL=http://localhost:3000
# NEXT_PUBLIC_DASHBOARD_API_URL=http://localhost:3001

### Runtime API base URLs. Unlike the NEXT_PUBLIC_ variables above, these are
### read when the server starts rather than baked into the build, so one
### prebuilt image can serve any install. They take precedence over the
### NEXT_PUBLIC_ overrides.
# E2B_INFRA_API_URL=http://127.0.0.1:3000
# E2B_DASHBOARD_API_URL=http://127.0.0.1:3010

### Optional sandbox traffic base URL for local development proxies.
# NEXT_PUBLIC_E2B_SANDBOX_URL=http://sandbox.lvh.me:3002

### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem
### inspector). Unset on a runtime-configured install means "the host this
### page was served from, on port 3002". The value is handed to the browser,
### so it has to be reachable from the browser and not only from the server —
### the loopback below works only when the two are the same machine.
# E2B_SANDBOX_URL=http://127.0.0.1:3002

### Set to "false" when the dashboard is served over plain http (a LAN address
### or an IP), or the browser drops the api key cookie and the key form loops.
# DASHBOARD_COOKIE_SECURE=false

### OpenTelemetry (disabled unless the endpoint is set).
# OTEL_SERVICE_NAME=e2b-dashboard
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Nothing else in CI builds the image, so a break in the Docker build would
# otherwise go unnoticed until someone builds it by hand. This job runs on the
# files that can break it, not on every PR — a full Next build in Docker is
# minutes, and application changes are already covered by Test / Code Quality.
name: Container

on:
push:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- next.config.ts
- tsconfig.json
- package.json
- bun.lock
- scripts/check-app-env.ts
- scripts/container-smoke.sh
- src/lib/env.ts
- .github/workflows/container.yml
pull_request:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- next.config.ts
- tsconfig.json
- package.json
- bun.lock
- scripts/check-app-env.ts
- scripts/container-smoke.sh
- src/lib/env.ts
- .github/workflows/container.yml
workflow_dispatch:

env:
FORCE_COLOR: "1"
CLICOLOR_FORCE: "1"

permissions:
contents: read

jobs:
smoke:
name: Build and Smoke-Test the Image
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Build the image and check the responses it serves
run: ./scripts/container-smoke.sh
63 changes: 63 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Three stages: Bun resolves the dependencies (bun.lock is the lockfile), Node
# runs the Next build, Node serves. The runtime stage carries only Next's
# standalone output, so the full dependency tree never ships in the image.
#
# The build runs under Node, not Bun: `bun run build` forks Next's page-data
# workers, and Bun's CommonJS interop throws "Expected CommonJS module to have
# a function wrapper" on the webpack output those workers load.
#
# The build fetches three Google Fonts families through next/font/google
# (src/app/fonts.ts): it needs outbound HTTPS to fonts.googleapis.com and
# fonts.gstatic.com, and fails there in an air-gapped environment.
FROM oven/bun:1.2.20 AS deps

WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

FROM node:22-bookworm-slim AS builder

WORKDIR /app

# Only to run the prebuild env check, which is a TypeScript entrypoint.
COPY --from=deps /usr/local/bin/bun /usr/local/bin/bun
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next inlines every NEXT_PUBLIC_* value into the bundles, so the domain is a
# build input, and the prebuild env check (scripts/check-app-env.ts) exits 1
# without it. The default resolves nowhere on purpose: a container started
# with no configuration must fail loudly instead of reaching a deployment that
# is not yours. Point a container at an install with the runtime variables.
ARG NEXT_PUBLIC_E2B_DOMAIN=unset.invalid
ENV NEXT_PUBLIC_E2B_DOMAIN=${NEXT_PUBLIC_E2B_DOMAIN}
ENV NEXT_TELEMETRY_DISABLED=1

RUN bun scripts/check-app-env.ts
RUN node node_modules/next/dist/bin/next build --webpack

FROM node:22-bookworm-slim AS runtime

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# server.js reads PORT (default 3000) and HOSTNAME (default 0.0.0.0). The
# default is 3001 so the dashboard does not land on 3000, which an E2B install
# already uses for its API when both share a host network.
ENV PORT=3001
ENV HOSTNAME=0.0.0.0

# Reported as service.version on OTEL traces (src/instrumentation.node.ts).
ARG BUILD=dev
ENV BUILD=${BUILD}

COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public

USER node
EXPOSE 3001

CMD ["node", "server.js"]
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,51 @@ Authentication is a single **team API key**:
- Visiting `/` shows a form to enter the key. It is validated against infra-api and stored in an httpOnly `e2b_api_key` cookie. All upstream calls happen server-side with the `X-API-Key` header — the key never reaches client JavaScript.
- Alternatively, set the `E2B_API_KEY` environment variable to pre-authenticate the whole deployment (single-user mode; the key form and sign-out are hidden).

### Configuration

| Variable | Read | Purpose |
|---|---|---|
| `NEXT_PUBLIC_E2B_DOMAIN` | build | Derives `https://api.<domain>` and `https://dashboard-api.<domain>` |
| `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Explicit overrides of the derived URLs |
| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence |
| `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Base URL the browser uses for sandbox traffic |
| `E2B_SANDBOX_URL` | per request | Same, for a prebuilt image; takes precedence, and is what the browser is told to use |
| `DASHBOARD_COOKIE_SECURE` | server start | `false` only for a plain-http install; the api key cookie then travels unencrypted. Defaults to secure in production builds |

Each URL resolves in that order: the runtime variable, then the
`NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines
`NEXT_PUBLIC_*` into the bundles at build time, so a prebuilt image is
configured with the runtime variables. Every explicit URL must carry an
`http://` or `https://` scheme, and the server rejects anything else naming
the variable. The infra and dashboard URLs are resolved at module scope, so a
malformed one fails on server start. The sandbox URL is resolved per request,
so a malformed one fails on first use, such as opening a terminal.

The browser reads the sandbox URL from `GET /api/config`, which resolves it
per request. When `E2B_INFRA_API_URL` is set and no sandbox URL is given, it
defaults to the host the dashboard was reached on, port 3002. That default
routes only when the dashboard is reached over `localhost` or an IP address,
which is how the sandbox proxy accepts header-routed traffic. Reach the
dashboard on a domain name and you must set `E2B_SANDBOX_URL` yourself, to a
`localhost`, IP, or `sandbox.<domain>` base URL. `curl
http://<host>:<port>/api/config` shows what a deployment resolved.

The dashboard's own server-side sandbox calls, such as killing a terminal's
pty when you leave the page, resolve the URL from the same request by the same
rule, so a self-hosted install needs no `E2B_SANDBOX_URL` unless the request
host is the wrong one for sandbox traffic.

`/api/config` is unauthenticated and carries no secret. Behind a reverse
proxy, that proxy must set `X-Forwarded-Host` and `X-Forwarded-Proto` itself
rather than pass through whatever a client sent; `GET /api/config` trusts
them to describe the browser-facing origin.

`E2B_SANDBOX_URL` is also read by the E2B SDK for its own connection config.
That is the same setting, so the dashboard deliberately shares the name. It
is served to the browser as-is, so the value has to be reachable from the
browser, not only from the server. A runtime-configured install should leave
it unset unless the port-3002 default is wrong.

## Features

- **Sandboxes**: paginated live list, per-sandbox monitoring (CPU/memory/disk), logs, filesystem inspector, and an in-browser terminal
Expand Down Expand Up @@ -75,6 +120,35 @@ bun run build
bun run start
```

### Run it in a container

The repository builds a self-contained image: Bun resolves the dependencies,
Node runs the Next build, and Node serves the standalone output; the runtime
stage carries no dev dependencies.

```bash
docker build --build-arg NEXT_PUBLIC_E2B_DOMAIN=your-domain.com -t e2b-dashboard .
docker run --rm -p 3001:3001 e2b-dashboard
```

- `PORT` (default `3001`) and `HOSTNAME` (default `0.0.0.0`) are read by the
server at start. The default keeps the dashboard clear of port 3000, which
an E2B API already uses when both share a host network.
- `NEXT_PUBLIC_E2B_DOMAIN` is a **build** argument, not a runtime variable:
Next inlines `NEXT_PUBLIC_*` values into the bundles. It defaults to a
domain that resolves nowhere, so an unconfigured container fails loudly
instead of talking to a deployment that is not yours.
- An image built this way resolves both APIs from `NEXT_PUBLIC_E2B_DOMAIN` at
build time. A container configured through the runtime variables in
[Configuration](#configuration) resolves them at runtime instead, so it
needs no build-time value beyond the default.
- The build needs outbound HTTPS for the three Google Fonts families in
`src/app/fonts.ts`; an air-gapped build fails there.
- `GET /api/health` reports dashboard-api's health and answers 503 while
dashboard-api is unreachable, so use `GET /` as the container liveness
check.
- `scripts/container-smoke.sh` builds the image and asserts those responses.

## Scripts

| Command | Description |
Expand Down
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const browserNodeModuleStubs = {
const config: NextConfig = {
reactStrictMode: true,
reactCompiler: true,
// Emits .next/standalone: a server plus only the traced dependencies, which
// is what the container image runs. `next start` still works from .next for
// local previews, and platform builds ignore this output.
output: 'standalone',
experimental: {
useCache: true,
turbopackFileSystemCacheForDev: true,
Expand Down
67 changes: 67 additions & 0 deletions scripts/container-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Builds the container image and checks the three responses a self-hosted
# install depends on. Needs Docker and outbound HTTPS: the Next build pulls
# the Google Fonts faces declared in src/app/fonts.ts.
set -euo pipefail

IMAGE="${IMAGE:-e2b-dashboard:smoke}"
PORT="${PORT:-3001}"
CONTAINER="e2b-dashboard-smoke-$$"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "==> building ${IMAGE}"
docker build -t "${IMAGE}" "${ROOT}"

echo "==> starting ${CONTAINER} on port ${PORT}"
docker run -d --name "${CONTAINER}" -e PORT="${PORT}" -p "${PORT}:${PORT}" "${IMAGE}" >/dev/null

ready=0
for _ in $(seq 1 60); do
if curl -fs -o /dev/null "http://127.0.0.1:${PORT}/"; then
ready=1
break
fi
sleep 1
done

if [ "${ready}" != 1 ]; then
echo "FAIL: nothing answered on port ${PORT} within 60s" >&2
docker logs "${CONTAINER}" >&2 || true
exit 1
fi

fail=0
check() {
if [ "$3" = "$2" ]; then
echo "ok $1: $3"
else
echo "FAIL $1: expected $2, got $3" >&2
fail=1
fi
}

check "GET / serves the api key form" 200 \
"$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/")"

check "GET /sandboxes redirects to the key form" 307 \
"$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/sandboxes")"

check "GET /sandboxes redirect target" "http://127.0.0.1:${PORT}/?returnTo=%2Fsandboxes" \
"$(curl -sS -o /dev/null -w '%{redirect_url}' "http://127.0.0.1:${PORT}/sandboxes")"

# /api/health probes dashboard-api, which this run does not provide, so 503 is
# the correct answer here and proves route handlers are being served.
check "GET /api/health without a dashboard-api" 503 \
"$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/health")"

if [ "${fail}" != 0 ]; then
docker logs "${CONTAINER}" >&2 || true
exit 1
fi

echo "==> container smoke test passed"
18 changes: 18 additions & 0 deletions src/app/api/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { resolveBrowserRuntimeConfig } from '@/core/server/runtime-config'

// Resolved from the environment and the request host on every call, so this
// must never be prerendered or cached.
export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
const config = resolveBrowserRuntimeConfig(request.headers, request.url)

// Unauthenticated and readable by anyone who can reach the dashboard, so
// this payload must never grow a secret.
return NextResponse.json(config, {
headers: {
'Cache-Control': 'no-store',
},
})
}
23 changes: 22 additions & 1 deletion src/configs/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,32 @@ export const COOKIE_KEYS = {

export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year

/**
* Browsers drop a `Secure` cookie on a plain-http origin, so a self-hosted
* install served over http on a LAN address turns the key form into a login
* loop. DASHBOARD_COOKIE_SECURE overrides the flag; unset keeps the build-mode
* default, which is what every existing deployment already gets.
*
* The value is read case-insensitively rather than trusting the schema's
* narrowed type: a prebuilt image starts without the env check, so whatever
* the container was handed arrives here unvalidated.
*/
function isSecureCookie(): boolean {
const configured: string | undefined =
process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase()

if (configured !== undefined && configured !== '') {
return configured !== 'false'
Comment on lines +30 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) isSecureCookie() only treats the exact string 'false' as disabling Secure; any other DASHBOARD_COOKIE_SECURE value (e.g. '0', 'no', 'off', a typo) silently falls through to secure=true, silently reproducing the plain-http login loop this env var exists to fix, with no error since the container skips the build-time env schema check. Fix: parse the value against a small explicit set of falsy tokens (or reuse the same enum the build-time schema uses) and fail loudly (throw, like assertHttpUrl does for the URL vars) on any unrecognized value instead of defaulting to secure=true.

Extended reasoning...

A self-hoster running the prebuilt container sets DASHBOARD_COOKIE_SECURE=0 (a very common boolean convention) on a plain-http LAN install. src/lib/env.ts's z.enum(['true','false']) would reject '0' in dev/build, but per the module's own comment 'a prebuilt image starts without the env check', so nothing validates it at runtime. isSecureCookie() lowercases/trims to '0', which is not '===' 'false', so it returns true. BASE_COOKIE_OPTIONS.secure becomes true, the browser drops the httpOnly e2b_api_key cookie on the http origin, and the key form redirect-loops exactly as before this feature was added, with no log or error pointing at the misconfigured variable.

Verification: nit. Factually real and reachable: at src/configs/cookies.ts:35 return configured !== 'false' treats only the exact (trimmed, lowercased) token 'false' as disabling Secure, so DASHBOARD_COOKIE_SECURE='0' (or 'no'/'off') falls through to secure=true. The runtime value is unvalidated in the prebuilt container — src/lib/env.ts:17 (z.enum(['true','false'])) runs only at build time, confirmed by…

}

return process.env.NODE_ENV === 'production'
}

const BASE_COOKIE_OPTIONS: Partial<ResponseCookie> = {
path: '/',
maxAge: COOKIE_MAX_AGE_SECONDS,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
secure: isSecureCookie(),
}

/**
Expand Down
Loading
Loading