Every service deployed by Vivarta that is buit into a container image and deployed to Kubernetes uses the same pipeline. There is a single archetype - containerized_deployment - that can configured for your service by creating a vivarta.yml next to its deployment manifests and filling in a handful of values. A static nginx site, a Spring Boot fat-jar, a Wanderland engine: same pipeline, different knobs. Once you've seen one, you can clone the next in about twenty minutes.
For the most part, creating a new pipeline is a matter of copy and change, and the goal of this tutorial is to show you how.
When you are don, you should be able to take a working vivarta.yml, understand which lines are structural (leave them alone upon pain of BOFH) and which are yours to change (and most importantly, why), and stand up a new pipeline without needing to know how the Wanderland engine actually works (it mostly creates a middleware chain and executes it in order with some neat signalling). We'll spend almost all our time in the two sections that actually matter — environments: and pipeline: — and on the one lovely bit of Wanderland syntax that makes it all work - !Environment.
Wanderland is a context engine that executes a feed forward pass through a chain of middleware stages it calls boundaries. At one end, input adapters greet the calling client and accept their capabilites which are stored the in context to frame the rest of the dicussion. Each boundary has access to the outputs of each that preceded it and writes a signed copy of its notes to the same context upon its completion. At the tail end, one or more formatters eaches back through the layers to the capabilities written by the client to ensure the output is appropriate for the caller's needs and the response is issued.
These boundary chains are organized as routes and a route and its arguments form a common dispatch shape that can be handled by Rack for web facing tools and Commander for the more intimate, CLI variety. In the CI/CD world, the same dispatch can be sent to RSpec with the engine's adapters (its bridge to the outside world) in mocked or VCR mode. A route can be defined as a simple chain of boundaries with their arguments in the YAML configuration presented to the engine and it will happily compile it. For more elaborate workflows, the chain can be written into the system as an archetype. These are the mad-libs of the workflow world, a series of pre-defined boundaries in a chain with placeholders to be compiled into the chain by the engine at boot time. Additionally, an injector system allows the workflow author or downstream implementer to inject additional boundaries into set points in the flow - mainly the beginning, end or interleaved interceptor style around each boundary.
When the route is called, the final chain is compiled and exceuted when presented with the dispatch negotiated by the inbound adapter.
Open a working one from any of our repositories and you'll find these top-level sections. Most of them you copy as is, environments and pipelines are generally the ones that need to be customized.
service: / port: — a name and a port for the engine process that runs the pipeline. Cosmetic for a deploy pipeline. Change service: to your service's name; leave port: alone (pick any free number, it only matters if two run at once in Rack mode).archetype: containerized_deployment — the recipe card. Never changes.storage: — three in-memory sqlite mounts the engine needs to boot. Copy verbatim, every time, without reading it.adapters: — the tools the pipeline shells out to: docker (build), ecr/sts (push to the registry), kubectl (apply). There may be slightly different sets of adapters in play dependig on whether you're deploying to AWS or Coreweave, try to find a starter pipeline that targets the same cloud as you.routes: — one route, /deploy, it's a POST and it consumes the containerized_deployment archetype. You can technically call it whatever you want, just be prepared to answer questions in standup if someone notices Jenkins running wanderland --type cli your-vivarta.yml scritch-the-kittens and is curiouswanderland_environment: — this uses the !Env resolver to read the WANDERLAND_ENVIRONMENT env var (defaults to dev) into the context to control which of the following environments definitions is selected at execution time.environments: — this one is you. this is where you configure the per-environment values that slot into your pipeline definition - things like the overlay selected or the URL to deploy behindpipeline: — this one too this is where you fill in the mad-lib chosen by the route's archetype. For example, the containerized_deployment pipeline exposes thunks for the build, image, and deploy stages. Each is expecting a single boundary (chain is a valid boundary if you need to do more than one thing, as are a variety of methods for spawning sub-flows) that is injected into its slot at boottime and filled at execution time.pipeline: describes what a deploy is for your service, independent of where it's going. It has four parts.
pipeline:
metadata:
name: advaita-datahub
image_name: !Environment image_name
permissions:
deployer_role_arn: !Environment permissions.deployer_role_arn
tags:
environment: !Environment kube_context
owner: you@advaitabio
sub_environment: bd
system: advaita-datahub
tier: advaita-ecs-workloads
stages:
collect:
args:
steps: !Environment collect_steps
image: { type: !Environment image_stage_actor }
deploy:
type: component
args:
output_root: /tmp/advaita-datahub-deploy
artifacts:
images:
datahub:
type: docker_build
args:
source_path: !Environment source_path
image_name: !Context for_boundary.chain.image.name
image_tag: !Context for_boundary.chain.datahub.short_sha
builder: buildx
docker_build_args: !Environment docker_build_args
components:
advaita-datahub:
type: k8s_provisioner
args:
kustomize_dir: !Environment kustomize_dir
name: <ecr>/advaita/drive
image: !Environment image_uri
namespace: !Environment namespace
deployment: deploy/advaita-drive
context: !Environment kube_context
load_restrictor: LoadRestrictionsNone
wait_timeout_s: 180
metadata: — the run's identity and the tags stamped onto everything. system: and tier: are yours to name. The rest you'll notice are !Environment — hold that thought.stages: — the three stages you can influence. collect gathers facts before the build (a git sha, or the currently-running image for a promotion). image builds and pushes. deploy applies to the cluster. Notice collect.steps and image.type are both !Environment — the stages themselves get reshaped per environment, which is the punchline of this whole tutorial.artifacts.images: — the one image this service builds. type: docker_build with builder: buildx means "build and push in one shot." You give it a source_path (where the Dockerfile lives) and it produces a registry-qualified ref the deploy stage consumes.components: — the thing(s) the deploy stage applies. For a single Kubernetes workload this is one k8s_provisioner: pin the image into a kustomize overlay, kubectl apply, wait for the rollout. Its args are the cluster coordinates — and again, the ones that vary are !Environment.That's the shape. It does not mention local, dev, or prod anywhere. It mentions slots where those environments will pour their values in.
environments: is a map from environment name to a block of values. There's a default: block for shared fallbacks, then one block per environment. Whatever !Environment <path> asks for in the pipeline: section gets looked up here, under the currently-active environment.
environments:
default:
docker_build_args: ["--provenance=false"]
source_path: !Env { name: DATAHUB_SOURCE_PATH, default: ../../../advaita-drive-service }
local:
kube_context: orbstack
namespace: datahub-local
kustomize_dir: k8s/overlays/local
image_name: <ecr>/advaita/drive
image_stage_actor: artifacts
collect_steps:
- type: git_hash
args: { key: datahub, path: !Environment source_path }
image_uri: !Context for_boundary.artifacts.image_ref
permissions:
deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads
dev:
kube_context: useast13-dev
namespace: datahub-dev
kustomize_dir: k8s/overlays/dev
docker_build_args: ["--platform", "linux/amd64", "--provenance=false"]
image_name: <ecr>/advaita/drive
image_stage_actor: artifacts
collect_steps:
- type: git_hash
args: { key: datahub, path: !Environment source_path }
image_uri: !Context for_boundary.artifacts.image_ref
permissions:
deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads
prod:
kube_context: useast13-prod
namespace: drive
kustomize_dir: k8s/overlays/prod
image_stage_actor: noop
collect_steps:
- type: k8s_live_image
args: { deployment: advaita-drive, namespace: drive-qa, context: useast13-dev }
image_name: !Context for_boundary.chain.image_name
image_uri: !Context for_boundary.chain.image_uri
permissions:
deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads-prod
Read it as a spreadsheet. Each environment is a column; each key is a row. When the pipeline asks for !Environment kube_context, it reads the kube_context row of the active column. Set WANDERLAND_ENVIRONMENT=dev and every !Environment in the pipeline: section resolves against the dev: block, with default: filling any gaps.
Here's the rule that makes the whole thing click, and it's worth saying plainly because it's the one idea you actually need:
The
pipeline:section holds what is the same across every environment (the shape). Theenvironments:section holds what differs (the values).!Environmentis the seam between them.
So the question "should this value be inline in pipeline:, or should it be !Environment pulled from environments:?" answers itself: does it change between local, dev, and prod? If no, write it inline. If yes, make it !Environment and put the per-environment values in the columns.
A hostname changes per environment → !Environment. The image tag's format (buildx, --provenance=false) is the same everywhere → inline. The kube context is different for every cluster → !Environment. The system: tag is the service's name and never changes → inline.
You could, in principle, hardcode three separate vivarta.yml files. The reason we don't is the same reason we don't copy-paste functions: one shape, one place to fix a bug, and the diff between environments is visible in one spreadsheet instead of smeared across three files.
This is where it gets genuinely useful, and it's the part people miss. !Environment doesn't only substitute scalars like hostnames. It can substitute the actor that runs a stage, the arguments to the build, and the entire set of things you deploy. The pipeline shape stays fixed; the environment column decides how that shape behaves.
Shaping the image build. Look at docker_build_args. Locally you build native (fast, runs on your laptop's OrbStack without emulation). For the CoreWeave clusters you must build linux/amd64. Same build stage, different flags, chosen by the column:
# environments.default
docker_build_args: ["--provenance=false"]
# environments.dev overrides it
docker_build_args: ["--platform", "linux/amd64", "--provenance=false"]
Shaping the image stage itself — build vs promote. This is the sharpest example. In dev you build a fresh image; in prod you don't rebuild at all — you promote the exact image dev is already running. Two different behaviours out of one image: stage, because the stage's actor is !Environment image_stage_actor:
dev sets image_stage_actor: artifacts → the image stage runs the docker_build under artifacts.images and produces a fresh ref.prod sets image_stage_actor: noop → the image stage does nothing. Instead, prod's collect stage runs a k8s_live_image step that reads the image dev is currently serving, and image_uri is sourced from that read rather than from a build.Notice both the actor (image_stage_actor) and the fact-gathering (collect_steps) and where the final ref comes from (image_uri) flip together, per column. The pipeline: section never learned the words "promote" or "build" — it just has slots, and the environment fills them to mean one or the other.
Shaping what you deploy. The components: map — the set of things the deploy stage applies — can itself be !Environment. The echo frontend does exactly this: locally it deploys one component (the workload); in dev and prod it deploys two — a CloudFormation Route53 DNS record and the workload, DNS first so the TLS challenge can resolve. The deploy stage iterates whatever the column hands it:
# pipeline.components is a single line:
components: !Environment components
# environments.local → { workload }
# environments.dev → { dns, workload }
So a mixed AWS-plus-Kubernetes deploy in dev collapses to a plain Kubernetes deploy locally, with no change to the pipeline shape — only the column differs. The same lever that swaps a hostname swaps whether you provision cloud DNS at all.
The mental move is always the same: find the thing that differs, replace it in pipeline: with !Environment <name>, and add <name> as a row in each environment column. It works for strings, for lists, for the name of the boundary that runs a stage, and for the whole deploy manifest.
Take a working vivarta.yml (the datahub or echo-backend one). Here is the full change list to retarget it at a new service. Nothing outside this list needs to move.
service: → your service's name. Because it names the engine process; keep it recognizable.pipeline.metadata.name / .tags.system → your service's name. Because these stamp the run and the resources; they're your service's identity.artifacts.images.<key> and the collect_steps git_hash key: → one short name for your image (they must match — the collect step stashes the sha under that key and the build reads it back). Because the pipeline threads facts between stages by this name.image_name (in each environment column) → your ECR repository, e.g. <account>.dkr.ecr.<region>.amazonaws.com/advaita/<repo>. Because that's where the image is pushed and pulled from.components.<key>.args.name → the same ECR image name your base Kubernetes manifest references. Because kustomize_set_image matches on this to know which image line to pin the freshly-built tag into. Get it wrong and the deploy applies the old tag silently.components.<key>.args.deployment → deploy/<your-deployment-name>. Because that's what the pipeline waits on for the rollout.source_path (in default:) → the path to the repo holding your Dockerfile. Because that's the build context. If the deploy config and the service source are separate repos, this reaches across to the sibling checkout.kube_context / namespace / kustomize_dir (per column) → your clusters, namespaces, and overlay directories. Because this is the entire "where does it land" story, and it's different per environment — which is exactly why they're columns.permissions.deployer_role_arn (per column) → the IAM role allowed to push to ECR and (if you provision cloud resources) touch them. Because prod usually assumes a stricter role than dev.If your service is a single container that just needs to run (like datahub — a Spring Boot jar behind a ClusterIP), you're done: one image, one k8s_provisioner component, and the columns. If it needs a public hostname with TLS, add a DNS component to the dev/prod columns as shown above. If it promotes rather than rebuilds in prod, wire the image_stage_actor: noop + k8s_live_image pair into the prod column.
One boundary worth drawing, because it saves confusion. The pipeline does the routine deploy: build, push, pin, apply, wait. It does not do first-time bootstrap — creating the namespace, materializing the image-pull secret, provisioning TLS certificate issuers, seeding application secrets, cutting the initial DNS record. Those are a one-time setup step (a setup.sh or a bootstrap drone) run once per cluster before the pipeline ever fires. The pipeline assumes the namespace and secrets already exist. Keep that split in your head and you won't try to make vivarta.yml do a job that isn't its.
From the directory holding your vivarta.yml:
# default environment (dev)
bundle exec wanderland --type cli vivarta.yml deploy
# any other column
WANDERLAND_ENVIRONMENT=prod bundle exec wanderland --type cli vivarta.yml deploy
Before you trust it, dry the resolution: --wanderland-render prints the fully-resolved config with every !Environment and !Env already substituted, so you can confirm the active column poured the right values into the shape without running a build. It's the fastest way to catch a column with a missing row.
k8s_provisioner, kustomize_set_image, k8s_live_image): [wanderland-dev-k8s-pack]