Skip to content

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:

  1. Identity: name, description, provider and service
  2. Required fields: what a pilum.yaml must contain
  3. Optional fields: extra settings, with defaults that pilum init offers
  4. 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-run
description: Deploy to Google Cloud Run
provider: gcp
service: 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:

  1. By type. The service’s type: is looked up as a recipe key. type: gcp-cloud-run selects the gcp-cloud-run recipe.
  2. By provider, as a fallback. If type is missing or matches no key, Pilum uses the first recipe for the service’s provider, in alphabetical order of recipe file names.
  3. 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.

pilum.yaml
name: api
type: gcp-cloud-run # selects the recipe
project: my-project
region: us-central1
registry_name: services
template: go

Built-in Recipes

typeProviderWhat it doesSteps (tags)
gcp-cloud-rungcpBuild an image and deploy it as a Cloud Run servicebuild binary (build), build docker image (build), publish to registry (push), deploy to cloud run (deploy), route traffic to latest (deploy)
gcp-cloud-run-jobgcpBuild an image and deploy a Cloud Run job; optionally run itbuild binary (build), build docker image (build), publish to registry (push), deploy job (deploy), execute job (execute)
gcp-cloud-run-from-imagegcpDeploy a Cloud Run service from an existing image, without buildingdeploy from image (deploy), route traffic to latest (deploy)
gcp-artifact-registry-imagegcpBuild an image and push it to Artifact Registry; no deploybuild binary (build), build docker image (build), publish to registry (push)
gcp-mig-containergcpRoll a new container image onto an existing managed instance group with no downtimebuild binary (build), build docker image (build), publish to registry (push), create instance template (deploy), start rolling update (deploy), wait for stable (deploy)
azure-container-appsazureBuild an image, push it to ACR and deploy it to Azure Container Appsbuild binary (build), build docker image (build), publish to registry (push), deploy to container app (deploy)
aws-lambdaawsBuild and deploy a SAM applicationbuild (build), deploy (push, deploy)
cloudflare-pagescloudflareBuild a static site and deploy it to Cloudflare Pagesinstall dependencies (build), build site (build), deploy to pages (deploy)
homebrewhomebrewCross-compile a Go CLI and update a Homebrew tap’s formulabuild binaries (build), create archives (build), generate checksums (build), update formula (deploy), push to tap (deploy)
npmnpmBuild and publish an npm packageinstall 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

Terminal window
pilum recipes # list all built-in recipes
pilum recipes gcp-mig-container # fields, defaults and steps for one recipe
pilum recipes npm --json # the same, as JSON

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

  1. Finds every pilum.yaml from the current directory down.
  2. Checks that name and provider are set.
  3. Looks up the recipe by type, as described above.
  4. Checks that every required field without a default is 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 valid

On 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-tap

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

TypeDescriptionExample
stringText value"us-central1"
intInteger value10
booltrue or falsetrue
listArray 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

FieldTypeDefaultDescription
namestring-Step name. Without command, it selects the handler that generates the command
commandstring or list-Explicit command. A string runs through sh -c; a list runs directly with no shell
execution_modestringrootroot runs in the directory you ran pilum from; service_dir runs in the service’s directory
timeoutint--timeout (60)Seconds before the command is killed. Overrides --timeout
retriesint--retries (3)Extra attempts after a failure. Overrides --retries when greater than 0
tagslist-Labels used by --only-tags, --exclude-tags, the build/push/publish commands, waves and worker sizing
env_varsmap-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_runboolfalseRun this step even after an earlier step failed, like finally. Used for cleanup; the run still fails
build_flagsmap-Parsed but currently has no effect
debugboolfalseParsed but currently has no effect; use --debug

Execution mode

  • root: the working directory where you ran pilum. Run from the project root so paths such as ./_templates/<template> resolve.
  • service_dir: the directory containing the service’s pilum.yaml.

Tags

Tags decide which commands select a step and how it is scheduled:

TagSelected byOrderingWorkers
buildpilum build, pilum publishDependency wavesLimited by CPU cores and memory
pushpilum push, pilum publishFlat parallelUp to 16
deploypilum deploy onlyDependency wavesUp to 16
executepilum deploy, pilum publishDependency wavesUp 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.

Terminal window
# Only deploy steps; images must already exist
pilum deploy --only-tags deploy --tag v1.2.0
# Skip build steps
pilum deploy --exclude-tags build --tag v1.2.0

Handlers 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 nameGenerated command
build binaryRuns the service’s build.cmd through sh -c with build.env_vars. Skipped if build.cmd is empty
build docker imagedocker build -t <image>:<tag> [-t <image>:latest] --build-arg SERVICE_NAME=<service path> -f ./_templates/<template> . from the working directory
publish to registrydocker 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:

VariableValue
${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:

Terminal window
pilum deploy --recipe-path ./recipes --tag v1.2.0

A minimal custom recipe:

recipes/fly-app-recipe.yaml
name: fly-app
description: Deploy to Fly.io
provider: fly
service: 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]
services/web/pilum.yaml
name: web
type: fly-app
provider: fly # required: provider can't be inferred from a custom type

See Adding a Provider for handlers and command generators.

Next Steps