Multi-Service Monorepo
Pilum is built for monorepos with many services. This guide covers project layout, service selection, dependency ordering, failure handling, parallelism and build cache warming.
Recommended Structure
my-project/├── .gitignore # also read by Pilum for discovery├── .pilumignore # exclude examples, fixtures, etc.├── .pilum.yml # optional project config (webhooks, compose)├── go.mod # or go.work, package.json + lockfile, ...├── _templates/ # Dockerfiles, shared by services│ └── go├── services/│ ├── api-gateway/│ │ ├── pilum.yaml # template: go│ │ └── main.go│ ├── user-service/│ │ ├── pilum.yaml│ │ └── main.go│ └── worker/│ ├── pilum.yaml│ └── main.go├── shared/ # shared libraries (no pilum.yaml)│ ├── auth/│ └── database/└── tools/ └── my-cli/ └── pilum.yaml # type: homebrewDockerfiles live in _templates/ at the project root, not in each service directory. A service picks one with template: <file>. Pilum runs docker build from the project root with -f ./_templates/<template> and passes the service’s directory as --build-arg SERVICE_NAME=<path>, so one Dockerfile serves every service in that language. pilum init scaffolds _templates/<language> for you.
name: api-gatewaytype: gcp-cloud-runproject: my-projectregion: us-central1registry_name: servicestemplate: go # → ./_templates/go
build: language: go cmd: go build -o ./dist/app . env_vars: CGO_ENABLED: "0" GOOS: linux GOARCH: amd64Always set type:, because it picks the recipe. With only provider: gcp, Pilum falls back to the first GCP recipe, which builds and pushes but never deploys.
Service Discovery
Pilum finds every pilum.yaml under the current directory, up to 4 levels deep, skipping paths matched by .gitignore and .pilumignore:
$ pilum listFound 4 services:
• api-gateway Provider: gcp Project: my-project Region: us-central1 Service:
• user-service Provider: gcp Project: my-project Region: us-central1 Service:
• worker ...Both flat (api/pilum.yaml) and nested (services/workers/email/pilum.yaml) layouts work, as long as each pilum.yaml is within the depth limit. Service names must be unique across the repo.
Selecting Services
Every pipeline command (deploy, build, publish, push, dry-run) runs on all services unless you narrow it:
# By name: space- or comma-separatedpilum deploy api-gateway worker --tag=v1.0.0pilum deploy api-gateway,worker --tag=v1.0.0
# By glob (quote it so the shell doesn't expand it)pilum deploy 'api-*' --tag=v1.0.0
# By providerpilum deploy --provider=gcp --tag=v1.0.0
# Only services with git changes, plus their dependentspilum deploy --only-changed --tag=v1.0.0pilum deploy --only-changed --since=v0.9.0 --tag=v1.0.0Filters are applied in order: names, then --provider, then --only-changed. A name that matches nothing prints a warning with a suggestion. If you named services and none matched, the command fails.
Only changed services
--only-changed keeps services with changed files in their own directory since --since, plus uncommitted and untracked files. The default --since is the default branch (main or master). Then it adds every service that depends on one of those, transitively:
$ pilum deploy --only-changed --tag=v1.0.0 --debugDEBUG: Service database has direct changesDEBUG: Service api included (depends on changed service)● Found 2 service(s) with changes (1 direct, 1 via dependencies)A change in shared/, outside every service directory, doesn’t mark any service as changed. If services must redeploy when shared code changes, deploy them explicitly. For the right --since value in CI, see Environments & CI.
Service Dependencies
Use depends_on to order services:
name: apitype: gcp-cloud-runproject: my-projectregion: us-central1registry_name: servicestemplate: gobuild: language: go cmd: go build -o ./dist/app .depends_on: - database-migrationsname: worker# ...depends_on: - api - database-migrationsPilum builds a dependency graph and groups services into waves by depth. Services in a wave run in parallel, and waves run one after another. Waves apply to every step tagged build, deploy or execute. Push steps (publish to registry) always run flat in parallel, because pushing an image doesn’t depend on another service.
━━━ Step 4/5: deploy to cloud run ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Wave 1/3: ✓ database-migrations 3.2s Wave 2/3: ✓ api 2.9s Wave 3/3: ✓ worker 3.1sWaves are used only when at least one selected service declares depends_on. --no-deps turns them off and runs every step flat.
If you deploy a subset whose dependencies aren’t selected, those dependencies are not built or updated. The summary says so:
⚠ 1 dependency NOT built or updated (not part of this run): worker → api Include them explicitly if you expected a fresh build.Failure handling
Failures are handled at two levels:
-
Within a step, a failure skips only that service’s dependents in later waves. Independent services in the same step run to completion:
━━━ Step 1/5: build binary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Wave 1/2:✗ database-migrations✓ billingWave 2/2:○ api (dependency database-migrations failed)○ worker (dependency database-migrations failed) -
Across steps, once any step has failed, Pilum skips all remaining steps for every service. Only steps marked
always_runin the recipe still run, such as the npm recipe’s manifest restore. In the example above,billingbuilt successfully but is not pushed or deployed in this run.
So a monorepo deploy is all-or-nothing per step: if one service’s build fails, nothing reaches the deploy step. The command exits non-zero and reports the error from the failed step.
Circular dependencies
pilum check does not detect cycles. At deploy time Pilum prints:
✗ Dependency order could not be resolved: circular dependency detected involving: [api worker]✗ Deploying in declaration order — dependent services may run before what they depend on.Any step that uses waves then fails with the same cycle error. Run pilum dry-run after changing depends_on to catch this before a real deploy.
Parallel Execution
Pilum sizes the worker pool per step:
- Build steps are limited by CPU and memory. Workers = cores ÷ cores per build (default 2), capped by 75% of RAM ÷ the heaviest language’s build memory. Each build gets
GOMAXPROCS= cores ÷ workers. Docker build steps are sized from the Docker daemon (on Docker Desktop, the VM). - Push, deploy and execute steps mostly wait on the network and get one worker per service, up to 16.
pilum deploy --tag=v1.0.0 # automatic sizingpilum deploy --max-workers=2 --tag=v1.0.0 # at most 2 at a time, every steppilum deploy --max-workers=1 --tag=v1.0.0 # one at a timeSet build.resources.cpu and build.resources.memory on services that are heavier than their language default. Details are in Build Cache Warming → Parallelism.
Build Cache Warming
When many services share dependencies, parallel builds on a cold cache each compile the same packages. Before the first build step, Pilum runs one warm command per toolchain and workspace, for example go build over all the Go services’ packages, or pnpm fetch in a temporary copy of the lockfile. The builds then only compile their own code.
━━━ Warming build caches ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✓ go (19 services, auto) 60.1sOn Torch’s 19 Go services, this cut a cold-cache build from 490s to 69s. Warming is automatic for Go, Node, Python, Rust, JVM, .NET and PHP services that have a build.cmd, when two or more share a workspace. It is skipped when no build step runs. Turn it off with --no-warm or build.warm: false. See Build Cache Warming.
Mixed Providers
A single monorepo can deploy to different providers. Each service declares its own type:
name: apitype: gcp-cloud-runproject: my-gcp-projectregion: us-central1registry_name: servicestemplate: gobuild: language: go cmd: go build -o ./dist/app .name: lambda-handler # also the CloudFormation stack nametype: aws-lambdaproject: my-projectregion: us-east-1# Runtime, memory and timeout live in the SAM template (template.yaml) in this directoryname: my-clitype: homebrewdescription: My command-line toollicense: Apache-2.0homebrew: project_url: https://github.com/my-org/my-cli tap_url: https://github.com/my-org/homebrew-tap token_env: GH_TOKENbuild: language: go version_var: github.com/my-org/my-cli/cmd.versionDeploy them all together:
pilum deploy --tag=v1.0.0Pilum runs step 1 of every service’s recipe, then step 2, and so on. A service whose recipe has fewer steps has nothing to do in the later ones.
Multi-Region Services
A service can deploy to several regions:
name: global-apitype: gcp-cloud-runproject: my-projectregistry_name: servicestemplate: goregions: - us-central1 - europe-west1 - asia-east1build: language: go cmd: go build -o ./dist/app .Pilum expands this into one instance per region:
$ pilum listFound 3 services:
• global-api (us-central1) Provider: gcp Project: my-project Service:
• global-api (europe-west1) ...# All regionspilum deploy global-api --tag=v1.0.0
# One regionpilum deploy "global-api (us-central1)" --tag=v1.0.0Excluding Services
Use .pilumignore to exclude directories:
# Development onlyexamplesfixturestest-*
# Archived servicesarchived/*-deprecated.gitignore is read too (disable that with --no-gitignore). See Ignore Files for the full syntax.
Naming Conventions
Consistent names make globs and filters useful:
| Pattern | Example | Select with |
|---|---|---|
{domain}-{type} | user-api, user-worker | 'user-*' |
{type}-{name} | api-gateway, worker-email | 'worker-*' |
Don’t put environments in names (prod-api, staging-api). Use one service with an environments block and --env instead. See Environments & CI.
CI/CD Tips
# Push to main: deploy what changed since the previous push- uses: actions/checkout@v4 with: fetch-depth: 0- uses: SID-Technologies/pilum-action@v1 with: command: deploy tag: ${{ github.sha }} flags: --only-changed --since=${{ github.event.before }}# Pull requests: validate and preview- run: pilum check- run: pilum dry-run --only-changed --since=origin/${{ github.base_ref }} --tag=pr-${{ github.event.pull_request.number }}Restore the language cache (for example actions/setup-go with caching on) so the warm phase is near-instant on CI runners. A complete workflow is in Environments & CI.
Next Steps
- Build Cache Warming — warming and worker sizing in detail
- Environments & CI —
--env, locks and GitHub Actions - Ignore Files — exclude directories
- Service Configuration — every
pilum.yamlfield - CLI Commands — all commands and flags