BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

What an OrchStep module really is

A module isn't a plugin or a magic import — it's a packaged, versioned kit of tasks. Here's the anatomy: a manifest that declares the contract, and a workflow that implements it.

Jul 9, 2026 OrchStep Team 6 minROLE: Backend DeveloperSCALE: Any

You've got a deploy sequence — build, push, roll out, announce — that three of your repos need. So you copy orchstep.yml into each one. Then the rollout step changes, and now you're editing the same fix in three places and hoping you didn't miss one.

A module is the fix for that. But it's worth being precise about what it is, because "module" is an overloaded word. An OrchStep module is not a plugin, not a binary, not a magic import. It's a packaged, versioned kit of tasks distributed over Git. You write the tasks once, tag a version, and import them by name — ^1.2.0, not "copy-paste and pray."

Anatomy of an OrchStep module: a manifest declaring metadata, permissions, config schema, and exports, alongside the workflow that implements them

Two files, two jobs

Every module is a directory with two files that do very different things:

orchstep-module.yml
metadata:
  name: slack-notify
  version: "1.1.0"
  description: "Post a release announcement to a chat channel"
  author: "your-org"
  tags: ["notify", "release", "chatops"]

permissions:           # what the module is allowed to do
  shell: true
  http:
    allow: []          # allowed host patterns for func: http ([] = none)
  git: false

config:                # config the consumer may pass
  schema:
    properties:
      channel:
        type: string
        required: false
        default: "#releases"
        desc: "Channel to post to"

exports:               # the public tasks consumers can call
  announce:
    desc: "Announce a release to the team channel"
    params:
      - name: service
        type: string
        required: true
      - name: version
        type: string
        required: false
        default: "latest"
    returns:
      type: object
      properties:
        message:
          type: string

The manifest (orchstep-module.yml) is the contract. It declares the module's identity under metadata:, what it's allowed to do under permissions:, the config it accepts under config.schema:, and — the important part — the tasks it exposes under exports:. A consumer reads exports: the way you'd read a function signature: here's the task name, here are its params, here's what it returns.

The workflow (orchstep.yml) is the implementation. It's a perfectly normal OrchStep workflow — the same tasks: and steps: you'd write anywhere. Its job is to actually do the thing the manifest promises. defaults: hold the config values; tasks set outputs: to satisfy the returns: declared in the manifest.

That split is the whole idea. The manifest is the public API; the workflow is the private body. A consumer depends on the manifest and never has to read the workflow.

Calling it is one block plus one step

Once a module is published (we'll cover that in a later post), consuming it looks like this. You import it in a top-level modules: block with a local alias, then call an exported task with the separate module: and task: keys:

modules:
  - name: notify                                      # local alias
    source: "github.com/orchstep/test-module-single"  # the published repo
    version: "^1.0.0"                                  # a semver constraint

tasks:
  release:
    steps:
      - name: tell_the_team
        module: notify        # the alias
        task: announce        # an EXPORTED task
        with:                 # task params
          service: "checkout"
          version: "2.4.0"

Three things to internalize, because they're the rules people trip on:

  • module: and task: are separate keys. A dotted task: notify.announce is invalid.
  • version: is a semver constraint^1.0.0, ~1.2.0, 1.x, or an exact pin — resolved from the repo's Git tags.
  • with: passes the task's params; a config: block on the import passes the module's config schema values.

What you gained over copy-paste

ConcernCopy-pasted YAMLA module
Fixing a bugedit every repo that copied itbump the version once, consumers pull it
Knowing what it doesread the whole workflowread exports: in the manifest
Upgradesmanual diff + mergea semver constraint (^1.2.0)
Blast radius"what depends on this?" is a grepthe manifest declares permissions:
Provenance"which copy is current?"a Git tag is the source of truth

The manifest is what makes a module more than a shared file. It turns "here's some YAML, good luck" into "here's a versioned contract." That's the difference between a snippet and a dependency.

When a module is overkill

Don't reach for a module the first time you write a task. If a sequence lives in exactly one repo and nothing else will ever call it, a plain orchstep.yml is the right answer — wrapping it in a manifest just adds a file you have to maintain. Modules earn their keep at the second consumer: the moment you're about to copy a task into another repo, that's the signal to package it instead. Until then, a normal workflow is simpler and you lose nothing.

And you don't need to publish anything to a registry to get most of the benefit. The lightest possible module is just a folder in your own repo — that's the next post in this series.

Where to go next

Got a deploy or notify sequence living in three repos? Package it once. Start with Modules Overview.

#MODULES#REUSE#VERSIONING#GETTING-STARTED
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY