Docs

Troubleshooting

The gotchas that actually bite, and how to diagnose a service that will not start. When something is wrong, two commands tell you most of what you need:

eph check        # does the .eph file parse?
eph -v up        # start with verbose (debug) logging on stderr

“failed to connect to docker (is docker running?)”

eph talks to your local Docker daemon. Start Docker (Docker Desktop, Colima, systemctl start docker, whichever you use) and confirm with docker ps. On macOS and Windows, make sure the Docker Desktop VM has fully started, not just begun launching.

”no .eph file found in … or any parent directory”

You are not inside a workspace. eph searches the current directory and walks up to find a .eph file. Either cd into your project or create a .eph file. Confirm what eph resolves with eph info.

A service fails to become healthy

service postgres failed to become healthy within 30s

Causes, in rough order of likelihood:

  1. The health check uses shell features. For image and dockerfile services the health check runs inside the container without a shell: it is split on whitespace and exec’d directly. Pipes (|), &&, redirects, $VAR, and quoted arguments containing spaces do not work. Use one plain command, such as pg_isready -U dev or redis-cli ping. (For run and compose services the check runs through the platform shell, so shell syntax is fine there.)
  2. The check binary is not in the image. mysqladmin, mongosh, pg_isready, and curl must exist inside the container. Slim images may omit them.
  3. The service genuinely needs longer. Raise ready-timeout= (seconds).
  4. The service crashed on startup. Inspect its logs with eph logs <service>, which works for every service type and shows a run= service’s output even after it exited. For Docker-backed services you can also go straight to the daemon: docker logs eph-<short_id>-<service> (compose the name from eph info plus the service name, or find it with docker ps -a).

Run eph -v up to watch each health-check attempt and its exit code.

A property was rejected

Any typo’d or misplaced service property is a hard parse error; eph check catches it before any eph up. Two shapes show up in practice:

  • A lowercase typo is an unknown-property error listing every known property name:

    unknown service property 'prot' at line 5 (known properties: image,
    dockerfile, compose, run, role, command, port, port.<name>, expose.<name>,
    env.<KEY>, volume, pre-start, post-start, pre-stop, post-stop, healthcheck,
    ready-timeout, context)

    Property names are lowercase: image, port, env.X, healthcheck, post-start, and so on.

  • An UPPERCASE key inside a section (the classic HEALTHCHECK=... instead of env.HEALTHCHECK=..., or a top-level variable you meant to put after this service) is rejected rather than silently absorbed as a top-level variable:

    'HEALTHCHECK' at line 5 looks like an environment variable, but it is
    inside service 'postgres' (sections do not end at blank lines). To set it
    for the service, write env.HEALTHCHECK=...; to export it from `eph env`,
    move it into an [env] section or above the first section

    The error names both possible intents so you can pick the right fix: prefix it with env. if it belongs to the service, or move it into an [env] section (or above the first section) if it belongs to your shell. See Where to put top-level variables.

Something is “duplicate”

Nothing in a .eph file is silently merged or overwritten. A repeated declaration is a parse error naming both occurrences:

  • A reopened section ([db] appearing twice) does not merge the two blocks; the second [db] is rejected, naming the line the first one started on.
  • A repeated single-valued property (a second image=, healthcheck=, command=, ready-timeout=, or a second source under any spelling, such as image= followed by run=) is rejected. Hooks and volume= are the exception: they are designed to repeat and accumulate.
  • A repeated port or env. key (port= twice, the same port.<name>= twice, the same env.KEY= twice) is rejected; give each a distinct name instead.
  • A repeated top-level variable name is rejected, whether both declarations are above the first section, both inside [env] sections, or split across the two: the top-of-file block and every [env] section share one namespace.

Give the second occurrence a different name, or delete it if it was a leftover from editing.

An interpolation reference did not parse

${service.property} placeholders in a top-level variable or env.<KEY>= value are validated at parse time, before any eph up:

  • unterminated '${' ...: a ${ with no closing }. Close it, or if you meant a literal ${, escape it as $${.
  • invalid interpolation ... expected ${service.property}: the text inside ${...} has no ., so it cannot name a service and a property (for example ${name} instead of ${web.port}). Add the missing part, or escape it as $${ if it was never meant to be a placeholder.
  • unknown service '...' referenced from ...: the placeholder names a service that is not defined anywhere in the file. Check the spelling against the section header; a service defined later in the file resolves fine, so this is always a genuine typo or a missing section.

These are different from a port reference that does not resolve: that is a well-formed reference to a real service that is not running.

An inline comment broke a value

Comments must be on their own line. There are no trailing comments; a # after a value is part of the value:

port=5432   # this whole thing is the value, and fails to parse

Symptoms: invalid port number at line N, or an image name or URL that mysteriously has # ... appended. Move the comment to its own line above.

pre-start or post-start ran again and broke (or duplicated data)

pre-start and post-start hooks run on every eph up, including when a stopped container is restarted, not just on first creation. A hook that is not idempotent (a plain INSERT seed, a one-shot setup script) repeats its effect and may fail or duplicate rows on the second eph up.

Fixes:

  • Make the hook idempotent: a migration that no-ops when already applied, an INSERT ... ON CONFLICT DO NOTHING seed, CREATE TABLE IF NOT EXISTS, codegen that overwrites its output in place.
  • Move one-off or destructive work out of the hook and run it explicitly with eph run when you actually want it.

eph down or eph clean fails on a pre-stop or post-stop hook

A failing pre-stop hook aborts the teardown and leaves the service running, so the hook (a backup, a drain) can be fixed and retried rather than silently skipped. A failing post-stop hook also aborts the rest of the teardown, but its own service is already stopped, so re-running eph down will not run that post-stop again. If a broken hook is wedging teardown:

  • Fix the hook, then re-run eph down or eph clean. A pre-stop retries on the re-run; a post-stop whose service already stopped must be run by hand, for example with eph run.
  • Or skip the hooks for this teardown: eph down --skip-hooks or eph clean --skip-hooks.

A port reference did not resolve

eph check rejects unknown services, unknown properties, missing named ports, and ambiguous bare port references. A valid reference can still be unavailable at runtime when its service is stopped. In that case, eph env unsets the affected shell variable or omits the JSON key, warns on stderr, and exits non-zero. Other execution paths fail before launching a child.

  • The service is not running. Interpolation only resolves against running services. Run eph up first, and check with eph status.
  • The name is wrong. ${db.port} only resolves if the section is [db]; eph check reports this before runtime.
  • It is a multi-port service. ${minio.port} is rejected when a service declares several ports; use ${minio.port.api}. Compose mappings use expose.<alias>=<compose-service>:<port> and resolve as ${service.port.<alias>}.

A run= service is on the wrong port

With a numeric port=, a run= service is not port-remapped: the process binds whatever it binds, and eph reports the declared value verbatim for interpolation. Make the declared port match the port your process actually listens on, or switch to port=auto and let eph allocate and inject it (see Running Your App). If you use port=auto and the app still lands elsewhere, your framework is ignoring its injected PORT; enable its strict-port mode.

Stale state or “ghost” services

eph status reconciles saved state against the live Docker daemon and tracked PIDs, so a container you removed manually simply drops out of status. If state ever looks wrong, eph clean resets the workspace completely (removing containers, named volumes, and the state file), after which eph up rebuilds from scratch.

Renaming or deleting a service’s section from the .eph file does not orphan its container: a bare eph down and eph clean both also tear down whatever state.json remembers starting under a name no longer in the file. If you still find a container docker ps -a shows but eph status does not, run eph clean, which additionally sweeps any leftover container or volume carrying the workspace’s eph-<short_id>- prefix even if state does not know about it.

”state file … is corrupt”

If state.json cannot be parsed (a hand edit, disk corruption, damage from something outside eph), the next eph command logs a warning, moves the broken file aside to state.json.corrupt, and continues as if the workspace had no recorded state, rather than aborting. Containers and compose projects are found again from Docker by name on the next eph up or eph status; a run= service’s PID is the one thing this cannot recover, since the PID lived only in the corrupted file. Inspect the host process table and stop a leftover process by hand; eph status and docker ps cannot discover it.

”another eph command is running in this workspace; waiting for it”

eph up, eph down, and eph clean on one workspace exclude each other with an OS-level lock, so two overlapping runs never race the same state.json. This message just means a second command has to wait; it clears on its own once the first command finishes. If it never clears, the first command is still genuinely running (check eph -v up’s output or docker ps) rather than stuck: the lock is an OS file lock released automatically when the holding process exits, so a crashed or killed eph cannot leave a later command wedged.

docker compose down failed during eph down or eph clean

A compose service’s teardown failure, such as a missing docker compose plugin, is a real error and stops the rest of the teardown. eph tears down by recorded project name without rereading the Compose file: docker compose -p eph-<short_id>-<service> down reproduces the command. Fix the underlying problem and rerun eph down or eph clean. --skip-hooks does not help here; it only skips lifecycle hooks.

If the workspace directory itself was deleted, run eph system prune from anywhere. It scans all eph state directories and removes resources for recorded workspace paths that are missing or empty folders. Use eph system prune --dry-run first to see the plan. If a recorded path still contains files but you know the workspace can be discarded, preview it with eph system prune --force-non-empty --dry-run, then rerun with eph system prune --force-non-empty --yes. Add --force-live when the preview reports running containers or a live run= process. An 8-character state directory without workspace metadata is skipped unless you pass --compatibility-v042.

For run= services, system prune stops only a recorded PID whose live process still matches the identity eph captured at launch; PIDs can be reused, so a mismatch is skipped with a warning rather than killed. A command that deliberately detached grandchildren outside the shell tree eph launched can still leave processes behind after that tree exits; stop those manually.

run= teardown says the PID has no recorded process identity

A run= state entry without process identity cannot distinguish its PID from a reused PID. eph down, eph clean, and config reconciliation refuse to signal a live PID in that state. Inspect the process, stop it manually if it belongs to the workspace, and rerun the eph command. Every launch must capture a stable identity; startup stops the child and fails when capture is unavailable.

Windows

eph runs natively on Linux, macOS, and Windows. Docker-backed sources use the local Docker daemon and platform path conventions.

The shell-based features (run= services, lifecycle hooks, and run/ compose health checks) run through the platform shell: sh -c on Unix and cmd /C on Windows. Process liveness and teardown are native (no POSIX kill required), and teardown stops the whole process tree a run= command spawned, so a compound command’s children are not orphaned. The features work natively on Windows with no WSL.

What does not cross over automatically is the command string itself: a run=, hook, or health-check command written for sh (pipes, $VAR, &&, POSIX tools) may need a cmd-compatible rewrite on Windows (%VAR%, different builtins). Two ways to handle it:

  • Write the command for cmd, or call a cross-platform binary directly; many, like pg_isready and redis-cli, take the same arguments on both platforms.
  • Keep your POSIX command strings and run eph inside WSL, where it is a Linux process and sh is the shell.

Under WSL, eph is a Linux process, so its state directory is the Linux path (~/.local/share/eph/<short_id>/), not the Windows %LOCALAPPDATA% path. The %LOCALAPPDATA% location applies only to a native Windows build. (EPH_STATE_ROOT overrides either default; see Core Concepts.)

Relative bind mounts on Windows

A relative host bind mount (volume=./seed:/docker-entrypoint-initdb.d) resolves against the workspace root and is passed to Docker as a plain C:\... path. Absolute drive-letter paths (C:\..., C:/...) and UNC paths are accepted. Docker does not accept an extended-length \\?\... bind source; eph rejects that form before container creation. Move a workspace whose resolved path requires that prefix to a shorter path.

Getting more detail

eph -v <command> enables debug logging (to stderr). For a service’s own output, use eph logs <service> (add -f to follow). For Docker-level issues, drop to the docker CLI using the names from eph info:

docker ps -a | grep eph-<short_id>          # containers for this workspace
docker logs eph-<short_id>-<service>        # a service's logs (or: eph logs <service>)
docker volume ls | grep eph-<short_id>      # named volumes for this workspace

Next

The Command Reference has every command and flag in one place.