Making your app run inside a deploy
What your app has to do to run here — bind to PORT, answer HTTP quickly, and bring its own data.
A deploy is a fresh container holding your repository at one commit, and nothing else. Your app has to come up in it, on its own, in about ninety seconds. This page is the contract: three things your app must do, and what is already in the container to help.
Bind to PORT
Midstream sets a PORT environment variable before running your start command,
and looks for your app on that port. The number is not fixed and not the one you
use locally, so read it:
const port = process.env.PORT || 3000;
app.listen(port);Most Node servers already do — next start, Express, Nest, and Fastify all read
PORT. What does not work is a port hardcoded in a config file, or a
package.json script that passes --port 3000. Those apps come up on a port
nobody is watching and the deploy fails as though they never started.
Bind to localhost or to all interfaces. An app bound to one specific external
address is unreachable.
Your Playwright config needs the same treatment, because the seeding test runs against the app we started:
export default defineConfig({
use: { baseURL: `http://localhost:${process.env.PORT ?? 3000}` },
});If your config has a webServer block, point its url at PORT too. Playwright
reuses a server that is already answering, so it will find the app we started
instead of trying to start a second one.
Answer HTTP within ninety seconds
Once your start command is running, Midstream requests / about five times a
second and waits up to ninety seconds for an answer. Any HTTP response counts —
a 200, a redirect to your login page, even a 500. We are checking that
something is listening and speaking HTTP, not that your app is healthy.
The clock covers everything your start command does on the way there, not just the server at the end of it. A command that brings up a database container, writes a config file and then launches a server spends the budget on all three.
Two ways this fails:
- The process exits first. Your start command ran and quit before serving
anything — a missing build, a config error, a crash on boot. The error is
SANDBOX_START_FAILEDand the deploy stops. - Nothing ever answers. The process is alive but the port stays silent for
ninety seconds. The error is
SANDBOX_START_TIMEOUT.
Ninety seconds is the tightest budget in a deploy, and the fix when you run out
of it is almost always to move work earlier. Your install command has fifteen
minutes. Build there, migrate there, seed there. Leave start doing nothing but
starting a server that is already built.
Your start command must also stay in the foreground. It is the app's process for the life of the instance. A command that backgrounds itself and returns looks exactly like an app that exited.
Bring your own data
Nothing from your own infrastructure comes with a deploy. No staging database, no Redis, no environment variables from your CI, no secrets. Your repository at one commit, and whatever your install and start commands create — that is the whole world.
So anything the test needs on the way to the scene, you create. In practice that means one of:
- A database in the container. Bring it up in
installwith Docker Compose, then migrate and seed it. - SQLite, or another file-backed store, committed or created during install. The simplest option by a distance if your app supports it.
- Fixtures created by the test itself, through your own app's UI or API on the way to the scene.
Do not point a deploy at a real production database. It would need credentials you cannot supply, and a scene captured against real data stores the session that was live at that moment. Use test accounts and seeded data — the security article in the troubleshooting section covers why.
What is already in the container
-
Node.js 24, with
npm. Corepack is available, socorepack enable pnpmin your install command gets you pnpm or Yarn. -
Docker, with Compose. This is how you bring anything else: Postgres, Redis, or your whole app if it is not a Node app. The daemon runs inside the deploy and starts with it, so ports your containers publish land on the same
localhostwe watch for your app. It starts with no images cached, so every pull and build is a first one — count that against your install budget. -
Chromium for Playwright, already installed. Playwright pins a Chromium build to each of its own versions, so which build you need is decided by the Playwright in your
package.json. The container ships one; if yours is a different one, the deploy runs your project's ownnpx playwright install chromiumand fetches it before your test runs. You do not have to do anything.Keeping that command in your install command is still fine — it finishes in a second and downloads nothing when the browser is already there.
--with-depsworks too, and is never needed: the system libraries are in the image already, and the flag spends a minute of your install budget proving it. -
Git, and your repository checked out at the capture's commit. The checkout is one commit deep, so anything that reads history — a build that stamps the last tag, say — will come up empty.
Commands run as an unprivileged user with passwordless sudo — the same shape
as a GitHub Actions runner, so a command copied out of your workflow file
behaves the way it does there. sudo apt-get install … works; a bare
apt-get install … does not. Prefer Docker for anything large: an apt install
runs on every deploy and comes out of your install budget.
Advice by stack
Next.js, Remix, SvelteKit, Nuxt. Build in install, start the production
server in start. Do not use a dev server: the first request compiles, which
frequently outlasts the readiness window.
{ "install": "npm ci && npm run build", "start": "npm start" }A plain Node or Bun API. Usually already correct, as long as it reads PORT.
Anything not JavaScript — Rails, Django, Laravel, Go, Elixir. There is no
Ruby, Python, or PHP in the container, so run your app the way your Dockerfile
already does:
{
"install": "docker compose build",
"start": "docker compose up --abort-on-container-exit web"
}Publish the web service on PORT in your Compose file, since that is the port we
watch:
services:
web:
ports:
- "${PORT}:3000"And keep the command in the foreground, so it stays your app's process for the life of the instance.
An app with a separate frontend and backend. Serve both from one port, or put a small proxy in front. Midstream watches exactly one port.
After the app is live
Once the seeding test reaches the scene, the instance is ready and your app keeps
running. If it crashes, we restart it, twice. A third crash ends the instance
with SANDBOX_APP_CRASHED.
An instance that nobody is using goes to sleep, and wakes on the next visit. A
resumed instance skips the clone, the install, and the seeding test — that work
is already on disk, which is why the second open is quick and the first is not.
Your start command runs again, though, so it has to work against a checkout that
is already installed and a database that already has data in it. A start that
assumes an empty database and fails on a duplicate row will resume badly.
Every stage has a deadline, not just the readiness window. The full table is in the troubleshooting section under Time limits.