Skip to content

Local Development with Compose

pilum compose writes a docker-compose.yml for the services Pilum discovers. It can also add infrastructure such as Postgres or Redis and wire the connection strings into the services that need them. The services you deploy are the services you run locally, with no second hand-maintained compose file.

Quick start

Terminal window
# 1. Build the images locally (host build + docker build, no push)
pilum build
# 2. Generate docker-compose.yml for every service
pilum compose --all
# 3. Run the stack
docker compose up -d

pilum compose only writes the file. It doesn’t build or start anything, and the generated services reference images by name; there are no build: sections. Build the images first with pilum build (or pull them from your registry). The command’s summary suggests docker compose up --build, but --build has nothing to build.

A multi-region service appears once in the file, since local runs have no regions.

Choosing services

You must pass --all or name services. Without either, the command fails with specify --all to include all services or pass service names as arguments.

Terminal window
pilum compose --all # every service
pilum compose api worker # these two
pilum compose 'api-*' # glob
pilum compose --all --provider gcp # only GCP services
pilum compose --all --env staging # apply environments.staging first
pilum compose --all --no-deps # skip infrastructure dependencies

Services are discovered from the project root, even if you run the command from a subdirectory. The output file is written relative to where you run it.

Flags

FlagShortDefaultDescription
--allfalseInclude every discovered service
--registry-RImage prefix. Images become <registry>/<name>:<tag>
--tag-tlatestImage tag
--output-odocker-compose.ymlOutput path
--env-eApply an environments override before generating
--providerOnly services of this provider
--no-depsfalseLeave out compose.dependencies
--env-fileAlso write a .env template with service URLs (e.g. apps/web/.env.example)

compose is also available as docker-compose and dc.

What gets generated

For each service:

Compose keyValue
image--registry given: <registry>/<name>:<tag>. GCP service with project and region: <region>-docker.pkg.dev/<project>/<registry_name or project>/<name>:<tag>, the same image pilum build produces. Otherwise <registry_name>/<name>:<tag>, or <name>:<tag>
container_nameThe service name
restartunless-stopped (no for a migration service, see below)
ports<host port>:8080. Host ports are assigned from 8080 upward in alphabetical order of service name, unless the service sets a top-level port:
environmentEvery env_vars entry as written. Every secrets key as NAME=${NAME}, so Docker Compose fills it from your shell or a .env file. Secrets that a dependency provides get its connection string instead
networksapp-network (a bridge network shared by all services)
depends_onDependencies whose secrets the service uses, with condition: service_healthy

The container port is always 8080. Your service has to listen on 8080 inside the container, which is also the Cloud Run default.

The image name uses the service name, not image_name. If a service sets image_name, pass --registry or edit the generated file.

A few more keys in pilum.yaml are read only by compose:

KeyEffect
port (top level)Host port for the service instead of the alphabetical assignment
health_check.path, health_check.initial_delayAdds a healthcheck curl -f http://localhost:8080<path> (path default /health, start period default 5s)
runtime.memory, runtime.cpuBecome mem_limit (512Mi is written as 512m) and cpus

Two secret names get fixed development values instead of ${NAME}: JWT_SECRET becomes dev-jwt-secret-do-not-use-in-production and ADMIN_KEY becomes dev-admin-key.

Migration services

If a service’s name contains migration, it is treated as a one-shot job: its restart is no, and every other service gets depends_on: <migration>: condition: service_completed_successfully. Migrations finish before the services start. If several names contain migration, only the first alphabetically is used for ordering.

Infrastructure dependencies

Databases and caches are declared once for the project in .pilum.yml, under compose.dependencies. Pilum reads .pilum.yml from the directory you run it in.

.pilum.yml
compose:
dependencies:
- type: postgres
database: app
secrets: [DATABASE_URL] # services with this secret get the connection string
- type: redis
secrets: [REDIS_URL]
- type: custom
name: mailpit
image: axllent/mailpit:latest
port: 8025
healthcheck: wget -q --spider http://localhost:8025
FieldRequiredDescription
typeYespostgres, mysql, mariadb, redis, mongo or custom
nameNoCompose service name and hostname. Defaults to type
imageFor customOverrides the preset image
portNoPublished as <port>:<port>. Defaults to the preset’s port
user, password, databaseNoCredentials. Presets default to appuser / apppassword / appdb
healthcheckNoShell command for the healthcheck. Presets have one built in
environmentNoExtra environment variables (map)
secretsNoService secret names this dependency satisfies

Presets

TypeImagePortConnection string injected
postgrespostgres:15-alpine5432postgresql://<user>:<password>@<name>:<port>/<database>?sslmode=disable
mysqlmysql:8.03306mysql://<user>:<password>@<name>:<port>/<database>
mariadbmariadb:113306mysql://<user>:<password>@<name>:<port>/<database>
redisredis:7-alpine6379redis://<name>:<port>
mongomongo:727017mongodb://<user>:<password>@<name>:<port>/<database>
customyour imageyour portnone

Each preset also gets its environment variables (POSTGRES_USER, MYSQL_DATABASE and so on), a healthcheck (pg_isready, mysqladmin ping, redis-cli ping, …) checked every 10s with 5 retries, and a named volume <name>_data for its data. Postgres also mounts ./init-scripts into /docker-entrypoint-initdb.d, so SQL files there run on first start.

How secrets are wired

A service whose pilum.yaml lists a secret named in a dependency’s secrets gets two things:

  1. The secret’s environment variable is set to the dependency’s connection string instead of ${NAME}.
  2. A depends_on entry on the dependency with condition: service_healthy, so the service starts only after the database passes its healthcheck.
services/api/pilum.yaml
name: api
type: gcp-cloud-run
project: my-project
region: us-central1
registry_name: services
template: go
secrets:
DATABASE_URL: prod-db-url:latest # Secret Manager in production
REDIS_URL: prod-redis-url:latest

The generated compose entry for api, with the .pilum.yml above, contains:

api:
image: us-central1-docker.pkg.dev/my-project/services/api:latest
container_name: api
environment:
- DATABASE_URL=postgresql://appuser:apppassword@postgres:5432/app?sslmode=disable
- REDIS_URL=redis://redis:6379
ports:
- 8080:8080
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy

Frontend env template

--env-file writes a .env template with one URL per service, for a frontend that calls your APIs:

Terminal window
pilum compose --all --env-file apps/web/.env.example
apps/web/.env.example
VITE_API_API_URL=http://localhost:8080/api/v1
VITE_AUTHENTICATION_API_URL=http://localhost:8081/api/v1

The variable name is the service name in capitals, with - replaced by _ and a trailing _SERVICE removed (authentication-service becomes VITE_AUTHENTICATION_API_URL). Ports follow the same alphabetical assignment, so regenerate the file when services are added or removed.

Workflow

Terminal window
# After changing code: rebuild only what changed, then restart it
pilum build api
docker compose up -d api
# After adding or removing a service, or changing secrets/env_vars
pilum compose --all
docker compose up -d
# Tear down (add -v to delete database volumes)
docker compose down

Tips:

  • Regenerate rather than hand-editing. The file is derived from pilum.yaml and .pilum.yml, and the next pilum compose overwrites it. Commit it if your team runs it without Pilum, or add it to .gitignore if not.
  • Put values for ${NAME} secrets in a .env file next to docker-compose.yml. Docker Compose reads it automatically.
  • pilum build tags images with your --tag and also with :latest locally, so the default --tag latest in compose picks up the latest local build.
  • With --json, compose prints {file, services, ports}, which scripts can use to find each service’s port.

Next Steps