Docs

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
ResourceName
Containereph-<short_id>-<service>
Named volumeeph-<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 env fill 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:

ReferenceResolves 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:

PlatformLocation
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:

  1. Already running and matching: the service is reused, then any declared health check is rerun.
  2. Stopped but still present and matching (after eph down): the existing resource is restarted and checked. Fast, and the data is still there.
  3. Not present: a fresh container is created, pulling or building the image if needed.
  4. 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= to image=.

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-start and post-start run on every eph up, not only on fresh creation. Write them to be idempotent: a migration that no-ops when applied, an INSERT ... ON CONFLICT seed. For one-off work, use eph run instead.
  • A failing hook aborts the command rather than being skipped silently. --skip-hooks is the escape hatch on up, down, and clean.

Three levels of teardown

CommandStopsRemoves containerRemoves named volumes (data)Removes state
eph downyesnonoclears entries
eph down --rmyesyesnoclears entries
eph cleanyesyesyesdeletes 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 ./seed or C:\data) are never deleted by eph clean. Only Docker named volumes are.
  • compose services are torn down with docker compose down in every case, so --rm makes no difference for them. Compose services cannot declare .eph volumes, and eph clean does not remove volumes defined inside the Compose file. See Defining Services.
  • Teardown works from recorded state, not just the sections currently in your .eph file. A bare eph down (no service names) and eph clean both also stop and remove anything state.json remembers 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 targeted eph 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 clean additionally 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’s eph-<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 up brings the dep tier up and healthy first, then the app, so the app’s DATABASE_URL resolves the moment it starts. Teardown runs in reverse.
  • Tiers are addressable. eph up --role dep starts just the dependency services, for example to prewarm databases from a coding agent’s session-start hook without launching the app. eph up --role app starts 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.