DOC_INDEX
THEME
DOCS/Example Cookbook/Modules Examples

Modules Examples

OrchStep examples for module usage, config, overrides, and nesting


basic module

Basic Module Usage

# Example: Basic Module Usage
# Import a reusable module and call its tasks from your workflow.
#
# Any directory with an orchstep.yml can be used as a module -- no
# special metadata file is required (implicit module). You reference
# a module by giving it a name and a source path.
#
# Then call its tasks with `module: <name>` and `task: <task>`.
#
# Try: orchstep run

name: basic-module-demo
desc: "Simple module usage with implicit modules"

# Import the greeting module from a local directory
modules:
  - name: greeter
    source: "./modules/greeting"
    # No config needed -- uses the module's defaults

tasks:
  main:
    desc: "Use the greeting module"
    steps:
      # Call the module's "greet" task
      - name: say_hello
        module: greeter
        task: greet

      # Access the module's step outputs
      - name: show_result
        func: shell
        do: |
          echo "Module returned: {{ steps.say_hello.greeting }}"
          echo "Recipient was:   {{ steps.say_hello.recipient }}"

      - name: explain
        func: shell
        do: |
          echo ""
          echo "=== Module Basics ==="
          echo "1. Create a directory with an orchstep.yml"
          echo "2. Import it:  modules: [{name: x, source: ./path}]"
          echo "3. Call tasks:  module: x, task: task_name"
          echo "4. Read outputs: steps.<step_name>.<output_name>"

module overrides

Module Overrides with with:

# Example: Module Overrides with `with:`
# After setting config at import time, you can further override
# values for a specific call using `with:`. This is useful when
# different steps need different settings from the same module.
#
# Precedence: with > config > module defaults
#
# Try: orchstep run

name: module-overrides-demo
desc: "Override module defaults with 'with:' for specific calls"

modules:
  - name: db
    source: "./modules/database"
    config:
      connection:
        host: "prod-db.example.com"
        port: 5432
        pool_size: 50
      operation:
        timeout: 60
        retry: 5

tasks:
  main:
    desc: "Show how 'with:' overrides config per call"
    steps:
      # Call 1: Use the import-time config as-is
      - name: normal_query
        desc: "Normal query uses config values"
        module: db
        task: query
        # pool_size=50, timeout=60 (from config)

      # Call 2: Override specific values for this call only
      - name: lightweight_query
        desc: "Quick query with reduced pool and timeout"
        module: db
        task: query
        with:
          connection:
            pool_size: 5         # Override for this call only
          operation:
            timeout: 10          # Fast timeout for quick queries

      # Call 3: Back to normal -- 'with' does not persist
      - name: another_normal_query
        desc: "Back to config defaults (with does not persist)"
        module: db
        task: query

      - name: compare
        func: shell
        do: |
          echo "=== Override Comparison ==="
          echo ""
          echo "Normal query:     pool={{ steps.normal_query.pool }}, timeout={{ steps.normal_query.timeout }}"
          echo "Lightweight:      pool={{ steps.lightweight_query.pool }}, timeout={{ steps.lightweight_query.timeout }}"
          echo "Back to normal:   pool={{ steps.another_normal_query.pool }}, timeout={{ steps.another_normal_query.timeout }}"
          echo ""
          echo "Precedence: with > config > module defaults"
          echo "'with:' only affects the step it is on."

module variaule scoping

Module Variable Scoping

# Example: Module Variable Scoping
# Modules are scope-isolated: they cannot see the consumer's variables
# and the consumer cannot see the module's internal variables.
#
# Data flows into a module through `config:` and `with:`.
# Data flows out through step outputs.
#
# If both the consumer and a module define the same variable name,
# each sees its own value -- no collision.
#
# Try: orchstep run

name: module-variable-scoping
desc: "How variables resolve in modules -- scope isolation"

defaults:
  app_name: "consumer-app"
  region: "us-east-1"

modules:
  - name: greeter
    source: "./modules/greeting"
    config:
      # These values are visible inside the module
      message: "Hello from config"
      recipient: "Production Team"

tasks:
  main:
    desc: "Demonstrate scope isolation between consumer and module"
    steps:
      # Consumer sees its own variables
      - name: consumer_vars
        desc: "Consumer's own variables"
        func: shell
        do: |
          echo "=== Consumer Scope ==="
          echo "app_name = {{ vars.app_name }}"
          echo "region   = {{ vars.region }}"

      # Module sees config values, not consumer variables
      - name: module_call
        desc: "Module sees config values, not consumer vars"
        module: greeter
        task: greet

      # Consumer can read module outputs but not module internals
      - name: read_outputs
        desc: "Consumer reads module outputs"
        func: shell
        do: |
          echo "=== Module Outputs ==="
          echo "greeting  = {{ steps.module_call.greeting }}"
          echo "recipient = {{ steps.module_call.recipient }}"

      # Override for a specific call
      - name: module_with_override
        desc: "Use 'with:' to override for this call"
        module: greeter
        task: greet
        with:
          message: "Overridden greeting"
          recipient: "Staging Team"

      - name: show_override
        func: shell
        do: |
          echo "=== With Override ==="
          echo "greeting = {{ steps.module_with_override.greeting }}"

      - name: summary
        func: shell
        do: |
          echo ""
          echo "=== Variable Scoping Rules ==="
          echo "  1. Module cannot see consumer variables"
          echo "  2. Consumer cannot see module internal variables"
          echo "  3. Data in:  config: and with:"
          echo "  4. Data out: step outputs"
          echo "  5. Same variable name in consumer and module = no collision"
          echo ""
          echo "  Precedence inside module:"
          echo "    with > config > task vars > module defaults"

module with config

Module Configuration

# Example: Module Configuration
# When importing a module, pass `config:` to override the module's
# default variable values. Config is applied at import time and
# affects all calls to that module.
#
# Try: orchstep run

name: module-with-config-demo
desc: "Module configuration overrides defaults"

# Import the service module with custom configuration
modules:
  - name: api
    source: "./modules/service"
    config:
      service:
        name: "payments-api"
        port: 9000
        replicas: 5
      features:
        cache: true
        logging: "debug"

tasks:
  main:
    desc: "Deploy and check the configured service"
    steps:
      # The module sees our config values, not its defaults
      - name: deploy_api
        module: api
        task: deploy

      - name: check_api
        module: api
        task: status

      - name: show_result
        func: shell
        do: |
          echo "Deployed: {{ steps.deploy_api.service_name }}"
          echo "Port:     {{ steps.deploy_api.service_port }}"
          echo "Replicas: {{ steps.deploy_api.replicas }}"
          echo ""
          echo "=== Module Config ==="
          echo "Module defaults:  name=default-service, port=8080, replicas=1"
          echo "Our config:       name=payments-api, port=9000, replicas=5"
          echo ""
          echo "Config overrides defaults at import time."

nested modules

Nested Modules

# Example: Nested Modules
# Modules can import other modules, creating a dependency chain.
# OrchStep resolves the full chain and executes bottom-up.
#
# In this example:
#   Consumer (this file)
#     -> app-stack module
#         -> database module (from the same modules/ directory)
#
# Each layer is scope-isolated. Config flows down through imports;
# outputs flow up through step outputs.
#
# Try: orchstep run

name: nested-modules-demo
desc: "Composing modules that use other modules"

# Import the top-level module (it imports its own dependencies)
modules:
  - name: stack
    source: "./modules/app-stack"
    config:
      region: "us-west-2"
      app_name: "order-service"
      app_version: "2.1.0"

tasks:
  main:
    desc: "Deploy a full application stack through nested modules"
    steps:
      # One call triggers the entire chain:
      # app-stack -> database -> (network if configured)
      - name: deploy_stack
        module: stack
        task: deploy

      - name: show_result
        func: shell
        do: |
          echo "=== Deployment Complete ==="
          echo "Deployment ID: {{ steps.deploy_stack.deployment_id }}"
          echo "App URL:       {{ steps.deploy_stack.app_url }}"
          echo "Status:        {{ steps.deploy_stack.status }}"

      - name: explain
        func: shell
        do: |
          echo ""
          echo "=== Module Nesting ==="
          echo "Execution chain:"
          echo "  1. Consumer calls app-stack.deploy"
          echo "  2. app-stack calls database.query"
          echo "  3. Each layer is scope-isolated"
          echo ""
          echo "Config flows DOWN through module imports."
          echo "Outputs flow UP through step outputs."
          echo ""
          echo "OrchStep tracks nesting depth and warns if"
          echo "the chain becomes too deep (> 3 levels)."

self contained module

Self-Contained Module

# Example: Self-Contained Module
# A module can be a single YAML file that contains everything:
# metadata, config schema, exports, and task implementations.
#
# This is ideal for simple, portable modules that you want to
# share without a directory structure.
#
# The module file: modules/deploy.yml
#
# Try: orchstep run
# Try: orchstep run deploy_with_custom_replicas

name: self-contained-module-demo
desc: "Module with full metadata, exports, and config schema in one file"

# Import a single-file module (direct file reference, not a directory)
modules:
  - name: deployer
    source: "./modules/deploy.yml"
    config:
      environment: "staging"
      registry: "my-registry.example.com"

tasks:
  # Basic deployment using module defaults
  deploy_basic:
    desc: "Deploy with default replicas"
    steps:
      - name: deploy_app
        module: deployer
        task: deploy
        with:
          app_name: "user-service"
          version: "v2.0.0"
          # replicas omitted -- uses module default (3)

      - name: show_result
        func: shell
        do: |
          echo "Deployment ID: {{ steps.deploy_app.deployment_id }}"
          echo "Status:        {{ steps.deploy_app.status }}"
          echo "Replicas:      {{ steps.deploy_app.replicas }}"

  # Deployment with all params specified
  deploy_with_custom_replicas:
    desc: "Deploy with custom replica count"
    steps:
      - name: deploy_scaled
        module: deployer
        task: deploy
        with:
          app_name: "order-service"
          version: "v3.5.1"
          replicas: 10

      - name: show_result
        func: shell
        do: |
          echo "App:      {{ steps.deploy_scaled.app_name }}"
          echo "Version:  {{ steps.deploy_scaled.version }}"
          echo "Replicas: {{ steps.deploy_scaled.replicas }}"

  main:
    desc: "Run both deployment scenarios"
    steps:
      - name: basic
        task: deploy_basic

      - name: custom
        task: deploy_with_custom_replicas

      - name: summary
        func: shell
        do: |
          echo ""
          echo "=== Self-Contained Modules ==="
          echo "A single .yml file can contain:"
          echo "  - metadata:  name, version, author, license"
          echo "  - config:    schema with validation rules"
          echo "  - exports:   public task interfaces with params/returns"
          echo "  - tasks:     the actual implementation"
          echo ""
          echo "Import with: source: ./path/to/module.yml"

configured instances (instance_profile)

A module can run as a self-contained configured instance that loads its OWN profile (environments/<name> + its dotenv: + env:) instead of being driven by the caller. Select the profile per call with instance_profile:; the same module can run as several independent instances in one workflow. See Using Modules.

# The module ./modules/billing ships:
#   defaults: { retries: 3 }
#   dotenv:   [billing.env]          -> STRIPE_KEY=sk_live_MODULE
#   env:      { BILLING_TIER: bundled-env }
#   environments/prod.yml  -> region: mod-prod, tier: gold
#   environments/dev.yml   -> region: mod-dev,  tier: bronze
name: deploy-billing
modules:
  - name: billing
    source: ./modules/billing
tasks:
  fanout:
    steps:
      # Same module, two configured instances - each loads its own profile.
      - { module: billing, task: report, instance_profile: prod }
      - { module: billing, task: report, instance_profile: dev }