Docs

The .eph File

The .eph file is the entire configuration for a workspace. It extends .env syntax with INI-style [sections] for services; a plain .env file is already a valid .eph file, and you add services on top. This page is the complete format. If you have not read Core Concepts, start there: this page tells you what you can write, that one tells you what it means.

Anatomy

# A top-level environment variable (exported by `eph env`)
APP_ENV=development

# A service definition
[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev

# A top-level variable after a service section goes in [env]
[env]
DATABASE_URL=postgres://dev@localhost:${postgres.port}/app

There are exactly two kinds of content:

  • Top-level environment variables: KEY=VALUE lines above the first section, or inside an [env] section. These are what eph env prints for your shell.
  • Service sections: a [name] header followed by property=value lines. These define what eph up starts.

Syntax rules

  • One directive per line.
  • key=value splits on the first =. Both sides are trimmed of surrounding whitespace.
  • A value may be wrapped in one matching pair of 'single' or "double" quotes, which are stripped. The pair is only stripped when it is unambiguous: the value is at least two characters, starts and ends with the same quote, and that quote does not occur again in between. "a" and "b" is left exactly as written rather than mangled to a" and "b. Quotes are only needed to preserve leading or trailing spaces; they are otherwise optional.
  • A leading UTF-8 byte-order mark (some Windows editors add one automatically) is ignored.
  • Within a section, property order does not matter, and interpolation refers to services by name, so a service can be referenced from anywhere in the file. Sections do not end at blank lines or comments: once a [section] opens, every following line belongs to it until the next [section], [env], or [roles_order] header. A bare top-level variable therefore cannot follow a service section directly; see Where to put top-level variables.

Comments

A comment is a line whose first non-whitespace character is #:

# This is a comment.
image=postgres:16-alpine

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

port=5432            # WRONG: the value is "5432            # WRONG..." and
                     # fails to parse as a port number

Write it as:

# the database port
port=5432

Environment variables

A top-level environment variable is a bare KEY=VALUE line, written either above the first section or inside an [env] section:

APP_ENV=development
DEBUG=true
LOG_LEVEL=info

These are the variables eph env emits for your shell to load. They may contain ${service.property} references (see Interpolation).

Do not confuse these with env.KEY= inside a service section. Top-level variables are emitted by eph env; env.KEY= configures that service’s container, Compose invocation, or host process. They are separate namespaces.

A top-level variable name must be a valid shell identifier: ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits, and underscores, not starting with a digit). Anything else would break the export NAME=... line eph env emits. The top-of-file block and every [env] section share one namespace, so declaring the same name twice anywhere is a duplicate-variable error, even across the two forms.

Where to put top-level variables

Sections do not end at blank lines, so once you are inside [postgres], a bare KEY=VALUE line is ambiguous: is it a new service property, or a variable meant for your shell? The parser does not guess. A bare top-level variable is legal in exactly two places:

  • Above the first section. Nothing has opened yet, so every KEY=VALUE line is a top-level variable.

  • Inside an [env] section. [env] is a reserved section name, never a service: every line inside it is a top-level variable, exactly like the top-of-file block. [env] may appear more than once; each occurrence switches back into variable context, so you can group a service’s variables near its own section:

    [postgres]
    image=postgres:16-alpine
    port=5432
    
    [env]
    DATABASE_URL=postgres://dev@localhost:${postgres.port}/app
    
    [redis]
    image=redis:7-alpine
    port=6379
    
    [env]
    REDIS_URL=redis://localhost:${redis.port}

A bare KEY=VALUE written directly after a service section, with no [env], is a hard parse error. The diagnostic names the open service and directs you to either prefix the key with env. for that service’s runtime or move the variable into an [env] section (or above the first section) for eph env.

The conventional layout is variables first, then services, then a trailing [env] section for anything that needs to reference a service:

APP_ENV=development

[postgres]
image=postgres:16-alpine
port=5432

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

A misspelled [env] ([envs], [vars], [variables], [environment]) is rejected with a hint pointing at [env] rather than being treated as an unknown, unclassified service.

Service sections

A service is a [bracketed] section followed by its properties:

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

Every service must declare a source: image, dockerfile, compose, or run. A section with no source is rejected at parse time, by eph check and before any eph up:

service 'redis' has no source defined (set one of image/dockerfile/compose/run)

Declare exactly one source. A section that declares a second one, whether the same key twice (image= twice) or two different keys (image= and run=), is a parse error naming the service and line. The four sources are covered in depth in Defining Services.

Service names must match ^[a-z][a-z0-9-]*$: lowercase letters, digits, and hyphens, starting with a letter. The rule is strict because a service name becomes three other things: a container name, the service half of a ${service.property} reference (a . would split at the wrong place), and the EPH_<NAME>_* metadata variables (allowing both - and _ would let auth-db and auth_db collide once both are upper-cased). [My-Service], [auth_db], and [1db] are all rejected.

Reopening a section name later in the file ([db][db] again) is a parse error naming both line numbers; sections are never silently merged.

Environment variable names must match ^[A-Za-z_][A-Za-z0-9_]*$. Names that start with EPH_, in any letter case, are reserved for eph’s workspace and service metadata and are rejected in top-level variables, [env], and env.<KEY>= properties.

Service properties

PropertyRepeatableDescription
image=noDocker image to pull and run.
dockerfile=noPath to a Dockerfile to build (relative to the workspace).
context=noBuild context for dockerfile= (defaults to the Dockerfile’s directory). Illegal with every other source.
compose=noPath to a Docker Compose file to delegate to.
run=noShell command for a non-Docker service.
role=noThe role (tier) this service belongs to; a free-form name you choose, such as dep or app. See Roles and ordering.
command=noOverride the container’s default command. Only legal for image=/dockerfile= services: a run= service’s command is its run= value, and a compose service’s command lives in the compose file, so command= there is a parse error.
port=no (one per service)A container port to publish on a random host port. For run= services, port=auto makes eph allocate the port (see Running Your App). Illegal on compose= services; use expose.<name>= there instead.
port.<name>=one per distinct nameA named port, for multi-port services. port.<name>=auto is allowed for run= services. Same restrictions as port=.
env.<KEY>=one per distinct keyAn environment variable passed to the service runtime. For Compose it is exported to the Compose CLI; for run= it is injected into the host process.
volume=yesA volume mount: name:/path (named) or ./host:/path (bind). Only legal for image= and dockerfile= services.
healthcheck=noCommand that must succeed before the service counts as ready.
ready-timeout=noNon-zero seconds to wait for the healthcheck (default 30; 60 for compose). Requires healthcheck=.
pre-start=yesHook run before the service is created.
post-start=yesHook run after every service in the up is healthy.
pre-stop=yesHook run before the service is stopped.
post-stop=yesHook run after the service has stopped.
expose.<alias>=one per distinct aliasFor compose=: expose <compose-service>:<container-port> for interpolation. The short form <container-port> targets the Compose service named by the alias. Illegal on every other source.

“Repeatable” means the property can appear multiple times and every value is kept; several post-start= lines run in order. Every non-repeatable property is single-valued: a second occurrence (even of a differently-named port.<name>= that repeats an already-used name) is a parse error naming the property, service, and line. Hooks and volume= are the only properties designed to repeat and accumulate.

An unknown property, of any case, is a parse error listing every known property name. There is no reclassification: a stray HEALTHCHECK=... inside a section is rejected with a hint to write env.HEALTHCHECK=... (to set it in the container) or move it into [env] (to export it from your shell), the same distinction as the top-level-variable rule above.

Every property except env.<KEY>= rejects an empty value: image=, volume=, healthcheck=, post-start=, and the rest are parse errors with nothing after the =. env.<KEY>= alone stays legal, since setting a container variable to the empty string is a real thing to want.

Ports

port= publishes a single container port:

[redis]
image=redis:7-alpine
port=6379

port.<name>= declares named ports for services that expose more than one:

[minio]
image=minio/minio
port.api=9000
port.console=9001
command=server /data --console-address ":9001"

Port names follow the same rule as service names (^[a-z][a-z0-9-]*$): they become part of ${service.port.<name>} interpolation and the EPH_<SERVICE>_PORT_<NAME> metadata variable.

Reference them as ${minio.port.api} and ${minio.port.console}. A service with exactly one port may use ${service.port}, whether that port was declared as port= or port.<name>=. A bare ${service.port} is a parse error when the service exposes zero or multiple ports; use a named reference for multi-port services.

port= and port.<name>= are only for services eph itself publishes: image=, dockerfile=, and run=. On a compose= service they are a parse error; declare expose.<name>= instead (see Defining Services).

Volumes

volume= accepts two forms, distinguished by the shape of the host part:

Volumes are supported only for image= and dockerfile= services. Compose volumes belong in the Compose file, and host processes use ordinary paths.

  • Named volume: a bare name that does not look like a path. Docker manages it, and eph prefixes it per workspace (eph-<short_id>-<service>-<name>). It survives down and down --rm and is removed by clean.

    volume=pgdata:/var/lib/postgresql/data
  • Bind mount: a path on your machine. Relative paths (starting with .) resolve from the workspace root. eph never deletes bind mounts. The host part counts as a path when it starts with . or /, or, on Windows, when it is a drive-letter path (C:\... or C:/...) or a UNC path (\\server\share\...).

    volume=./seed:/docker-entrypoint-initdb.d
    volume=/absolute/host/path:/data
    volume=C:\Users\me\data:/data

Health checks and timeouts

healthcheck= makes eph up wait until a command succeeds before reporting the service as started (and before any post-start hooks run):

[postgres]
image=postgres:16-alpine
healthcheck=pg_isready -U dev
ready-timeout=30

Where the command runs depends on the service type, and this matters:

  • For image and dockerfile services, the command runs inside the container via docker exec, split on whitespace. It is not run through a shell, so pipes, &&, redirects, $VAR expansion, and quoted arguments containing spaces do not work. Use one simple command: pg_isready -U dev, redis-cli ping.
  • For run and compose services, the command runs on the host through the platform shell (sh -c on Unix, cmd /C on Windows), so full shell syntax is available in that platform’s dialect.

If you omit healthcheck, eph waits a fixed 500 ms and assumes the service is ready. ready-timeout defaults to 30 seconds, or 60 for compose services. ready-timeout=0 and a timeout without a health check are parse errors.

Lifecycle hooks

Four hooks bracket a service’s life. This section is the authoritative reference for how they behave.

HookRunsTypical use
pre-start=before the service is createdcodegen, a generated config the service reads
post-start=after every service in the up is healthymigrations, seeding
pre-stop=before the service stopsbackup, drain
post-stop=after the service has stoppedcleanup eph cannot do itself

All four run on the host through the platform shell (sh -c on Unix, cmd /C on Windows), in the workspace root. Each is repeatable, and repeated hooks run in order:

[api]
run=./bin/server
port=auto
# Codegen the server needs to compile, before it boots.
pre-start=go generate ./...
post-start=./scripts/seed.sh
pre-stop=./scripts/drain.sh
# Tear down a scratch bucket eph never created and cannot clean up.
post-stop=./scripts/drop-scratch-bucket.sh

[postgres]
image=postgres:16-alpine
env.POSTGRES_USER=dev
healthcheck=pg_isready -U dev
post-start=psql "$DATABASE_URL" -f schema.sql
pre-stop=./scripts/backup.sh

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

Hook environment

Hooks run with eph’s resolved environment injected, layered in this order (later entries win where names collide):

  1. The resolved top-level .eph variables: what eph env prints, with ${service.port} filled in. $DATABASE_URL in the example above is already set; no eval needed. One difference during eph up: a hook also sees the reserved ports of the run= services being started, which eph env (a separate invocation resolving against running services) cannot show until they are up.
  2. EPH_* metadata variables: EPH_WORKSPACE_ID, EPH_WORKSPACE_ROOT, and EPH_CONTAINER_PREFIX, plus per service EPH_<SERVICE>_HOST, EPH_<SERVICE>_PORT, EPH_<SERVICE>_PORT_<NAME> (for named ports), and EPH_<SERVICE>_CONTAINER. Service names are upper-cased with - replaced by _, so auth-db becomes EPH_AUTH_DB_PORT.
  3. The owning service’s own env.X= values.

The same environment is available outside hooks via eph run, which runs an arbitrary command with these variables set.

Startup hooks: pre-start and post-start

  • Both run on every eph up, for every service, whether its container was freshly created or an existing one was restarted. Write them to be idempotent: a migration that no-ops when applied, an INSERT ... ON CONFLICT seed. For one-off or destructive work, use eph run instead of a hook.
  • pre-start runs before its service exists, but with the ports of the run= services this eph up is starting already decided: eph reserves a managed app’s port before any hook runs, so a variable derived from the app’s own port (APP_URL=http://localhost:${api.port}) resolves even in the app’s own pre-start. It also sees any service already up at that point: within a single eph up, backing services start before run= apps (or in role order), so an app’s pre-start can reach the database’s assigned port. The one thing it cannot reference is a container port Docker has not assigned yet, i.e. an image=/dockerfile=/compose= service later in the start order (its own included).
  • post-start hooks run in a second phase, only after every service in the up is healthy. A post-start hook can therefore reference any service’s assigned port, for example a migration whose DATABASE_URL interpolates ${postgres.port}.
  • A failure aborts the up. A failing pre-start aborts before its service starts; a failing post-start aborts the up at that point.

Teardown hooks: pre-stop and post-stop

  • A failing pre-stop leaves the service running and aborts the down or clean, so a backup or drain that fails is never silently skipped. Fix the hook and retry.
  • post-stop runs after the service has stopped, for cleanup eph cannot do itself. It sees the same pre-teardown environment as pre-stop, so it can still reference the now-stopped service’s port. A failing post-stop aborts the rest of the teardown, but its own service is already stopped, so a later eph down will not re-run it; fix the cleanup and run it by hand.

Skipping hooks

--skip-hooks on eph up skips pre-start and post-start; on eph down and eph clean it skips pre-stop and post-stop. It is the escape hatch for a broken hook that is wedging startup or teardown.

Roles and ordering

A role= tags a service with a tier, and roles_order declares the dependency graph over those tiers. The usual split is a dep tier of backing services (databases, caches, queues) that must be up before the app tier can talk to them. Naming the tiers also makes them addressable: eph up --role dep prewarms the backing services without starting the app. The model is in Core Concepts.

roles_order=dep,app

[postgres]
image=postgres:16-alpine
role=dep
port=5432

[web]
run=npm run dev
role=app
port=auto

Implicit ordering and role ordering

A file with no role= declarations and no roles_order uses implicit ordering. Services start in declaration order with run= services deferred to the end, and teardown reverses that order.

A file uses role ordering when any service declares a role= or a roles_order appears. Role ordering requires all of the following, checked at parse time (by eph check and before any eph up):

  • a roles_order is present (linear or DAG form);
  • every service declares a role;
  • every service’s role is listed in roles_order;
  • every role in roles_order is backed by at least one service;
  • every dependency edge names a known role;
  • the role graph is acyclic.

A violation is a hard parse error naming the offending service or role, so a half-specified graph never reaches eph up.

roles_order

“Depends on” means “must come up first”: if app depends on dep, then dep starts before app, and requesting app pulls dep in with it. Write the graph in one of two forms; declaring both is an error.

Linear form (a top-level key): a comma-separated chain where each role depends on the one before it.

roles_order=dep,app

This reads “app depends on dep”: dep comes up first, then app. Extend the chain (roles_order=dep,cache,app) when every tier depends on the whole tier before it.

DAG form (a reserved [roles_order] section): one role=dep1,dep2 line per role, spelling out each role’s dependencies explicitly. A bare role= (empty value) declares a root that depends on nothing. Every role must appear as a key, roots included:

[roles_order]
dep=
app=dep
worker=dep

Here app and worker both depend on dep but not on each other, so a worker that needs the database but not the app can start without it. Use the DAG form when a role needs some but not all of the others; use the linear form for a straight chain.

The [roles_order] section may appear anywhere in the file, including before the services it names. Every line inside it is a role edge (role names are free-form, so nothing is reinterpreted as an environment variable), so keep top-level variables outside the section. A misspelled section name ([role_order], [roles-order], [roles], and similar) is rejected with a hint pointing at [roles_order].

Ordering with roles

With roles, the role graph is the single source of truth for order. Bring-up follows the topological order of the graph (dependencies first), with services grouped by role and declaration order preserved within a role. Teardown is the exact reverse.

The implicit “run= services start last” heuristic does not apply. A run= service tagged as a dependency role comes up before the app that needs it, exactly where the graph places it. The role, not the source type, decides order.

Interpolation

Top-level environment variable values, and a service’s env.<KEY>= values, may reference other services:

[postgres]
image=postgres:16-alpine
port=5432

[minio]
image=minio/minio
port.api=9000

[env]
DATABASE_URL=postgres://localhost:${postgres.port}/db
S3_ENDPOINT=http://localhost:${minio.port.api}
HOST=${postgres.host}
SyntaxResolves to
${service.port}Assigned host port (single-port services)
${service.port.name}Named port
${service.host}localhost

Two different things happen at two different times:

  • At parse time, eph check (and every other command) validates the shape of every placeholder in a top-level variable or env.<KEY>= value: an unterminated ${ is an error, a placeholder that is not the two-part ${service.property} form is an error, and a placeholder naming a service that is not defined anywhere in the file is an error. A service defined later in the file is fine; the check runs after the whole file is read, so forward references work. A literal ${ that is not meant as a placeholder is written $${, which renders as ${ and is never validated as a reference:

    [env]
    TEMPLATE=cost: $${not.a.placeholder}
  • At runtime, eph env, hooks, eph run, and every service’s own env.<KEY>= values resolve each placeholder against currently running services. This is consistent across every source: an image= or dockerfile= service’s env.<KEY>= is resolved just before its container is created, a compose= service’s env.<KEY>= is resolved into the environment docker compose up and port discovery run with (so the compose file’s own ${VAR} substitution sees it too), and a run= service’s env.<KEY>= is resolved into the process it launches. For hooks, eph run, and every service’s own env.<KEY>=, every reference must resolve before eph launches the hook, command, process, container, or Compose invocation. A stopped dependency is an error naming the affected variable and reference.

    Shell output from eph env has one extra safety requirement: stale variables from an earlier successful evaluation must be cleared. It emits an unset for each unresolved variable, appends a shell failure statement, warns on stderr, and exits non-zero. JSON output omits unresolved variables and also exits non-zero. For example:

    warning: DATABASE_URL: ${postgres.port} is not resolvable while postgres is not running

    Run eph up and evaluate eph env again once the dependency is running.

    Resolved values are host-facing: ${service.port} is the port Docker published on the host’s loopback interface, and ${service.host} is always localhost as seen from the host. That is exactly right for a hook, eph run, or a run= process, all of which execute on the host. It is usually the wrong address for one container reaching another: a postgres container’s env.DATABASE_URL=...${redis.port} receives a literal localhost:PORT, but inside that container localhost means the container itself, not the host, so it will not reach redis. Reach a sibling service from inside a container through host.docker.internal (Docker Desktop) or by addressing the sibling on a shared Docker network by its container name and container port, not through eph’s interpolated, host-facing value.

run= and hook (pre-start/post-start/pre-stop/post-stop) command strings are shell commands, so ${VAR} there belongs to the shell rather than eph. Health checks preserve ordinary shell forms such as ${PORT} too, while recognizing, validating, and resolving dotted eph references such as ${api.port} before execution.

For a compose service, expose.<alias>=<compose-service>:<container-port> resolves as ${service.port.<alias>}. The short form expose.<alias>=<container-port> targets the Compose service whose name is the alias. Failure to query that exact mapping from Compose is a startup error.

Complete example

# =============================================================================
# Services
# =============================================================================

[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=app
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=myapp_dev
volume=pgdata:/var/lib/postgresql/data
healthcheck=pg_isready -U app
post-start=npm run db:migrate
post-start=npm run db:seed

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

[minio]
image=minio/minio
port.api=9000
port.console=9001
env.MINIO_ROOT_USER=dev
env.MINIO_ROOT_PASSWORD=devdevdev
command=server /data --console-address ":9001"
volume=minio-data:/data

[mailhog]
image=mailhog/mailhog
port.smtp=1025
port.web=8025

# =============================================================================
# Environment variables
# =============================================================================

[env]
DATABASE_URL=postgres://app:dev@localhost:${postgres.port}/myapp_dev
REDIS_URL=redis://localhost:${redis.port}
S3_ENDPOINT=http://localhost:${minio.port.api}
S3_ACCESS_KEY=dev
S3_SECRET_KEY=devdevdev
SMTP_HOST=localhost
SMTP_PORT=${mailhog.port.smtp}
MAIL_WEB_UI=http://localhost:${mailhog.port.web}
APP_ENV=development

A larger annotated example, including roles and a run= app, ships in the repository as example.eph.

Next

Defining Services goes deep on the four service sources and gives ready-to-use definitions for common services.