BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Render config files from templates

One template, one source of truth, a config file per environment. func: render turns variables into nginx.conf, app.ini, or any text file — no envsubst, no sed, no copy-pasted near-duplicates.

Jun 10, 2026 OrchStep Team 6 minROLE: Backend DeveloperSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/render-config
VIEW SOURCE

Every backend has the config files that come in pairs: app.staging.ini and app.production.ini, identical except for three values, and drifting apart one hotfix at a time. Someone bumps replicas in production and forgets staging. Six months later nobody knows which differences are intentional and which are rot.

The usual fixes are worse than the disease. envsubst < template > out works until a value needs a default or a conditional. sed -i "s/__REPLICAS__/6/" works until a placeholder appears twice. Both turn "render a config" into string surgery.

OrchStep has a function for exactly this: func: render. One template, your variables, and the engine's full template language (Sprig pipes plus OrchStep helpers). Loop it over a list of targets and you get one rendered config per environment from a single source.

One template, rendered per target

The targets list is the only thing that differs between environments. The template is written once; the loop stamps it out for each entry:

orchstep.yml
name: render-demo
defaults:
  app: checkout
  version: "2.4.0"
  port: "8080"
  targets:
    - { tier: staging, replicas: "1", host: stage.internal }
    - { tier: production, replicas: "6", host: api.internal }

tasks:
  config:
    steps:
      - name: render_per_target
        loop:
          items: "{{ vars.targets }}"
          as: t
        func: render
        do: |
          # generated for {{ loop.t.tier }} - do not edit by hand
          [service]
          app      = {{ vars.app }}
          version  = {{ vars.version }}
          tier     = {{ loop.t.tier }}
          replicas = {{ loop.t.replicas }}
          listen   = {{ loop.t.host }}:{{ vars.port }}

  # the render shorthand: do: IS the template
  banner:
    steps:
      - name: line
        func: render
        do: "deploying {{ vars.app }} v{{ vars.version }} on :{{ vars.port }}"

Run it and you get both configs, side by side, from the same five template lines:

orchstep run config
# generated for staging - do not edit by hand
[service]
app      = checkout
replicas = 1
listen   = stage.internal:8080

# generated for production - do not edit by hand
[service]
app      = checkout
replicas = 6
listen   = api.internal:8080

The shared structure can only be wrong in one place now. Change the template and both environments move together; the only per-environment facts live in the targets list, where the difference is the whole point.

Write the file, not just the log

Rendering to the run log proves the shape; in real use you want a file on disk. render takes an output_file, and the path itself is a template — so the destination is per-target too:

- name: write_nginx
  func: render
  args:
    template_file: "templates/nginx.conf.tmpl"
    output_file: "build/{{ vars.tier }}/nginx.conf"

That's the whole pattern: a .tmpl checked into the repo, one render step, and a build/<tier>/ tree that's reproducible from git + orchstep run. No generated files committed, no "which sed produced this?" archaeology.

The template language is the good part

Because render uses the engine's full template set, the values aren't just substitutions — they can be computed:

- name: render_config
  func: render
  args:
    template: |
      app_upper = {{ vars.app | upper }}
      workers   = {{ mul (atoi vars.replicas) 2 }}
      built_at  = {{ now | date "2006-01-02" }}

upper, mul, now | date, defaults, conditionals — the things envsubst makes you shell out for are pipes here. And the render sees the same context every step sees (vars, steps, env, stdin), so a value computed by an earlier step can flow straight into the file.

What you traded away

The old wayWith render
app.staging.ini + app.production.inione template + a targets list
envsubst (no defaults, no logic)full template language: pipes, upper, mul, dates
sed -i placeholder surgerynamed vars.* references
drift between near-duplicatesshared structure changes in one place
"which script wrote this file?"output_file: from a versioned template

When a plain file is fine

If a config never varies — one environment, no computed values — keep the static file and don't template it. render pays off the moment the same file exists in two shapes, or a value has to be derived. That's also the moment hand-maintained duplicates start to drift, which is exactly when you want a single source.

Where to go next

The demo is render-only, so it runs anywhere: orchstep run config.

#TEMPLATES#CONFIG#RENDER#ENVIRONMENTS#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY