Core Concepts
This page explains the model behind eph: workspaces, isolation, automatic
ports, persisted state, the service lifecycle, and the split between dependency
services and the app you are building. Once these six ideas click, the commands
and the file format are obvious.
Workspaces
A workspace is any directory that contains a .eph file.
When you run an eph command, it searches the current directory and then walks
up through parent directories until it finds a .eph file, the same way
git finds a repository. The directory that holds the file is the workspace, and
every command operates on that workspace, so eph status works from any
subdirectory of your project.
If no .eph file is found in the current directory or any parent, the command
fails with no .eph file found.
All relative paths and shell commands in your .eph file (volumes,
dockerfile=, compose=, run=, health checks, and lifecycle hooks) resolve
and execute from the workspace root, not from wherever you happen to run
the command.
Isolation
Each workspace gets its own eph-managed resource namespace, including two
checkouts of the same repository. Direct containers receive separate names,
volumes, and automatic host ports. Fixed run= ports and bindings declared in
a Compose file remain explicit configuration and can still conflict.
Isolation is keyed on a workspace ID: the SHA-256 hash of the workspace’s
absolute (canonicalized) path. The first 16 hex characters form the short
ID that namespaces everything eph creates:
~/projects/app/ -> short ID a1b2c3d4e5f60718 -> eph-a1b2c3d4e5f60718-postgres
~/projects/app-v2/ -> short ID e5f60718293a4b5c -> eph-e5f60718293a4b5c-postgres
| Resource | Name |
|---|---|
| Container | eph-<short_id>-<service> |
| Named volume | eph-<short_id>-<service>-<volume> |
Built image (dockerfile=) | eph-<short_id>-<service> |
Compose project (compose=) | eph-<short_id>-<service> |
Because the ID comes from the path, the two checkouts above get different
container names, different volumes, and different ports. If the state root
contains an 8-character namespace whose metadata matches the full workspace
ID, eph uses that namespace until eph clean. An 8-character state directory
that cannot be verified blocks workspace construction so eph cannot start a
second namespace beside unknown resources.
Run eph info to see the ID, short ID, container prefix, and paths for the
current workspace.
Automatic ports
For each port= you declare, eph asks Docker to
publish the container port on a random free host port, bound to
127.0.0.1. This means:
- Direct container services avoid fixed host-port conflicts. Each creation asks Docker for an available port.
- Nothing is exposed to your local network. Services are bound to loopback only.
- The real port changes between container creations, so never hardcode it.
Reference it symbolically instead (next section) and let
eph envfill in the current value.
One exception: run= (non-container) services bind whatever port their process
binds. With a numeric port=, eph reports the declared value as-is; with
port=auto, eph allocates a free port and injects it into the process. See
Running Your App.
Compose services own their port publishing rules. An expose.<alias>= entry
discovers the host port selected by the Compose file; eph does not change its
bind address.
Interpolation: connecting your app to the ports
Because ports are dynamic, your environment variables reference services symbolically:
[postgres]
image=postgres:16-alpine
port=5432
[redis]
image=redis:7-alpine
port=6379
[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp
REDIS_URL=redis://localhost:${redis.port}
When you run eph env, each ${...} is replaced using the currently
running services:
| Reference | Resolves to |
|---|---|
${service.port} | The assigned host port (single-port services) |
${service.port.name} | A named port (multi-port services) |
${service.host} | Always localhost |
If a service is not running, eph env clears the affected variable in shell
formats, appends a failing shell statement, warns on stderr, and exits nonzero.
JSON omits the affected key and also exits nonzero. eph run refuses to launch
with an incomplete top-level environment. Run eph up first so every
reference resolves.
All four service types resolve once running: eph finds image and
dockerfile services by container name, run services by their tracked
process, and compose services by their Compose project label. Details in
Shell Integration.
Persisted state
When eph starts services, it records what it started (container IDs, assigned
ports, and process PIDs) in a small state.json file, with workspace metadata
beside it:
| Platform | Location |
|---|---|
| Linux | ~/.local/share/eph/<short_id>/state.json |
| macOS | ~/Library/Application Support/eph/<short_id>/state.json |
| Windows | %LOCALAPPDATA%\eph\<short_id>\state.json |
Set EPH_STATE_ROOT to an absolute path to override the parent directory (the
eph above <short_id>) for every workspace. Relative values are rejected so
state cannot move when a command runs from a different directory. The override
is useful for relocating state or giving a test harness a throwaway root.
State is why eph status and eph env answer instantly, why assigned ports
survive a terminal restart, and why eph knows which containers and volumes
belong to this workspace.
state.json is written after every individual service starts, not once at
the end of eph up: if a later service’s pre-start hook or creation fails,
whatever already started is still on disk, so eph down can find and stop
it instead of leaking it. The write itself is atomic (a temp file, renamed
over the real one), so an interrupted write does not replace state with a
truncated file
behind. If state.json is still unreadable (hand-edited, corrupted by
something outside eph), the next command quarantines it to
state.json.corrupt, warns, and continues with empty state rather than
aborting; recover a run= service’s PID by hand if it needs stopping, since
that is the one thing the quarantine cannot recover on its own (containers are
found again from Docker by name).
eph up, eph down, and eph clean on a given workspace serialize against
each other through an OS-level file lock, so two overlapping commands never
race the same state.json or double-spawn a service; the second command
waits for the first (printing a notice while it does) rather than
proceeding against stale state. The lock is released by the operating system
the instant the holding process exits, crash included, so a killed eph up
can never wedge a later command.
Two commands manage state directories in bulk: eph clean deletes the state
directory for the current workspace along with its services and data, and
eph system prune
scans all state directories and removes leftovers for workspaces whose
directory has since been deleted (a worktree you removed, for example). A
successful eph up checks for exactly that situation in other workspaces (a
cheap filesystem scan, never Docker) and prints a one-line note on stderr
pointing at eph system prune when it finds any, so stale state does not sit
unnoticed until you happen to run prune yourself.
The service lifecycle
Bringing a service up is idempotent when its effective configuration still
matches. eph up fingerprints the source, immutable image, ports, resolved
environment, volumes, health settings, build context, and command before it
chooses a path:
- Already running and matching: the service is reused, then any declared health check is rerun.
- Stopped but still present and matching (after
eph down): the existing resource is restarted and checked. Fast, and the data is still there. - Not present: a fresh container is created, pulling or building the image if needed.
- Configuration drifted: the old resource is removed through the backend
type that created it, then the requested configuration is created. This also
handles source changes such as
run=toimage=.
That is the container story (image and dockerfile). The other two sources
have the same fingerprint gate: a run service includes its complete resolved
process environment, and Compose includes its exact delegated configuration.
Dockerfile services build through Docker’s cache on every up, then use the
resulting image ID to detect effective context changes. A failed start is
removed before up returns, so the next attempt cannot adopt a broken leftover.
Hooks bracket the lifecycle
Each service can declare four hooks: pre-start, post-start, pre-stop, and
post-stop. During eph up, each service’s pre-start hooks run just before
it is created (codegen, generated config), and once every service in the
up is healthy, all post-start hooks run in a second phase (migrations,
seeds). Deferring post-start this way means a hook can reference any other
service’s assigned port. Teardown mirrors it: pre-stop before a service stops
(backup, drain), post-stop after.
Two rules matter here; the full contract lives in
The .eph File:
pre-startandpost-startrun on everyeph up, not only on fresh creation. Write them to be idempotent: a migration that no-ops when applied, anINSERT ... ON CONFLICTseed. For one-off work, useeph runinstead.- A failing hook aborts the command rather than being skipped silently.
--skip-hooksis the escape hatch onup,down, andclean.
Three levels of teardown
| Command | Stops | Removes container | Removes named volumes (data) | Removes state |
|---|---|---|---|---|
eph down | yes | no | no | clears entries |
eph down --rm | yes | yes | no | clears entries |
eph clean | yes | yes | yes | deletes directory |
eph down keeps containers and data for a fast restart. eph down --rm
removes containers but keeps named-volume data, forcing a fresh create next
time. eph clean is the full reset: it deletes the data in named volumes,
so use it when you want to start over completely.
Three footnotes to the table:
- Bind mounts (host paths like
./seedorC:\data) are never deleted byeph clean. Only Docker named volumes are. composeservices are torn down withdocker compose downin every case, so--rmmakes no difference for them. Compose services cannot declare.ephvolumes, andeph cleandoes not remove volumes defined inside the Compose file. See Defining Services.- Teardown works from recorded state, not just the sections currently in
your
.ephfile. A bareeph down(no service names) andeph cleanboth also stop and remove anythingstate.jsonremembers starting under a name that is no longer in the file, so renaming or deleting a service’s section does not orphan its container. (A targetedeph down <service>only accepts names that still exist in the file, so it cannot reach a renamed entry by its old name; use the bare form to sweep those up.)eph cleanadditionally reports measured counts, what it actually stopped or removed rather than the number of services declared, so a workspace that never started anything reports zeros; it also sweeps any leftover Docker container or volume still carrying the workspace’seph-<short_id>-name prefix, in case something exists that neither the file nor the recorded state knows about.
Dependency services vs the app
Most stacks split in two. Dependency services (databases, caches, queues, mail catchers) are stable, slow to warm up, and fine to leave running. The first-party app is what you restart constantly and want to control precisely.
eph lets you name that split. Tag each service with a role= (say, dep
and app) and declare the order with roles_order=dep,app, which reads “app
depends on dep”. You get two things:
- Start order follows the dependency graph.
eph upbrings thedeptier up and healthy first, then the app, so the app’sDATABASE_URLresolves the moment it starts. Teardown runs in reverse. - Tiers are addressable.
eph up --role depstarts just the dependency services, for example to prewarm databases from a coding agent’s session-start hook without launching the app.eph up --role appstarts the app and pulls its dependencies up with it.
Roles are opt-in. A file with no role= and no roles_order uses implicit
ordering: services start in declaration order with run= services last.
The full rules are in The .eph File, and
the prewarming workflow is in
Recipes.
Next
You have the model. The .eph File gives you the complete
format, or jump ahead to Defining Services for ready-to-use
service definitions.