Environments & CI
This guide covers running the same services against several environments and running Pilum from CI.
Environment Overrides
Put per-environment differences in an environments block in pilum.yaml, then select one with --env (-e):
name: apitype: gcp-cloud-runproject: my-project-stagingregion: us-central1registry_name: servicestemplate: go
build: language: go cmd: go build -o ./dist/app .
env_vars: LOG_LEVEL: debug FEATURE_X: "on"
secrets: DATABASE_URL: staging-db-url:latest
cloud_run: min_instances: 0 max_instances: 5
environments: production: project: my-project-prod env_vars: LOG_LEVEL: info secrets: DATABASE_URL: prod-db-url:latest cloud_run: min_instances: 2 max_instances: 50pilum deploy --env=production --tag=v1.4.0Merge rules
The selected environment is deep-merged onto the base config:
| Value type | Behaviour | Example above |
|---|---|---|
| Scalar | The override replaces the base | project becomes my-project-prod |
| Map | Merged key by key, so base keys you don’t override are kept | env_vars keeps FEATURE_X: "on" and sets LOG_LEVEL: info |
| List | The override replaces the whole list | regions, depends_on and cloud_run.cloudsql_instances are replaced, not appended to |
Other rules:
- No
--env: theenvironmentsblock is ignored and removed, and the base config is used as-is. - Services without an
environmentsblock ignore--env. Environment-agnostic services such as a Homebrew formula can sit next to environment-aware ones. - Unknown environment: if a service has an
environmentsblock but not the name you passed, discovery fails withenvironment "prod" not found (available: [production staging]). The whole command stops; nothing is deployed. - Applied before region expansion, so an environment can override
regions. - Because
secretsandenv_varsare maps, overrides merge by key. They must be maps (KEY: value) in both the base and the override.
--env works on deploy, build, publish, push, dry-run, check, list, status, logs, rollback and compose. Run pilum dry-run --env=production to see the merged result as real commands.
Deployment Locks
deploy, build, publish, push and rollback take a lock file at .pilum/deploy.lock in the project root before they start. A second run in the same checkout fails with:
deployment locked by PID 48211 on dev-laptop (started 2026-10-08T14:02:11Z, command: deploy, services: [api worker]). Use --force to override- Released when the run ends, including on Ctrl+C (SIGINT) and SIGTERM.
- Stale locks are cleared automatically: a lock older than 1 hour, or one held by a PID that is no longer running on the same host.
--force(-f) removes an existing lock and proceeds. Use it only when you know the other run is gone.- Dry runs don’t take the lock.
- The lock is only taken when Pilum finds a project root (a directory with
go.mod,package.json,Cargo.toml,tsconfig.json,cdk.jsonor.gitignore).
Add .pilum/ to .gitignore; it also holds history.jsonl.
GitHub Commit Status
With --github-status, Pilum posts a commit status to the commit being deployed:
pendingwhen the run starts:Deploying N service(s)...success(Deployed N service(s) in 2m14s) orfailure(Failed after 41s) when it ends.
The status context is pilum/<command>, for example pilum/deploy or pilum/dry-run. You can make it a required check in branch protection.
The flag is opt-in, despite its help text saying “auto-detected”. Pilum posts only when the flag is set and all of these environment variables are present:
| Variable | Set by |
|---|---|
GITHUB_ACTIONS=true | GitHub Actions, automatically |
GITHUB_SHA | GitHub Actions, automatically |
GITHUB_REPOSITORY | GitHub Actions, automatically |
GITHUB_TOKEN | You. Actions doesn’t export it to steps by default: add env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
The token needs the statuses: write permission. If anything is missing, Pilum skips the status silently, and a failed API call doesn’t fail the deploy.
Webhooks
Pilum can POST a JSON payload to any URL when a pipeline starts, succeeds or fails, which is useful for Slack-compatible incoming webhooks and chat notifications. Webhooks are configured in .pilum.yml:
webhooks: - url: https://hooks.slack.com/services/T000/B000/XXXX events: [success, failure]The payload has event, command, tag, services, duration, success, error and a ready-to-post text field. Webhooks fire for deploy, build, publish, push and dry-run, but not for rollback. A failed webhook prints a warning and never fails the run. See Project Configuration for the full format.
Deploying Only What Changed
--only-changed limits the run to services with changes, plus every service that depends on a changed service (directly or transitively, through depends_on):
pilum deploy --only-changed --since=<ref> --tag=$SHAHow changes are found:
- Pilum diffs
git merge-base <ref> HEADagainstHEAD, and adds uncommitted and untracked files. - A service counts as changed if any changed file is inside its directory (the one holding its
pilum.yaml). A change in a shared library outside every service directory does not mark any service as changed. Declare the dependency withdepends_onon a service, or deploy explicitly. - Without
--since, the ref is the default branch’s name as read fromorigin/HEAD(for examplemain), elsemainormasterif that branch exists. The bare name is used, so a local branch of that name has to exist. - Outside a git repository,
--only-changedwarns and has no effect.
Choosing --since in CI matters:
| Trigger | Use | Why |
|---|---|---|
Push to main | --since=${{ github.event.before }} | The default ref is main itself; the merge-base of main and HEAD is HEAD, so nothing would count as changed |
| Pull request | --since=origin/${{ github.base_ref }} | PR checkouts are detached, and a local main usually doesn’t exist |
Both need the full history: check out with fetch-depth: 0.
GitHub Actions
The official action, SID-Technologies/pilum-action, lives in its own repository. It installs a Pilum release and runs one command.
| Input | Default | Description |
|---|---|---|
version | latest | Pilum release to install, e.g. v0.6.0 |
command | deploy | Pilum command to run |
tag | Passed as --tag | |
services | Space-separated services; empty means all | |
working-directory | . | Where to run Pilum |
flags | Extra CLI flags, appended as-is |
The action only installs and runs Pilum. Cloud credentials, Docker registry login and language toolchains are set up by earlier steps.
Deploy on push to main
name: Deploy
on: push: branches: [main]
permissions: contents: read statuses: write # for --github-status id-token: write # for Workload Identity Federation
concurrency: group: deploy-production cancel-in-progress: false # never cancel a deploy halfway
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # --only-changed needs history
# Restores GOCACHE and the module cache, so the warm phase # takes about a second instead of compiling shared packages. - uses: actions/setup-go@v5 with: go-version-file: go.mod cache-dependency-path: "**/go.sum"
- uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} service_account: ${{ vars.GCP_DEPLOY_SA }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- uses: SID-Technologies/pilum-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: command: deploy tag: ${{ github.sha }} flags: >- --env=production --only-changed --since=${{ github.event.before }} --github-statusNotes:
- Tag with something unique per release. Without
--tag, the image tag falls back to the service’sversion:, thenlatest.gcp-mig-containerrejectslatest, and its template names (<base>-<tag>) are limited to 63 characters, so use a short SHA for MIG services. - Cache restore makes warming cheap. On a cold runner, warming compiles the shared dependencies once instead of once per service. On a restored cache it takes about a second. See Build Cache Warming.
- Rollback in CI needs
--yes, for example in a manually triggered workflow:command: rollback,services: api,flags: --yes. See Rollback.
Validate pull requests
name: Validate
on: pull_request
jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0
- uses: SID-Technologies/pilum-action@v1 with: command: check
- uses: SID-Technologies/pilum-action@v1 with: command: dry-run tag: pr-${{ github.event.pull_request.number }} flags: --env=production --only-changed --since=origin/${{ github.base_ref }}pilum check validates every pilum.yaml against its recipe. dry-run prints the exact commands a deploy would run. It needs no cloud credentials, skips the Docker preflight check, and doesn’t take the lock or write history.
Without the action
The action is a thin wrapper, so you can install Pilum yourself and call it directly:
- run: go install github.com/sid-technologies/pilum@latest- run: pilum deploy --tag=${{ github.sha }} --env=production --github-status env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Next Steps
- Rollback, Status, Logs & History — recovering from a bad deploy
- Build Cache Warming — fast builds on cold runners
- Project Configuration —
.pilum.yml, webhooks and compose dependencies - Multi-Service Monorepo —
depends_on, waves and service selection