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. secretsandenv_varsmerge by key only when both the base and the override use the map form (KEY: value). A list (- name: X) is a list, so it replaces the other side entirely.
--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 affected by changes, plus every service that depends on one of them (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 is affected by changes in its own directory, in Go packages it imports (traced with
go list), in the lockfile or manifest at its workspace root, in its Dockerfile template, and in any path listed underwatch:. The full rules are in Multi-Service Monorepo. - Without
--since, the ref is the default branch, preferringorigin/<default branch>(whatactions/checkoutfetches) over a localmainormaster. - If git can’t compare (a shallow clone without the base commit, or a ref that wasn’t fetched), Pilum warns, suggests
fetch-depth: 0, and deploys every service. It never skips a service because history was missing. - If nothing changed (since 0.10), Pilum prints
No services changed; nothing to deployand exits 0, even when the workflow names services explicitly. A release that only touched docs or CI succeeds without deploying anything. - Outside a git repository,
--only-changedwarns and has no effect.
Choosing --since in CI matters:
| Trigger | Use | Why |
|---|---|---|
| Release tag | --since=<previous release tag> | Deploys what changed since the last release, however many pushes that spans |
Push to main | --since=${{ github.event.before }} | The default ref is origin/main, which is HEAD on a push to main, so nothing would count as changed |
| Pull request | --since=origin/${{ github.base_ref }} | Compares against the branch the PR merges into |
All of them need the full history: check out with fetch-depth: 0. A shallow checkout doesn’t fail, but it deploys everything.
Deploy a release tag
To deploy only what changed between two releases, compare against the previous tag:
on: push: tags: ["v*"]
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # history and tags
# The previous tag, or the first commit on the first release. - id: prev run: | prev=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || git rev-list --max-parents=0 HEAD | head -1) echo "tag=$prev" >> "$GITHUB_OUTPUT"
# ... cloud auth and registry login ...
- uses: SID-Technologies/pilum-action@v1 with: command: deploy tag: ${{ github.ref_name }} flags: --only-changed --since=${{ steps.prev.outputs.tag }}Don’t let --since end up empty: Pilum would then compare against the default branch, which on a tag cut from main finds nothing to deploy.
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.9.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
# Only needed for build.docker_cache: a builder that can export # a registry layer cache. - uses: docker/setup-buildx-action@v3
- 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, or is skipped for Go. See Build Cache Warming.
- Docker layers can be cached in the registry. With
build.docker_cache: true(per service, or for every service in.pilum.yml), a fresh runner reuses unchanged image layers. It needs the buildx builder set up above; without one, Pilum warns and builds uncached. See Docker Layer Cache. - Images are built for
linux/amd64by default, whatever the runner’s architecture. Setbuild.platformfor ARM targets. - 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 }}Telemetry in CI
Pilum never prompts in CI. The GitHub Action turns telemetry on by default and says so in the job log; set telemetry: false on the action, or PILUM_TELEMETRY=0 in the job, to turn it off. Without the action, CI sends nothing unless PILUM_TELEMETRY=1 is set. DO_NOT_TRACK=1 always turns it off. See Telemetry.
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