-
Notifications
You must be signed in to change notification settings - Fork 78
feat(container): self-hostable container image with runtime configuration #528
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
svalleru
wants to merge
7
commits into
main
Choose a base branch
from
feat/self-host-container
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
46976f5
feat(container): serve the dashboard from a standalone container image
svalleru 584f43e
ci(container): build and smoke-test the image on container changes
svalleru ae99405
feat(config): resolve infra-api and dashboard-api URLs at runtime
svalleru 31732a4
feat(config): serve browser API URLs from a runtime config endpoint
svalleru b8bb9af
feat(config): make the api key cookie's Secure flag configurable
svalleru 03121b0
fix(config): harden forwarded-host fallback and document the runtime …
svalleru d022696
fix(config): resolve the server-side sandbox URL like the browser does
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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…