How Recipes Work
Recipes are the core of Pilum. A recipe is a YAML file that defines a deployment workflow as ordered steps, plus the fields a pilum.yaml must contain to use it. The ten built-in recipes are compiled into the pilum binary.
What is a Recipe?
A recipe has four parts:
- Identity:
name,description,providerandservice - Required fields: what a
pilum.yamlmust contain - Optional fields: extra settings, with defaults that
pilum initoffers - Steps: the commands to run, in order
This is the built-in gcp-cloud-run recipe, with most optional fields left out:
name: gcp-cloud-rundescription: Deploy to Google Cloud Runprovider: gcpservice: cloud-run
required_fields: - name: project description: GCP project ID type: string default: my-gcp-project - name: region description: GCP region to deploy to type: string default: us-central1 - name: name description: Service name (used for Cloud Run service and container naming) type: string - name: registry_name description: Artifact Registry repository name (bare name, e.g. "my-repo" — not a full host/path) type: string - name: template description: Dockerfile template name (path relative to _templates directory) type: string
optional_fields: - name: cloud_run.min_instances description: Minimum instances (0 for scale to zero) type: int default: "0" - name: cloud_run.memory description: Memory per instance (e.g., 512Mi, 1Gi) type: string default: "512Mi"
steps: - name: build binary execution_mode: service_dir timeout: 300 tags: [build]
- name: build docker image execution_mode: root timeout: 300 tags: [build]
- name: publish to registry execution_mode: root timeout: 120 tags: [push]
- name: deploy to cloud run execution_mode: root timeout: 180 tags: [deploy]
- name: route traffic to latest execution_mode: root timeout: 120 tags: [deploy]None of these steps has a command. Pilum generates each command from the step name and the service’s provider; see Handlers vs Explicit Commands.
How a Service Finds Its Recipe
Each recipe has a key, <provider>-<service>, built from its provider and service fields: gcp-cloud-run, gcp-mig-container, aws-lambda, and so on. A service picks its recipe like this:
- By
type. The service’stype:is looked up as a recipe key.type: gcp-cloud-runselects thegcp-cloud-runrecipe. - By
provider, as a fallback. Iftypeis missing or matches no key, Pilum uses the first recipe for the service’sprovider, in alphabetical order of recipe file names. - Otherwise the run fails with
service 'x' (recipe key 'k') has no matching recipe.
The fallback is why every pilum.yaml should set type. GCP has five recipes, and the first alphabetically is gcp-artifact-registry-image, so a service with provider: gcp and no type (or a misspelled one) only builds and pushes an image. It never deploys. pilum check prints a No recipe found for '<key>' warning in that case but still reports success.
homebrew and npm are matched through the fallback: their recipes declare service: package, so their keys are homebrew-package and npm-package, while services use type: homebrew and type: npm. Both work.
If provider is omitted, Pilum infers it from a built-in type (gcp-cloud-run → gcp). A custom recipe’s type is not recognized, so services using one must set provider too.
name: apitype: gcp-cloud-run # selects the recipeproject: my-projectregion: us-central1registry_name: servicestemplate: goBuilt-in Recipes
type | Provider | What it does | Steps (tags) |
|---|---|---|---|
gcp-cloud-run | gcp | Build an image and deploy it as a Cloud Run service | build binary (build), build docker image (build), publish to registry (push), deploy to cloud run (deploy), route traffic to latest (deploy) |
gcp-cloud-run-job | gcp | Build an image and deploy a Cloud Run job; optionally run it | build binary (build), build docker image (build), publish to registry (push), deploy job (deploy), execute job (execute) |
gcp-cloud-run-from-image | gcp | Deploy a Cloud Run service from an existing image, without building | deploy from image (deploy), route traffic to latest (deploy) |
gcp-artifact-registry-image | gcp | Build an image and push it to Artifact Registry; no deploy | build binary (build), build docker image (build), publish to registry (push) |
gcp-mig-container | gcp | Roll a new container image onto an existing managed instance group with no downtime | build binary (build), build docker image (build), publish to registry (push), create instance template (deploy), start rolling update (deploy), wait for stable (deploy) |
azure-container-apps | azure | Build an image, push it to ACR and deploy it to Azure Container Apps | build binary (build), build docker image (build), publish to registry (push), deploy to container app (deploy) |
aws-lambda | aws | Build and deploy a SAM application | build (build), deploy (push, deploy) |
cloudflare-pages | cloudflare | Build a static site and deploy it to Cloudflare Pages | install dependencies (build), build site (build), deploy to pages (deploy) |
homebrew | homebrew | Cross-compile a Go CLI and update a Homebrew tap’s formula | build binaries (build), create archives (build), generate checksums (build), update formula (deploy), push to tap (deploy) |
npm | npm | Build and publish an npm package | install dependencies (build), snapshot package manifest (build, deploy), set version (build), build package (build), resolve workspace dependencies (build), publish package (deploy), restore package manifest (build, deploy; always runs) |
Run pilum recipes <type> to see a recipe’s fields, defaults and step timeouts.
Exploring Recipes
pilum recipes # list all built-in recipespilum recipes gcp-mig-container # fields, defaults and steps for one recipepilum recipes npm --json # the same, as JSONpilum recipes and pilum check always use the built-in recipes, even if you deploy with --recipe-path.
Recipe-Driven Validation
pilum check validates each service without any provider-specific Go code:
- Finds every
pilum.yamlfrom the current directory down. - Checks that
nameandproviderare set. - Looks up the recipe by
type, as described above. - Checks that every required field without a
defaultis present.
● Checking configuration of the services Checking service api (recipe: gcp-cloud-run)✓ api: valid Checking service worker (recipe: gcp-cloud-run-job)✓ worker: valid✓ All services are validOn the first invalid service it stops with exit code 2:
✗ error checking service api: recipe 'gcp-cloud-run' requires field 'registry_name': Artifact Registry repository name (bare name, e.g. "my-repo" — not a full host/path)Required vs Optional Fields
Required fields
A required field must be present in pilum.yaml, with one exception: a required field that has a default is not enforced. For example, gcp-cloud-run gives project a default of my-gcp-project, so a service without project passes check.
The default is not applied at deploy time either. Recipe defaults are only used by pilum init to pre-fill the generated file, so a missing project is passed to gcloud as an empty value. Set every field your deploy needs.
Nested fields use dots: homebrew.tap_url means
homebrew: tap_url: https://github.com/org/homebrew-tapmig.location is a special case in gcp-mig-container: it is satisfied by either mig.zone or mig.region.
Optional fields
Optional fields are documentation and init prompts. They are not validated, and their defaults are not applied at deploy time: each command generator decides what to emit when a setting is missing.
Field types
| Type | Description | Example |
|---|---|---|
string | Text value | "us-central1" |
int | Integer value | 10 |
bool | true or false | true |
list | Array of values | ["a", "b"] |
The type is shown by pilum recipes and used by init. Validation only checks presence.
Steps
Steps run in order. For each step, every selected service that has that step runs it in parallel (or in dependency waves), and the next step starts when all of them finish.
steps: - name: build binary execution_mode: service_dir timeout: 300 tags: [build] env_vars: CGO_ENABLED: "0"
- name: sync assets command: aws s3 sync ./public s3://${project}-assets execution_mode: service_dir timeout: 300 retries: 2 tags: [deploy]Step options
| Field | Type | Default | Description |
|---|---|---|---|
name | string | - | Step name. Without command, it selects the handler that generates the command |
command | string or list | - | Explicit command. A string runs through sh -c; a list runs directly with no shell |
execution_mode | string | root | root runs in the directory you ran pilum from; service_dir runs in the service’s directory |
timeout | int | --timeout (60) | Seconds before the command is killed. Overrides --timeout |
retries | int | --retries (3) | Extra attempts after a failure. Overrides --retries when greater than 0 |
tags | list | - | Labels used by --only-tags, --exclude-tags, the build/push/publish commands, waves and worker sizing |
env_vars | map | - | Environment variables for the command. Added to Pilum’s own environment and the service’s build.env_vars; step values win. $VAR references in values are expanded |
always_run | bool | false | Run this step even after an earlier step failed, like finally. Used for cleanup; the run still fails |
build_flags | map | - | Parsed but currently has no effect |
debug | bool | false | Parsed but currently has no effect; use --debug |
Execution mode
root: the working directory where you ranpilum. Run from the project root so paths such as./_templates/<template>resolve.service_dir: the directory containing the service’spilum.yaml.
Tags
Tags decide which commands select a step and how it is scheduled:
| Tag | Selected by | Ordering | Workers |
|---|---|---|---|
build | pilum build, pilum publish | Dependency waves | Limited by CPU cores and memory |
push | pilum push, pilum publish | Flat parallel | Up to 16 |
deploy | pilum deploy only | Dependency waves | Up to 16 |
execute | pilum deploy, pilum publish | Dependency waves | Up to 16 |
pilum deploy runs every step regardless of tags. An untagged step is treated as CPU-bound, isn’t ordered by dependencies, and is skipped by any --only-tags filter, including the defaults of pilum build and pilum push. Tag every step in a custom recipe.
# Only deploy steps; images must already existpilum deploy --only-tags deploy --tag v1.2.0
# Skip build stepspilum deploy --exclude-tags build --tag v1.2.0Handlers vs Explicit Commands
Handlers
When a step has no command, Pilum looks up a handler by the exact step name (case-insensitive) and the service’s provider. A provider-specific handler wins over a generic one.
The generic handlers work for any provider:
| Step name | Generated command |
|---|---|
build binary | Runs the service’s build.cmd through sh -c with build.env_vars. Skipped if build.cmd is empty |
build docker image | docker build -t <image>:<tag> [-t <image>:latest] --build-arg SERVICE_NAME=<service path> -f ./_templates/<template> . from the working directory |
publish to registry | docker push <image>:<tag> |
The provider-specific handlers are the other step names in the built-in recipes table, such as deploy to cloud run (provider gcp) or publish package (provider npm).
Explicit commands
steps: - name: sync assets command: aws s3 sync ./public s3://${project}-assets execution_mode: service_dir tags: [deploy]
- name: notify command: ["curl", "-fsS", "-X", "POST", "https://hooks.example.com/deployed?svc=${name}&tag=${tag}"] tags: [deploy]These variables are replaced in command:
| Variable | Value |
|---|---|
${name}, ${service.name} | Service name |
${provider} | Service provider |
${region} | Service region (each region, for multi-region services) |
${project} | Service project |
${tag}, ${build.version} | The --tag value, which may be empty |
In a string command each value is shell-quoted. In a list command values are inserted as-is, and shell syntax such as | or && is passed literally. No other variables exist, and variables are not substituted in field default values.
Custom Recipes
--recipe-path <dir> loads every .yaml and .yml file in a directory, in file-name order:
pilum deploy --recipe-path ./recipes --tag v1.2.0A minimal custom recipe:
name: fly-appdescription: Deploy to Fly.ioprovider: flyservice: app
required_fields: - name: name description: Fly app name type: string
steps: - name: deploy command: ["flyctl", "deploy", "--app", "${name}", "--image-label", "${tag}"] execution_mode: service_dir timeout: 600 tags: [deploy]name: webtype: fly-appprovider: fly # required: provider can't be inferred from a custom typeSee Adding a Provider for handlers and command generators.
Next Steps
- Adding a Provider — Add new provider support to Pilum
- Service Configuration — Every
pilum.yamlfield - CLI Commands — Step tags, filtering and pipeline flags