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.
  • Because secrets and env_vars are 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.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 with changes, plus every service that depends on a changed service (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 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 with depends_on on a service, or deploy explicitly.
  • Without --since, the ref is the default branch’s name as read from origin/HEAD (for example main), else main or master if that branch exists. The bare name is used, so a local branch of that name has to exist.
  • Outside a git repository, --only-changed warns and has no effect.

Choosing --since in CI matters:

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

InputDefaultDescription
versionlatestPilum release to install, e.g. v0.6.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
- 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. 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

.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 }}

Next Steps