Skip to content

feat(container): self-hostable container image with runtime configuration - #528

Open
svalleru wants to merge 7 commits into
mainfrom
feat/self-host-container
Open

feat(container): self-hostable container image with runtime configuration#528
svalleru wants to merge 7 commits into
mainfrom
feat/self-host-container

Conversation

@svalleru

@svalleru svalleru commented Sep 12, 2026

Copy link
Copy Markdown

Summary

  • What. The dashboard ships as one container image and is configured at runtime. A Dockerfile with output: 'standalone' runs node server.js as a non-root user on PORT=3001; the server reads E2B_INFRA_API_URL, E2B_DASHBOARD_API_URL, E2B_SANDBOX_URL and DASHBOARD_COOKIE_SECURE at runtime; a new GET /api/config hands the browser { infraApiUrl, sandboxUrl } per request, and the terminal and filesystem inspector read it instead of the build-inlined value.
  • Why. Self-hosters want the dashboard on the same machine as the rest of the stack, with no hosting account, from one prebuilt image. Next inlines NEXT_PUBLIC_* at build time, so a prebuilt image could not be pointed at an install, and a plain-http LAN address needs the api key cookie without Secure. See the E2B Embed package.
  • Existing deployments are unchanged. Each URL resolves as runtime variable, then NEXT_PUBLIC_ override, then the domain-derived value; with none set, /api/config returns sandboxUrl: null and the SDK derives the host as before. next start and platform builds are untouched.
  • The image build runs under Node, not Bun. bun run build fails inside the Linux image in Next's forked page-data workers, so the Dockerfile has three stages: Bun resolves the lockfile and runs the prebuild env check, Node builds, Node serves.
  • Two calls for you. src/core/shared/clients/api.ts now imports a server-only module (all importers are server-side; the build confirms nothing reaches a client chunk). E2B_SANDBOX_URL deliberately shares the name the E2B SDK reads for the same setting and is served to the browser verbatim, so it must be browser-reachable.

Verification

  • 305 unit and 7 integration tests pass (resolvers, the browser config fetch and its cache, forwarded-host handling, the cookie flag, GET /api/config); lint 0 errors; knip and next build clean.

  • The built server was probed both ways: with no E2B_* set it returns {"infraApiUrl":"https://api.e2b.dev","sandboxUrl":null}; with E2B_INFRA_API_URL set it returns the request host on port 3002, tracking Host and X-Forwarded-Host.

  • scripts/container-smoke.sh passes against the built image: GET / 200, GET /sandboxes 307 to the key form, GET /api/health 503 with no dashboard-api reachable. The runtime stage carries no dev dependencies and runs as node.

  • A path-filtered Container workflow runs the smoke script on PRs touching the files that can break the build.

  • End-to-end on a fresh VM against an E2B Embed stack: key-form login, sandbox list, a terminal command and a filesystem read from a headless browser, all through the sandbox proxy's header routing. It found that the server-side terminal-kill call resolved no sandbox URL when E2B_SANDBOX_URL is unset; the last commit makes the server resolve it the same way the browser does.

Notes

  • /api/config is unauthenticated and carries no secret. Behind a reverse proxy, the proxy must set X-Forwarded-Host/X-Forwarded-Proto itself. A malformed explicit URL fails at startup by design, naming the variable.
  • The build needs outbound HTTPS for the Google Fonts families. Please do not make Container a required check; it is path-filtered.

@cla-bot cla-bot Bot added the cla-signed label Sep 12, 2026
The sandbox router read the sandbox URL from the environment alone, so an
install that leaves E2B_SANDBOX_URL unset — the runtime-configured case, where
each browser is told the host it reached the dashboard on — sent every
server-side envd call to the build-time domain instead. Killing a terminal's
pty on leaving the page failed every time.

resolveServerSandboxUrl applies the browser's rule to the request the procedure
is serving, and the browser config is now expressed through it so the two
cannot drift.
@cla-bot

cla-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Independence Check.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@cla-bot cla-bot Bot removed the cla-signed label Sep 12, 2026
@svalleru
svalleru marked this pull request as ready for review September 12, 2026 01:58

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +111 to +121
*/
export function resolveSandboxUrl(): string | undefined {
const configured = firstSet(
['E2B_SANDBOX_URL', process.env.E2B_SANDBOX_URL],
['NEXT_PUBLIC_E2B_SANDBOX_URL', process.env.NEXT_PUBLIC_E2B_SANDBOX_URL]
)

return configured ? assertHttpUrl(configured) : undefined
}

/**

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) resolveSandboxUrl()/resolveServerSandboxUrl() validate E2B_SANDBOX_URL/NEXT_PUBLIC_E2B_SANDBOX_URL with assertHttpUrl, but unlike resolveInfraApiUrl/resolveDashboardApiUrl (called eagerly at module scope in api.ts), this resolver is only invoked lazily inside sandbox.ts's openTerminal/killTerminalPty/resume/pause and /api/config. A malformed value passes container startup and container-smoke.sh's checks, then throws on every terminal action a user actually takes, contradicting the PR's own "fails at startup by design" claim. Fix: validate E2B_SANDBOX_URL/NEXT_PUBLIC_E2B_SANDBOX_URL eagerly too (e.g. call resolveSandboxUrl() at module scope alongside the other two resolvers) so misconfiguration is caught before serving traffic.

Extended reasoning...

sandbox.ts builds connectionOpts (with sandboxUrl: resolveServerSandboxUrl(...)) before the surrounding try block at 4 call sites (openTerminal ~238, killTerminalPty ~421, resume ~326, pause ~382). If E2B_SANDBOX_URL (or its NEXT_PUBLIC_ fallback) is set to a scheme-less or otherwise malformed value, assertHttpUrl throws inside resolveSandboxUrl, uncaught by the local try/catch, and is only ever exercised once a user opens/resumes/pauses/kills a terminal — not at container start, and not by scripts/container-smoke.sh, which never opens a terminal. Every such action then fails with an opaque tRPC INTERNAL_SERVER_ERROR in production, well after the operator believed the fail-fast env check had validated the deployment.

Verification: nit. The facts hold. resolveSandboxUrl()/resolveServerSandboxUrl() are invoked only lazily — in sandbox.ts procedures (openTerminal:238, resume:326, pause:382, killTerminalPty:421) and /api/config (route.ts:9) — never at module scope. api.ts:11,13 calls only resolveInfraApiUrl/resolveDashboardApiUrl eagerly, so only those two fail-fast on server import; the module comment… | nit. Mechanism is…

Comment thread src/configs/cookies.ts
Comment on lines +30 to +35
function isSecureCookie(): boolean {
const configured: string | undefined =
process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase()

if (configured !== undefined && configured !== '') {
return configured !== 'false'

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…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant