Skip to main content

What runs your code

Bind 0.0.0.0, never 127.0.0.1. This is the one failure that lies. The health probe runs inside the container against 127.0.0.1:8080, but real traffic arrives from the proxy on the container's bridge address. An app listening only on loopback comes back status: "healthy" with empty errors, and then 502s on its real URL:

{ "error": "app is not serving", "reason": "upstream: client error (Connect)" }

Bun's port and Node's server.listen(port) bind every interface by default. The trap is writing the host explicitly. Read the port from PORT; it is always 8080, but read it.

The proxy speaks HTTP/1.1 to your app, and h2c when the request is gRPC. What clients negotiate with the edge is separate and can be either. A request carrying content-type: application/grpc is forwarded as cleartext HTTP/2 with te: trailers, which is what a tonic or grpc-go server needs (streaming in both directions included, since a gRPC status arrives in trailers and those are carried through end to end). application/grpc-web stays on HTTP/1.1, where it belongs. An app serving only cleartext HTTP/2 without being a gRPC server cannot be reached, and its deploy says so rather than reporting healthy.

The base image is oven/bun, so the runtime is Bun. TypeScript and JavaScript run as written, with no transpile step of your own. Python, Ruby and the JVM are out of scope. A package.json triggers bun install against a cache shared by every app on the box; no package.json skips install entirely, which is the fastest path for a single-file app. Then the entry point is picked, in this order:

  • scripts.start in package.json → bun run start
  • otherwise the first of index.ts, index.tsx, index.js, server.ts, server.js, main.ts, app.ts, src/index.ts, src/index.js, src/server.ts
  • otherwise an index.html at the root → served as a static site. Last on purpose, so an app shipping its own template still runs its own server.

No match is a failed deploy with that list in the error, not a silent guess.

That first rule is why a compiled binary runs, Rust and Go included: it goes through scripts.start rather than a runtime of its own. Three things have to line up. The binary must be built for linux/x86-64: nothing compiles here, and the image is glibc, so target musl or match it. A package.json has to name it. And the binary itself has to be in the bundle, which is the one most often gitignored.

{ "scripts": { "start": "./server" } } # with the built binary beside it

Miss any of the three and it is a crashed deploy with the reason in errors, not a silent fallback. Note that the package.json also triggers bun install, which is a no-op with no dependencies but not free.

Strip the binary. A debug build is the whole bundle, and go build keeps its symbols by default. Measured against this platform, an 8 MB Go binary is 4.6 MB of the 50 MB cap where the same server in Rust, stripped, is 227 KB. -ldflags="-s -w" for Go, strip = true under [profile.release] for Cargo.

Nothing here builds. There is no npm run build step: deploy the output of your build, not its source. Every scaffold gitignores that output and the bundle is gitignore-aware inside a repo, which is why deploying a project root so often ships the source and nothing else. Read bundle.excluded in the response.

DeployingShip this
Vite, CRA, any static front enddist/
Angulardist/<project>/browser/ (the SSR build's server/ is not a static site)
Next.js, servedthe project root, with /.next/cache/ gitignored in place of /.next/
Next.js, exportedout/, after output: "export" (sub-second, and the better choice unless the app needs a server)
Nuxtsame shape: ship what the build produced, or export it

A Vite source directory used to deploy healthy and serve a blank page, because the source has a root index.html too; that case is refused now, with the build command in the error. next start reads PORT and binds 0.0.0.0, so a served build needs no special handling. A default Next.js app measures 1.5 MB and ~22 s, most of it bun install.

What the static path serves is a front end and nothing else: no /api/* unless you write a server yourself, and secrets do nothing. They arrive as process environment and a static bundle has no process to read them. Client-side routing is handled, though: any request a browser makes as a navigation falls back to index.html, while a missing .js still answers 404 rather than quietly serving HTML in its place.

The process starts with a cleared environment plus exactly this:

PORT=8080 # bind this, or nothing reaches you
HOST=0.0.0.0 # localhost-only is unreachable from the proxy
DATA_DIR=/data # the only writable path that survives a restart
NODE_ENV=production
HOME=/tmp

…plus the app's secrets. Anything else a shell would have provided is not there. There are 10 seconds to accept a connection on PORT; after that the deploy is a timeout. A process that exits later is restarted three times with backoff before the app is marked failed (three times per burst, since staying up for 30 seconds counts as a recovery and returns the budget). Then the URL serves a 502 carrying the same error lines, so whoever loads it sees what the agent would.