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.
blog/render-configEvery 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:
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:8080The 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 way | With render |
|---|---|
app.staging.ini + app.production.ini | one template + a targets list |
envsubst (no defaults, no logic) | full template language: pipes, upper, mul, dates |
sed -i placeholder surgery | named vars.* references |
| drift between near-duplicates | shared 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
- render function —
template,template_file,output_file, the shorthand - Variables & Outputs — the variable layers a template renders against
- Environments & Variable Files — drive
targetsfrom per-environment files
The demo is render-only, so it runs anywhere: orchstep run config.
curl -fsSL https://orchstep.dev/install.sh | sh