Skip to content

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):

services/api/pilum.yaml
name: api
type: gcp-cloud-run
project: my-project-staging
region: us-central1
registry_name: services
template: 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: 50
Terminal window
pilum deploy --env=production --tag=v1.4.0

Merge rules

The selected environment is deep-merged onto the base config:

Value typeBehaviourExample above
ScalarThe override replaces the baseproject becomes my-project-prod
MapMerged key by key, so base keys you don’t override are keptenv_vars keeps FEATURE_X: "on" and sets LOG_LEVEL: info
ListThe override replaces the whole listregions, depends_on and cloud_run.cloudsql_instances are replaced, not appended to

Other rules:

  • No --env: the environments block is ignored and removed, and the base config is used as-is.
  • Services without an environments block ignore --env. Environment-agnostic services such as a Homebrew formula can sit next to environment-aware ones.
  • Unknown environment: if a service has an environments block but not the name you passed, discovery fails with environment "prod" not found (available: [production staging]). The whole command stops; nothing is deployed.
  • Applied before region expansion, so an environment can override regions.
  • secrets and env_vars merge 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.json or .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:

  1. pending when the run starts: Deploying N service(s)...
  2. success (Deployed N service(s) in 2m14s) or failure (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:

VariableSet by
GITHUB_ACTIONS=trueGitHub Actions, automatically
GITHUB_SHAGitHub Actions, automatically
GITHUB_REPOSITORYGitHub Actions, automatically
GITHUB_TOKENYou. 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:

.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):

Terminal window
pilum deploy --only-changed --since=<ref> --tag=$SHA

How changes are found:

  • Pilum diffs git merge-base <ref> HEAD against HEAD, 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 under watch:. The full rules are in Multi-Service Monorepo.
  • Without --since, the ref is the default branch, preferring origin/<default branch> (what actions/checkout fetches) over a local main or master.
  • 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 deploy and 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-changed warns and has no effect.

Choosing --since in CI matters:

TriggerUseWhy
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:

.github/workflows/release.yml
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.

InputDefaultDescription
versionlatestPilum release to install, e.g. v0.9.0
commanddeployPilum command to run
tagPassed as --tag
servicesSpace-separated services; empty means all
working-directory.Where to run Pilum
flagsExtra 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

.github/workflows/deploy.yml
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-status

Notes:

  • Tag with something unique per release. Without --tag, the image tag falls back to the service’s version:, then latest. gcp-mig-container rejects latest, 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/amd64 by default, whatever the runner’s architecture. Set build.platform for 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

.github/workflows/pr.yml
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