name: using-eph
description: Use when working in a repository that uses eph for ephemeral dev services (a .eph file exists, or eph is on PATH). Covers the local loop: start per-workspace services with eph up, load their connection details with eph env, and tear them down with eph down / eph clean. Also covers detecting an eph workspace, reading the .eph file, and the auto-assigned ports that mean you must never hardcode a connection string.
Using eph
eph is "dotenv for services": a CLI that starts the dev services a project
needs (Postgres, Redis, MinIO, ...) from a .eph file in the project root.
Containers are namespaced by a hash of the workspace path and host ports are
assigned randomly, so two checkouts never collide. Your job as an agent working
in such a repo is to bring those services up, wire your tools at the ports eph
actually assigned, and tear them down when you are done.
Detect an eph workspace
A workspace is any directory with a .eph file (eph searches upward from the
current directory, like git).
test -f .eph && echo "this project uses eph"
eph check
eph info
eph status
eph check and eph info never touch Docker, so they are always safe to run
first to learn the shape of the workspace.
The core loop
eph up
eval "$(eph env)"
eph down
eph up [SERVICE...] starts all services, or just the named ones. It is
idempotent: a running service is reused, a stopped container is restarted, and
an absent one is created fresh.
eph up blocks until each service passes its health check, so when it returns
the services are ready (one exception below for services with no health check).
- An unknown service name is an error.
Reading the environment (never hardcode ports)
Host ports are random and change every time a container is created, so the only
correct way to reach a service is through eph env. Do not read a port out of
the .eph file: that is the container port, not the published host port.
eval "$(eph env)"
eph env -f fish | source
eph env -f json | jq -r .DATABASE_URL
eph env prints only the top-level KEY=VALUE variables from the .eph file,
with ${service.port} style interpolations filled in from running services.
A reference to a stopped service is left unresolved, so run eph up first.
Tearing down
Three deliberately distinct levels, from lightest to heaviest:
| Command | Effect |
|---|
eph down | Stop services; keep containers and volume data for a fast restart. |
eph down --rm (alias -r) | Also remove the containers; named-volume data is kept. The next up recreates them. |
eph clean | Full reset: remove containers, remove per-workspace named volumes (deletes that data), and delete saved state. |
compose services are always fully torn down (--rm is a no-op for them);
clean removes only the named volumes declared in the .eph file, not
Compose-internal ones.
Reading the .eph file
It is INI-with-.env: top-level KEY=VALUE lines are shell env vars, and
[name] sections are services. Comments must be on their own line (#); a #
after a value is part of the value, so do not add inline comments.
[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
volume=pgdata:/var/lib/postgresql/data
healthcheck=pg_isready -U dev
post-start=npm run db:migrate
DATABASE_URL=postgres://dev@localhost:${postgres.port}/app
- A service names exactly one source:
image=, dockerfile= (+ context=),
compose= (+ expose.<name>=), or run= (a host process via sh -c).
port= is a container port published on a random host port; ${svc.port}
in a top-level variable resolves to the assigned host port at eph env time.
- For a
run= service (a first-party app eph launches), port=auto /
port.<name>=auto make eph allocate a free host port and inject it into the
process; reference the service's own assigned port as ${svc.port} in its
env.X (e.g. env.PORT=${web.port}). eph keeps the port stable across
restarts and re-launches the app on a fresh port if it dies on a port
conflict. Still never hardcode it — read it via eph env / ${svc.port}.
env.X= is set inside the container; the trailing DATABASE_URL= is a shell
env var emitted by eph env.
volume=name:/path is a per-workspace named volume; healthcheck for an image
service runs with no shell (whitespace-split, docker exec); the lifecycle
hooks run on the host via sh -c with eph's resolved environment injected (see
below).
Lifecycle hooks see eph's environment
Four hooks bracket a service, in order: pre-start (before it is created),
post-start (after it is healthy), pre-stop (before it stops), post-stop
(after it has stopped). All run with the same variables eph env emits already
in their environment, so a database migration or codegen step just works:
[api]
run=./bin/server
pre-start=go generate ./...
post-start=psql "$DATABASE_URL" -f schema.sql
pre-stop=./scripts/backup.sh
post-stop=rm -rf .cache/scratch
DATABASE_URL=postgres://dev@localhost:${postgres.port}/app
Each hook receives, layered in this order (later wins):
- the resolved top-level
.eph variables (exactly what eph env prints),
EPH_* metadata: EPH_WORKSPACE_ID, EPH_WORKSPACE_ROOT,
EPH_CONTAINER_PREFIX, and per service EPH_<SERVICE>_HOST,
EPH_<SERVICE>_PORT, EPH_<SERVICE>_PORT_<NAME> (for named ports),
EPH_<SERVICE>_CONTAINER (service names upper-cased, - -> _),
- the owning service's own
env.X= values.
pre-start runs just before its own service is created, so it cannot see that
service's own port, but it does see any service already up (backing services
start before run= apps). Use it for prep the service depends on, like codegen.
post-start hooks run only after every service in the up is healthy, so a
hook may reference any other service's port (${redis.port} resolves even if
redis started after the service whose hook needs it). post-stop runs after its
service has stopped and sees the same pre-teardown environment as pre-stop.
pre-start and post-start run on every eph up (fresh create or
restart); a failing pre-start aborts the up before its service starts and a
failing post-start aborts the up. A failing pre-stop aborts the down /
clean and leaves the service running so you can fix and retry; a failing
post-stop aborts the rest of teardown, but its service is already stopped.
Write hooks to be idempotent (a migration that no-ops when already applied, an
INSERT ... ON CONFLICT seed). For one-off, non-idempotent work, use eph run
instead.
Pass --skip-hooks to skip hooks for one invocation: eph up --skip-hooks
starts services without pre-start/post-start; eph down --skip-hooks /
eph clean --skip-hooks tear down without pre-stop/post-stop (the escape
hatch when a broken hook is wedging teardown).
Running one-off commands with the environment: eph run
eph run <cmd>... runs a command in the workspace root with the resolved
environment (the eph env variables plus the EPH_* metadata) already set, so
you do not have to eval "$(eph env)" first:
eph run psql "$DATABASE_URL"
eph run ./scripts/seed.sh
eph run sh -c 'psql "$DATABASE_URL" < dump.sql'
The command is executed directly, not through a shell, so eph does not expand
$VAR in the arguments; wrap it in sh -c '...' when you need shell expansion
of eph's injected variables, piping, or globbing. eph run exits with the
command's own exit code. Use it for repeatable operations (seeding, resets,
ad-hoc queries) -- unlike post-start, it runs every time you invoke it.
Claude Desktop preview servers: eph dev
eph dev runs the whole stack in the foreground for a Claude Desktop preview
server (.claude/launch.json), which launches one command and watches its port
but has no setup or teardown hook. eph dev fills both: it runs
pre-start hooks (e.g. codegen), brings every service up, foregrounds a run=
app with eph's own stdin, stdout, and stderr wired through to it, runs
post-start (seeding), and on stop tears the stack down -- eph down by
default, or eph clean with --clean (each running pre-stop then
post-stop).
{
"version": "0.0.1",
"configurations": [
{ "name": "web", "runtimeExecutable": "eph", "runtimeArgs": ["dev"], "port": 3000, "autoPort": true }
]
}
- Model the app as a
run= service with port=auto. The app runs on its own
internal port; eph dev opens the $PORT the preview server injects (its
autoPort) as a forwarding gate to the app, but only after post-start
hooks finish. Since the preview watches $PORT, it does not see the app as
ready until seeding is done, not the instant the server can answer a health
check. Do not also give the app a fixed port.
- With no SERVICE the sole
run= service is foregrounded; eph dev <service>
picks one when the .eph defines several.
- Teardown defaults to
eph down (keeps data for a fast relaunch, since Claude
restarts the server during a session). Use eph dev --clean (runtimeArgs: ["dev", "--clean"]) for a pristine reset on every launch.
- A hard kill (not a normal stop) skips teardown and leaves services up,
recoverable with
eph down. If the app crashes on its own, eph dev leaves
services up for inspection (eph logs <service>) and exits non-zero.
eph must be on the app's PATH; the desktop app may not inherit your shell
PATH, so use an absolute path in runtimeExecutable if it cannot find eph.
Inspecting logs: eph logs
eph logs
eph logs -f
eph logs <service>
eph logs -f <service>
eph logs -n 50 <service>
Works for every service type: run= services read from a captured log file,
while image= / dockerfile= / compose= services proxy docker logs /
docker compose logs. With no service, every service is streamed concurrently
and interleaved (compose-style), each line prefixed by a color-coded [name]
tag; a single eph logs <service> is untagged and pipe-friendly. Logs show even
for a stopped service, so a run= service that died on startup still leaves a
trace -- check eph logs <service> when a service is missing from eph status.
Behaviors that matter
- Ports are random and change per create. Never hardcode a host port; always
go through
eph env.
- Idempotent up. A running service is reused and a stopped one restarted, but
pre-start and post-start hooks run on every eph up regardless. Write
them to be idempotent (migrations that no-op when applied, codegen that
overwrites in place), or move one-off work to eph run. A failing pre-start
aborts the up before its service starts; a failing post-start aborts the
up.
- Image health checks have no shell: one whitespace-split command, no pipes,
&&, $VAR, or quoted spaces, and the binary must exist in the image.
- A service with no health check is given a fixed short wait, so it may need a
moment to accept connections after
eph up returns; retry your first connect.
- Isolation by path: two checkouts are different containers, volumes, and
ports. There is no
eph init (author .eph by hand).
- Output is on stdout; logs go to stderr.
eph env output is clean for
eval and piping. Add -v / --verbose for debug logging on stderr.
Safe defaults for automation
- Validate before acting:
eph check.
- Start and load in the same step so the env is fresh:
eph up && eval "$(eph env)" && <your command>.
- Tear down in an always-run step (even on failure):
eph clean.
- Treat
.eph as possibly holding dev credentials: do not print it to logs or
commit one with real secrets.