Skip to main content

Variables Overview

Variables provide dynamic configuration values that can be reused throughout your Xec configuration. They enable flexible, DRY (Don't Repeat Yourself) configurations that adapt to different environments and contexts.

Variable Types

Simple Variables

vars:
# Strings
appName: myapp
environment: production

# Numbers
port: 8080
replicas: 3
timeout: 30000

# Booleans
debug: false
enableCache: true

Complex Variables

vars:
# Objects
database:
host: db.example.com
port: 5432
name: production_db
credentials:
username: dbuser
password: ${secrets.db_password}

# Arrays
servers:
- web1.example.com
- web2.example.com
- web3.example.com

# Mixed structures
config:
features:
- name: feature-a
enabled: true
- name: feature-b
enabled: false
settings:
maxConnections: 100
timeout: 30s

Variable Interpolation

Basic Interpolation

vars:
name: myapp
version: "1.2.3"
tag: "${name}:${version}" # Result: myapp:1.2.3

tasks:
deploy:
command: docker run ${tag}

Nested Variables

vars:
env: production
region: us-east-1
endpoint: "https://api.${region}.example.com/${env}"

database:
host: db.example.com
port: 5432
url: "postgres://${database.host}:${database.port}/mydb"

Array Access

Array elements are addressed with dot-index notation (bracket syntax like ${servers[0]} is not supported):

vars:
servers:
- primary.example.com
- secondary.example.com

primaryServer: ${servers.0}
backupServer: ${servers.1}

Variable Sources

1. Configuration Variables

Defined in vars section:

vars:
appName: myapp
version: "2.0.0"

2. Environment Variables

Access system environment:

vars:
home: ${env.HOME}
user: ${env.USER}
customPath: ${env.CUSTOM_PATH}

# With defaults (everything after ':' is the default)
apiUrl: ${env.API_URL:http://localhost:3000}
logLevel: ${env.LOG_LEVEL:info}

3. Secrets

Access secure values:

vars:
apiKey: ${secrets.api_key}
dbPassword: ${secrets.database_password}
sshKey: ${secrets.deploy_key}

4. Task Parameters

Access task parameters:

tasks:
deploy:
params:
- name: version
required: true
command: |
docker pull myapp:${params.version}
docker run myapp:${params.version}

5. Command Substitution

${cmd:command} runs a shell command at configuration load time and substitutes its trimmed stdout:

vars:
git_sha: ${cmd:git rev-parse --short HEAD}
build_date: ${cmd:date +%Y-%m-%d}
current_user: ${cmd:whoami}

Keep these commands fast — they execute on every configuration load. If the command fails, loading fails with a descriptive error. The resolved output is never written back to disk when the configuration is saved.

A configuration that uses this is executable code

The command runs as you, with your credentials, whenever any xec command reads the file. A configuration arrives by git clone like everything else, so without a gate, entering a directory and running xec would run its author's commands.

Xec therefore refuses to load a configuration containing ${cmd:...} until you have approved it, and shows you what it would run:

$ xec config get vars.sha
Error: /srv/app/.xec/config.yaml runs commands when it is loaded, and has
not been approved.

git rev-parse --short HEAD

These run as you, with your credentials, every time any xec command reads
this configuration. Review them, then approve with:

xec config trust

Approval is recorded against the file's content, so editing it asks again — an approved configuration cannot be quietly changed into a different one. xec config trust --revoke withdraws it; --list shows everything approved.

In a pipeline running a configuration you own, XEC_TRUST_CONFIG=1 approves for that run without recording anything. Set it only where the configuration is yours.

Configurations that use no command substitution are never gated, and never prompt.

Variable Scope

Global Scope

Available everywhere:

vars:
globalVar: "available-everywhere"

tasks:
use-global:
command: echo ${globalVar}

profiles:
prod:
vars:
url: "https://${globalVar}.example.com"

Profile Scope

Override global variables:

vars:
environment: development

profiles:
production:
vars:
environment: production # Overrides global

tasks:
show-env:
command: echo ${environment} # Uses profile value

Task Scope

Tasks do not have their own vars: section — use task parameters with defaults for task-local values:

tasks:
scoped:
params:
- name: taskVar
default: "only-in-this-task"
command: echo ${params.taskVar}

Step Scope

A step's output can be registered as a variable for later steps. The registered value is the step's trimmed output as a string:

tasks:
multi-step:
steps:
- command: echo "test"
register: output

- command: echo "Result: ${output}"
# output only available after registration

Default Values

Defaults use a plain colon — everything after the first : is the default value. Bash-style :- is not supported (with ${env.PORT:-8080} the default would literally be -8080):

vars:
port: ${env.PORT:8080}

# Nested defaults
database:
host: ${env.DB_HOST:localhost}
port: ${env.DB_PORT:5432}
name: ${env.DB_NAME:development}

A reference without a default fails configuration loading when it cannot be resolved:

vars:
required: ${env.REQUIRED_VAR} # Fails if not set
optional: ${env.OPTIONAL_VAR:default} # Has default

What Interpolation Can and Cannot Do

Interpolation is a path lookup, not an expression language. There are no method calls, arithmetic, or ternaries inside ${...}:

vars:
name: "My App"
servers: [web1, web2, web3]

first: ${servers.0} # ✅ path lookup
greeting: "Hello ${name}" # ✅ string composition
# lower: ${name.toLowerCase()} # ❌ not supported
# port: ${base + offset} # ❌ not supported
# replicas: ${env == 'prod' ? 5 : 1} # ❌ not supported

For computed values, run a shell command at load time with ${cmd:...}:

vars:
name: "My App"
slug: ${cmd:echo "My App" | tr ' ' '-' | tr '[:upper:]' '[:lower:]'}
host_count: ${cmd:wc -l < hosts.txt}

Conditional logic belongs in task when: clauses (which support ==, !=, <, <=, >, >=, &&, ||, !, and parentheses) or in script steps.

Variable Resolution Order

Configuration sources are merged in this order — later sources override earlier ones:

  1. Built-in defaults - Lowest priority
  2. Global configuration - ~/.xec/config.yaml
  3. Project configuration - .xec/config.yaml (or xec.yaml)
  4. XEC_CONFIG file - Extra file referenced by the environment variable
  5. XEC_* environment variables - e.g. XEC_VARS_PORT=9000 sets vars.port
  6. Active profile - Selected via XEC_PROFILE; highest priority

Task parameters (${params.name}) are supplied per invocation and resolved in their own namespace, outside this merge.

Escaping Variables

Only ${...} sequences are interpolated. To output a literal ${...}, escape it with a backslash:

vars:
literal: "costs \${amount} dollars" # stays ${amount}
price: "Price: $100" # no ${...}, left untouched
command: echo $HOME # shell variable, untouched by Xec

Best Practices

1. Use Descriptive Names

# Good
vars:
apiEndpoint: https://api.example.com
maxRetries: 3

# Bad
vars:
url: https://api.example.com
n: 3
vars:
database:
host: db.example.com
port: 5432
name: myapp

cache:
host: cache.example.com
port: 6379

3. Provide Defaults

vars:
# Always provide sensible defaults
port: ${env.PORT:8080}
environment: ${env.NODE_ENV:development}

4. Document Variables

vars:
# Maximum number of retry attempts for API calls
maxRetries: 3

# API endpoint URL (must include protocol)
apiUrl: https://api.example.com

5. Validate Early

References without defaults fail configuration loading when unresolvable, so required values surface immediately:

vars:
apiKey: ${secrets.api_key} # load fails if the secret is missing
dbHost: ${env.DB_HOST} # load fails if DB_HOST is not set
# Check the configuration before running tasks
xec config validate

Common Issues

Circular References

# This causes infinite loop
vars:
a: ${b}
b: ${a} # Error: circular reference

Undefined Variables

vars:
# This fails if MISSING is not defined
value: ${env.MISSING}

# Use default to prevent failure
value: ${env.MISSING:default}

Type Mismatches

A variable that holds the whole value keeps its YAML type (number, boolean, object); a variable embedded in a longer string is stringified:

vars:
port: 8080 # Number — stays a number in ${port}
portString: "8080" # String

tasks:
connect:
command: connect --port ${port}

Debugging Variables

Show Resolved Values

# Show all variables
xec config list --path vars

# Show specific variable
xec config get vars.database.host

# Debug configuration loading
XEC_DEBUG=true xec run task

Next Steps

See Also