Docs

Recipes

Practical, end-to-end setups. Each assumes you have read Core Concepts. The preview-server and watch-mode workflows live in Running Your App.

Migrating from Docker Compose

eph and Compose describe the same services; the mapping is mechanical.

docker-compose.yml.eph
services: { postgres: ... }[postgres]
image: postgres:16image=postgres:16
build: { context: ., dockerfile: X }dockerfile=X + context=.
ports: ["5432:5432"]port=5432 (the host port becomes automatic)
environment: { POSTGRES_USER: dev }env.POSTGRES_USER=dev
volumes: ["pgdata:/var/lib/..."]volume=pgdata:/var/lib/...
command: server /datacommand=server /data
healthcheck: { test: [...] }healthcheck=... (one command, no shell for image services)

Worked example. This Compose file:

services:
  postgres:
    image: postgres:16-alpine
    ports: ["5432:5432"]
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
volumes:
  pgdata:

becomes:

[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp
volume=pgdata:/var/lib/postgresql/data
healthcheck=pg_isready -U dev

[redis]
image=redis:7-alpine
port=6379
healthcheck=redis-cli ping

[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp
REDIS_URL=redis://localhost:${redis.port}

What you gain: the fixed host ports (5432:5432) become automatic, so multiple checkouts stop fighting over them, and services only run when you eph up.

If a subsystem is genuinely complex (many interdependent containers), do not translate it. Keep the Compose file and reference it with compose=; see Defining Services.

Seeding a database

Three approaches, by when you want the seed to run.

A post-start hook runs your project’s own migrate and seed commands on the host after the container is healthy, on every eph up. The hook already has eph’s resolved environment, so $DATABASE_URL is set without any eval:

[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp
healthcheck=pg_isready -U dev
post-start=npm run db:migrate
post-start=npm run db:seed

[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp

Because it runs on every eph up, keep these commands idempotent: a migration that no-ops when applied, an INSERT ... ON CONFLICT seed. A failing post-start aborts the up. For a destructive re-seed from scratch, recreate the data volume with eph clean && eph up, or run the seed on demand with eph run (next).

On demand with eph run, for a re-seed you repeat whenever you want. It gets the same environment ($DATABASE_URL, EPH_*) a post-start hook would:

eph run npm run db:migrate
eph run npm run db:seed
eph run psql "$DATABASE_URL" -f fixtures.sql   # $DATABASE_URL from your shell

Unlike a hook, this runs only when you invoke it, so it is the simplest way to reset data without recreating the container.

Init scripts via bind mount: the official Postgres and MySQL images run any *.sql or *.sh in /docker-entrypoint-initdb.d on first initialization of the data volume:

[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp
volume=pgdata:/var/lib/postgresql/data
volume=./db/init:/docker-entrypoint-initdb.d
healthcheck=pg_isready -U dev

These run only when the data volume is empty, so eph clean (which deletes the named volume) is how you trigger a re-seed.

Prewarm dependency services on Claude Code session start

When a coding agent opens a worktree, its dev services are not running, and the dependency tier (databases, caches) is the slow, known-good part: image pulls, migrations, seeds. Warm it once on session start and reuse it. A Claude Code SessionStart hook can bring up just the dependency tier and inject its connection env before the agent’s first command, without starting the first-party app (which could bind preview ports or cause side effects the agent did not ask for).

This needs a .eph file that uses roles: tag the backing services role=dep, the app role=app, and declare roles_order=dep,app.

roles_order=dep,app

[postgres]
image=postgres:16-alpine
role=dep
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp
healthcheck=pg_isready -U dev
post-start=npm run db:migrate

[web]
run=npm run dev
role=app
port=auto
env.PORT=${web.port}

[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp

The hook script runs eph up --role dep (which starts the dep tier and its dependency closure, never the app), then appends eph env to the file named by $CLAUDE_ENV_FILE. Claude Code sources that file, so subsequent Bash tool calls inherit DATABASE_URL and the rest:

#!/usr/bin/env bash
# .claude/hooks/eph-prewarm.sh
# SessionStart hook: prewarm dependency services and inject their env.
eph up --role dep >/dev/null 2>&1 || exit 0
if [ -n "$CLAUDE_ENV_FILE" ] && resolved_env=$(eph env); then
  printf '%s\n' "$resolved_env" >> "$CLAUDE_ENV_FILE"
fi

Make it executable (chmod +x .claude/hooks/eph-prewarm.sh) and wire it in .claude/settings.json, so everyone who opens the repo or a worktree of it gets the hook:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ]
      }
    ]
  }
}

How it behaves:

  • The app is left alone. --role dep resolves to the dependency role and its dependency closure only. The run= app never starts, so no preview port is bound and no app-side side effects fire on session start.
  • The tier is reused, not restarted. eph up is idempotent, so a later eph up or eph dev adopts the already-running dependency services. On exit, eph dev tears down only the app it started and leaves the prewarmed tier hot for the next session.
  • Seeding is included. The plain eph up --role dep runs each dependency’s post-start (migrations, seeds). Add --skip-hooks to prewarm without seeding.
  • Environment injection is all-or-nothing. eph env exits nonzero if any top-level variable references a service outside the prewarmed closure. The command substitution above discards that partial shell program. Keep exported prewarm variables dependent only on prewarmed services; put app-only values in the app’s env.* or load the full environment after the app starts.
  • No install command. Roles are names you choose, so there is deliberately no eph hooks install. Copy the recipe and substitute your dependency role name. For a personal version that follows you across repos, put the same SessionStart block in ~/.claude/settings.json instead of the project file.
  • Optional cleanup on exit. To stop the tier when a session ends rather than leaving it warm, add a SessionEnd hook running eph down --role dep. Leaving it running (the default here) is usually what you want, so the next session reuses it.

Bring up only one tier

Once a .eph file defines roles, --role starts or stops a tier and its closure without naming individual services:

eph up --role dep      # dependency services (plus anything they depend on), not the app
eph up --role app      # the app plus every role it depends on (here: dep, then app)
eph down --role dep    # stop the dep tier AND everything that depends on it

eph up --role resolves the dependency closure (the role plus what it needs below it); eph down --role resolves the dependent closure (the role plus everything above it that would break without it), torn down in reverse start order. Both combine with positional service names. This is what the prewarm hook above uses to start the backing tier alone.

Multiple checkouts side by side

This is what eph is built for. Clone the same repo twice:

git clone git@github.com:you/app.git app
git clone git@github.com:you/app.git app-experiment

Run eph up in each. Because the workspace ID is derived from the path, each checkout gets its own managed resource names, named volumes, and automatic ports. Fixed run= ports and Compose bindings still follow their declarations. eph status in either directory shows only that checkout’s services, and eph info shows the differing short IDs.

When you later delete a checkout (a finished worktree, an abandoned experiment), its containers and volumes do not die with the directory. Run eph system prune from anywhere to sweep up resources belonging to workspaces whose directory no longer exists.

Using eph in CI

eph works in any CI that provides Docker. The pattern:

# GitHub Actions (sketch)
steps:
  - uses: actions/checkout@v4
  - run: cargo install --path . # or download a release binary
  - run: eph up
  - run: |
      eval "$(eph env)"
      <your test command>
  - run: eph clean
    if: always()

Notes:

  • Each run: step is a fresh shell, so eval "$(eph env)" must be in the same step as the command that needs the variables (or export them to the CI environment file).
  • eph up blocks until health checks pass, so tests never start before the services are ready. No manual sleep.
  • Use eph clean in an always-run step to release resources.

Handling secrets

A .eph file can contain credentials (for example env.POSTGRES_PASSWORD), and eph env prints top-level variables to stdout. Treat the file accordingly:

  • Prefer throwaway, dev-only credentials. The whole point is ephemeral local services; they do not need real secrets.
  • If a .eph file must hold something sensitive, add it to .gitignore and commit a .eph.example template instead.
  • Never commit a .eph file containing real secrets.

Keeping data vs. starting fresh

You want to…Run
Pause for the day, keep everythingeph down
Free the containers, keep the dataeph down --rm
Re-run migrations or seeds against the running dataeph run <your migrate/seed cmd>
Wipe data and start completely cleaneph clean then eph up
Sweep up after deleted checkoutseph system prune

Next

Troubleshooting covers the failure modes you will eventually meet, and how to read what eph tells you.