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_URLand friends) at launch. Noeval, no.envshuffle. - Ordering. Backing services come up healthy before the app starts.
- One foreground command.
eph devruns 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 itsenv.*; that is how the value reaches the process (most frameworks readPORT). The same port resolves everywhere else: other services’ hooks,eph env,APP_URLabove. - Stable across restarts. eph reuses the previously assigned port on the
next
eph upwhen 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=autois only valid forrun=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:
- Brings the backing services up, running each one’s own
pre-starthook immediately before it starts (so a later service’s hook can reference an earlier one’s assigned port) and waiting for health, exactly the interleavingeph upuses. - Runs the foregrounded app’s own
pre-starthook, which can therefore reference every backing service’s assigned port. - 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. - Runs every service’s
post-starthooks (migrations, seeds), the foregrounded app included, once everything is up, so apost-starthook may reference any service’s port. - 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*.tomlmatches a top-levelCargo.tomlwhile**/*.rsmatches 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 devstarted 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
.gitnever 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:
autoPorthands 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$PORTis set,eph devkeeps the app on its own internalport=autoand opens$PORTas 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 devopens$PORTonly afterpost-starthooks 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 downteardown keeps the database between launches. UseruntimeArgs: ["dev", "--clean"]only if you want a pristine database every launch. ephmust be on the app’s PATH. A desktop app does not always inherit your shellPATH(notably a macOS Dock launch); use an absolute path inruntimeExecutableif 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.