Docs

Running Your App

The backing services are only half the workspace. The other half is the app you are actually building: the dev server you restart constantly, the process that needs DATABASE_URL set and a port to bind. This page brings it under eph’s management, which buys you four things:

  • A collision-free port. Two checkouts of the same project stop fighting over port 3000.
  • The environment, injected. The app inherits every resolved variable (DATABASE_URL and friends) at launch. No eval, no .env shuffle.
  • Ordering. Backing services come up healthy before the app starts.
  • One foreground command. eph dev runs the whole stack (setup, seeding, the app, teardown) as a single process, which is exactly what preview servers and simple task runners want.

The app is a run= service

Model the app as a run= service with port=auto:

[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp
healthcheck=pg_isready -U dev

[web]
run=npm run dev
port=auto
env.PORT=${web.port}

[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp
APP_URL=http://localhost:${web.port}

Because eph launches the process, npm run dev starts with DATABASE_URL already set (backing services start first, so the value resolves), plus the EPH_* metadata and its own env.* values. The app connects to Postgres with zero shell plumbing.

port=auto

Container services get their host port from Docker. A run= process binds its own port, so eph handles it the other way around: port=auto makes eph allocate a free host port and hand it to the process through its environment.

[web]
run=npm run dev
port=auto
# tell your framework which port to bind
env.PORT=${web.port}

How it behaves:

  • eph picks the port and injects it. Reference the service’s own assigned port as ${web.port} in its env.*; that is how the value reaches the process (most frameworks read PORT). The same port resolves everywhere else: other services’ hooks, eph env, APP_URL above.
  • Stable across restarts. eph reuses the previously assigned port on the next eph up when it is still free, so bookmarks and OAuth callback URLs keep working. It only moves when the old port is taken.
  • Self-healing on startup exit. There is an unavoidable instant between eph reserving a port and your process binding it. An auto-port process that exits during startup is relaunched on a fresh port, up to four attempts. Fixed-port and portless processes fail immediately on an early exit.
  • Use your framework’s strict-port mode. The self-heal works only if your dev server exits on a busy port. A framework that silently picks the next port instead (Vite without --strictPort, for example) sidesteps both the conflict detection and the port eph reports. Strict mode makes the assigned port the bound port.
  • port=auto is only valid for run= services; container services already get a random host port from Docker.

Multiple auto ports work too (a frontend plus its HMR socket):

[web]
run=npm run dev
port.app=auto
port.hmr=auto
env.PORT=${web.port.app}
env.VITE_HMR_PORT=${web.port.hmr}

eph dev: the foreground loop

eph up starts services in the background and returns. eph dev runs the same stack as one foreground process.

eph dev              # foreground the sole run= service
eph dev web          # foreground a specific run= service by name
eph dev --skip-hooks # bring up and tear down with no lifecycle hooks

What it does, in order:

  1. Brings the backing services up, running each one’s own pre-start hook immediately before it starts (so a later service’s hook can reference an earlier one’s assigned port) and waiting for health, exactly the interleaving eph up uses.
  2. Runs the foregrounded app’s own pre-start hook, which can therefore reference every backing service’s assigned port.
  3. Starts the chosen run= app in the foreground: eph’s stdin, stdout, and stderr are wired straight through, so the app is fully interactive and its output streams to your terminal. eph’s own startup chrome goes to stderr, out of the app’s stdout. An auto-port app that exits during startup is retried on a fresh port, up to four attempts, while preserving its inherited output.
  4. Runs every service’s post-start hooks (migrations, seeds), the foregrounded app included, once everything is up, so a post-start hook may reference any service’s port.
  5. Blocks until the app is stopped, then tears down only the services it started itself, and leaves anything that was already running (a prewarmed dependency tier, say) untouched.

--skip-hooks skips all four hook phases for the whole session (bring-up and teardown alike), matching eph up --skip-hooks and eph down --skip-hooks.

The teardown default is eph down: containers and data stay for a fast next launch. Pass --clean to end with a full eph clean instead, dropping named volumes and their data for a pristine next run.

If the app crashes, eph dev exits non-zero and leaves the backing services up; eph down stops them when you are done. A hard kill of eph itself (SIGKILL) cannot run teardown, so the stack stays up, recoverable with eph down.

With no SERVICE argument, eph dev foregrounds the sole run= service in the file; name one when there are several. A .eph with no run= service is an error.

Watch mode

--watch GLOB (repeatable) restarts the whole stack when a matching file changes:

eph dev --watch "**/*.rs" --watch "*.toml"
  • Globs are relative to the workspace root with gitignore-style separators: * stays within a directory, ** spans directories, so *.toml matches a top-level Cargo.toml while **/*.rs matches any Rust file.
  • A restart is a full down and up, hooks included (pre-stop, post-stop, pre-start, post-start), not a bare process bounce, so a migration or codegen step reruns just as it would on a manual restart. Named volumes are always kept across restarts, even under --clean; that reset is reserved for the final stop.
  • Only the services eph dev started are bounced. An adopted, already-running dependency tier stays hot across restarts.
  • Changes are debounced (one save is one restart), and git’s churn under .git never triggers one.
  • In watch mode a crashing app does not end the session: eph reports the exit and waits for your next change to restart, since editing is exactly when crashes happen. Without --watch, the same crash ends the command.

Claude Desktop preview servers

Claude Desktop launches a dev server from .claude/launch.json and watches its port for the in-app preview. Each configuration runs a single foreground command with no separate setup or teardown hook, which is exactly the shape of eph dev: one command that brings the stack up, seeds it, foregrounds the app, and tears down when stopped.

Point the preview at eph dev:

// .claude/launch.json
{
  "version": "0.0.1",
  "configurations": [
    {
      "name": "web",
      "runtimeExecutable": "eph",
      "runtimeArgs": ["dev"],
      "port": 3000,
      "autoPort": true
    }
  ]
}

With a .eph like the one at the top of this page (postgres, plus a web app with port=auto and a post-start=npm run db:migrate seed), the pieces line up like this:

  • autoPort hands eph the port. The preview server picks a free host port and passes it as $PORT, then polls that port and reveals the app the instant it accepts a connection. When $PORT is set, eph dev keeps the app on its own internal port=auto and opens $PORT as a forwarding gate to it. Do not also pin a fixed port on the app.
  • The gate waits for seeding, not just for the server. eph dev opens $PORT only after post-start hooks finish. The preview therefore cannot go live while a slow seed is still filling the database; without the gate it would show an empty app the moment the server answered its first request.
  • The preview console is live. The app’s output streams through eph to the preview console, and stdin flows back.
  • Restarts are cheap. Claude Desktop stops and relaunches the preview server during a session, and the default eph down teardown keeps the database between launches. Use runtimeArgs: ["dev", "--clean"] only if you want a pristine database every launch.
  • eph must be on the app’s PATH. A desktop app does not always inherit your shell PATH (notably a macOS Dock launch); use an absolute path in runtimeExecutable if needed.

If you would rather keep the app out of eph, model only the backing services in .eph and let launch.json run the app through eph run so it still gets the resolved environment: "runtimeExecutable": "eph", "runtimeArgs": ["run", "npm", "run", "dev"]. You then own eph up and eph down yourself; that manual work is what eph dev automates.

Splitting the app from its dependencies

Once your file has both tiers, roles make them addressable: tag services role=dep and role=app, declare roles_order=dep,app, and you can prewarm the slow dependency tier once (eph up --role dep) while eph dev starts and stops only the app. eph dev adopts an already-running tier rather than restarting it, and leaves it running on exit. The full workflow, including a Claude Code SessionStart hook, is in Recipes.

Next

Shell Integration covers getting the resolved environment into your shell and tools when the app runs outside eph.