Defining Services
Every service declares a source: the thing eph starts. There are four:
| Source | Use it for |
|---|---|
image= | An existing Docker image (the common case). |
dockerfile= | A custom image you build from a local Dockerfile. |
compose= | A multi-container subsystem you already maintain as Compose. |
run= | A plain process on the host: your own app, a local binary. |
Declare exactly one per service. A section that declares a second one (the
same key twice, or two different keys) is a parse error. This page covers the
first three in depth and introduces run=; Running Your App
is the full story for first-party apps. The
common definitions at the end are ready to
paste.
image=: Docker image services
The common case: pull and run an existing image.
[postgres]
image=postgres:16-alpine
port=5432
env.POSTGRES_USER=dev
env.POSTGRES_PASSWORD=dev
env.POSTGRES_DB=app_dev
volume=pgdata:/var/lib/postgresql/data
healthcheck=pg_isready -U dev
eph pulls the image if it is not present, creates a container named
eph-<short_id>-postgres, publishes each port= on a random host port bound
to loopback, applies your env.* and volume= settings, and waits for the
healthcheck.
An env.<KEY>= value can reference another service with
${service.property}, resolved against whichever services are already
running at the moment this container is created, the same interpolation as a
top-level [env] variable. See
Interpolation for the full contract, including
why the resolved value (a host-facing localhost:PORT) usually is not the
right address for one container to reach another.
Use command= to override the image’s default command:
[minio]
image=minio/minio
port.api=9000
port.console=9001
command=server /data --console-address ":9001"
command= is parsed with shell-style word splitting (quotes are respected),
but it is not run through a shell; it replaces the container’s argv
directly.
dockerfile=: build a local image
When you need a custom image, build it from a Dockerfile. Paths are relative to the workspace root.
[worker]
dockerfile=./docker/worker.Dockerfile
context=.
port=8080
env.WORKER_THREADS=4
context=is the build context; if omitted, it defaults to the directory containing the Dockerfile.- The built image is tagged
eph-<short_id>-worker. eph invokes the build on everyupand relies on Docker’s layer cache, then fingerprints the resulting image ID. An effective Dockerfile or context change therefore recreates the service without making unchanged builds slow. - After building, the service behaves exactly like an
image=service: ports, env, volumes, health check, hooks.
Building shells out to the
dockerCLI, sodocker buildmust work in your environment.
Configuration reconciliation
Before reusing a service, eph compares a canonical fingerprint of the effective
runtime configuration with the record that created the existing backend. The
fingerprint covers the source, immutable image ID, ports, resolved environment,
volumes, health settings, build context, and command override. A run= service
also includes its final top-level and metadata environment, so a dependency’s
host-port change restarts dependents that consumed it.
When the fingerprint differs, eph tears down the recorded resource through its
backend type and creates the requested one. This applies to stopped resources
and to source changes such as run= to image=. A state record without a
fingerprint is reconciled rather than trusted. Reused services rerun their
declared health check, and a failed start is removed before up returns.
compose=: delegate to Docker Compose
For multi-container subsystems you already maintain as Compose (Kafka plus Zookeeper, an observability stack), delegate to the Compose file rather than translating it:
[kafka]
compose=./docker/kafka-compose.yml
expose.kafka=9092
expose.zookeeper=2181
ephrunsdocker compose -f <file> -p eph-<short_id>-kafka up -d, so the whole project is namespaced per workspace.expose.<alias>=<container_port>targets the Compose service named by the alias. Useexpose.<alias>=<compose-service>:<container_port>when the interpolation alias and Compose service name differ. The result is available as${kafka.port.<alias>}.ephasksdocker compose portfor that exact service and mapping; a missing or malformed result fails startup instead of inventing a host port.env.<KEY>=, with${service.property}references resolved against running services first, is exported into the process environmentdocker compose upand port discovery run with, so your compose file’s own${VAR}substitution can read it. Teardown uses only the recorded project name and does not reread the file or re-export service variables.- Compose services are tracked by
eph statusandeph env. Compose names its own containers, soephfinds the project by itscom.docker.compose.projectlabel rather than by container name. ready-timeoutdefaults to 60 seconds for compose services.
Requires the
docker composeCLI plugin.
How compose services differ
Compose support is intentionally thin: eph shells out to docker compose
and lets it own the container lifecycle. Three differences from the other
sources are worth knowing:
- Teardown is coarser. Both
eph downandeph down --rmrundocker compose ... down, which removes the compose containers either way.--rmmakes no difference for compose. eph cleandoes not remove Compose-internal volumes. Compose services cannot declare.ephvolume=entries. Volumes defined inside the Compose file belong todocker compose; rundocker compose ... down -vyourself if you need to drop them.- A failed
docker compose downis a real error. If the compose file is broken or thedocker composeplugin is missing,eph downandeph cleanstop and report it rather than treating it as success; fix the underlying problem and re-run.
run=: a process instead of a container
For services that are not containers (a locally installed binary, LocalStack, and above all your own app):
[localstack]
run=localstack start
port=4566
env.SERVICES=s3,sqs,dynamodb
healthcheck=curl -sf http://localhost:4566/_localstack/health
The essentials:
- The command runs through the platform shell (
sh -con Unix,cmd /Con Windows) in the workspace root, and its process is tracked in state. - Because eph launches it, the process inherits eph’s resolved environment:
the variables
eph envemits (likeDATABASE_URL), theEPH_*metadata, and the service’s ownenv.*values with${...}resolved. A managed app reaches the rest of the workspace without anyeval. - Fixed ports are not remapped. With a numeric
port=, the process binds whatever it binds, andephreports the declared value as-is for interpolation. Declare the port your process will actually use, or useport=autoto have eph allocate one and inject it. - The
healthcheck(if any) runs on the host through the platform shell with the same resolved environment the process gets, so a readiness check can reach an auto-allocated port:curl -sf http://localhost:$PORT/health. eph downstops the process gracefully, waits, then force-kills, and it targets the whole process tree the command spawned, so a compound command (run=build && serve, a pipeline, a backgrounded child) is torn down completely. eph captures process identity at launch and verifies it before any lifecycle command signals the recorded PID.- A matching process is reused by
eph up; its health check and selected startup hooks still run under the ordinaryupcontract.
run= services work natively on Linux, macOS, and Windows. On Windows the
command goes through cmd, so a command string written for sh may need a
cmd-compatible form, or run eph inside WSL to keep POSIX commands. See
Troubleshooting.
This source is how you put the app you are building under eph’s management:
port=auto, restart self-healing, eph dev, and preview servers are all
covered in Running Your App.
Multi-port services
A service can expose several named ports. Reference them by name:
[minio]
image=minio/minio
port.api=9000
port.console=9001
[env]
S3_ENDPOINT=http://localhost:${minio.port.api}
S3_CONSOLE=http://localhost:${minio.port.console}
For a single-port service use ${service.port}. For multi-port services
always use the named form.
Common service definitions
Copy these into your .eph file and adjust credentials and versions.
PostgreSQL
[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
[env]
DATABASE_URL=postgres://dev:dev@localhost:${postgres.port}/myapp
MySQL / MariaDB
[mysql]
image=mysql:8
port=3306
env.MYSQL_ROOT_PASSWORD=dev
env.MYSQL_DATABASE=myapp
env.MYSQL_USER=dev
env.MYSQL_PASSWORD=dev
volume=mysqldata:/var/lib/mysql
healthcheck=mysqladmin ping -h localhost
[env]
DATABASE_URL=mysql://dev:dev@localhost:${mysql.port}/myapp
Redis
[redis]
image=redis:7-alpine
port=6379
healthcheck=redis-cli ping
[env]
REDIS_URL=redis://localhost:${redis.port}
MongoDB
[mongo]
image=mongo:7
port=27017
env.MONGO_INITDB_ROOT_USERNAME=dev
env.MONGO_INITDB_ROOT_PASSWORD=dev
volume=mongodata:/data/db
healthcheck=mongosh --eval db.adminCommand(ping)
[env]
MONGO_URL=mongodb://dev:dev@localhost:${mongo.port}
Image health checks run without a shell, so keep them to one command with no quoted spaces.
mongosh --eval db.adminCommand(ping)works because it is plain whitespace-separated arguments.
MinIO (S3-compatible)
[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=miniodata:/data
[env]
S3_ENDPOINT=http://localhost:${minio.port.api}
S3_ACCESS_KEY=dev
S3_SECRET_KEY=devdevdev
MailHog (catch-all SMTP with a web UI)
[mailhog]
image=mailhog/mailhog
port.smtp=1025
port.web=8025
[env]
SMTP_HOST=localhost
SMTP_PORT=${mailhog.port.smtp}
MAIL_WEB_UI=http://localhost:${mailhog.port.web}
RabbitMQ
[rabbitmq]
image=rabbitmq:3-management
port.amqp=5672
port.ui=15672
env.RABBITMQ_DEFAULT_USER=dev
env.RABBITMQ_DEFAULT_PASS=dev
healthcheck=rabbitmq-diagnostics -q ping
[env]
AMQP_URL=amqp://dev:dev@localhost:${rabbitmq.port.amqp}
Next
Your backing services are defined. Running Your App brings the app you are actually building into the same workspace.