Configuring an application
Anything that differs between your laptop and production is declared on the application and bound to the host — an environment variable, a command-line argument, or both. There are three blocks, and they all work the same way.
kind: Telo.Application
metadata:
name: MyApp
version: 1.0.0
ports:
http:
env: PORT
default: 8080
variables:
apiBaseUrl:
env: API_BASE_URL
type: string
default: https://api.example.com
maxRetries:
env: MAX_RETRIES
type: integer
default: 3
secrets:
databaseUrl:
env: DATABASE_URL
type: string
Each resolves into its own CEL scope:
port: !cel "ports.http"
baseUrl: !cel "variables.apiBaseUrl"
dsn: !cel "secrets.databaseUrl"
Why declare it instead of reading the environment
- It fails at load, not at 3am. A missing required variable stops the whole load with a message naming it, before any resource initializes. Every failure in the block is aggregated into one report, so you fix them all at once instead of one restart at a time.
- It is typed.
type: integermeans the string"3"arrives as3. Anobjectorarrayvalue is JSON-decoded from the variable. A value that cannot be coerced, or that fails any further JSON Schema keyword you add, is a load error. - It is visible.
telo check, the editor, and anyone reading the manifest can see the app's entire configuration surface in one block. A runner knows which ports the app exposes without starting it. - It cannot be bypassed. Once a name is declared, reading it straight from
the process environment inside a controller returns
undefinedby design, so a declared binding is the only path.
Entry shape
Every entry binds env: — a variables: entry may bind arg: instead, or both — and a variables: / secrets: entry declares a type::
| Key | Meaning |
|---|---|
env: | The host environment variable to read. Conventionally SCREAMING_SNAKE_CASE. |
arg: | The command-line argument to read — see Command-line arguments. Not on secrets:; beside env: on ports:. |
type: | string, integer, number, boolean, object, or array. Not written on ports: entries — a port is always an integer. |
default: | Used when neither the argument nor the variable is given. An entry with no default is required. |
| anything else | Any further JSON Schema keyword — minimum, enum, pattern, … — validated at load. |
variables:
logLevel:
env: LOG_LEVEL
type: string
enum: [trace, debug, info, warn, error]
default: info
featureFlags:
env: FEATURE_FLAGS # FEATURE_FLAGS='{"newCheckout":true}'
type: object
default: {}
Command-line arguments
The same entry can be bound to the command line with arg:. Everything after the
manifest path belongs to the application — telo run [telo options] ./telo.yaml [application arguments], the way node [options] app.js [args] works — and a
packaged application gets every argument after its program name.
variables:
include:
type: array
items: { type: string }
arg: include # --include a.yaml --include b.yaml
default: ["**/tests/*.yaml"]
verbose:
type: boolean
arg: { flag: verbose, short: v } # --verbose / -v / --no-verbose
env: APP_VERBOSE
default: false
target:
type: string
arg: { position: 0 } # the first bare argument…
env: DEPLOY_TARGET # …or this, where a runner starts the app
ports:
http:
env: PORT
arg: port # --port 9000
default: 8080
telo run ./telo.yaml --port 9000 --include a.yaml --include b.yaml staging
telo run ./telo.yaml --help # the usage these bindings describe
--help also lists, under Environment:, every input read from an
environment variable alone — its type, its default or required, and the
input it fills — so an application with no arguments still answers how it is
configured. A secret shows only its type and whether it is required. Needs are
named on a last line; telo needs report lists the variables they read.
- The first source with a value wins: the command line, then the
environment variable, then
default:— and, only at a terminal, what you type when asked (see Being asked for what is missing). - Tokens are read by the entry's type — a repeated flag fills an array, each
token read by
items.type; a boolean is--flagor--no-flag;--ends the options, so every later token is positional. - Nothing undeclared gets through. An argument no entry binds, a flag with its value missing or a value of the wrong type stops the load, in the same single report as a missing variable, naming what the application accepts.
- A secret is never an argument (
ARG_BINDING_ON_SECRET): a command line is readable in the process table and in shell history. - A runner and the studio supply values through the environment, never the command
line. So a
ports:entry always bindsenv:, and a variable bound only byarg:needs adefault:(ARG_BINDING_INVALIDotherwise).
The full grammar is in Application arguments.
variables vs secrets
Same shape, one difference that matters: values bound to secrets: are
redacted from logs automatically, with no configuration — see
Logging basics. Put anything sensitive there — tokens,
connection strings, keys — and non-sensitive configuration in variables:.
Telo does not integrate with a secrets manager itself. Inject the values the way
your platform already does (a Kubernetes Secret, an ECS task-definition
secret, systemd EnvironmentFile, a wrapper that fetches from Vault and
execs telo) — see Security & supply chain.
ports
ports: is application-only and describes what the app listens on:
ports:
http:
env: PORT
protocol: tcp # tcp (default) | udp
default: 8080
The value is implicitly a port integer (1–65535), so no type: is written. Two
things follow from declaring it rather than hardcoding a number:
- A binding resource reads
!cel "ports.http"as the single source of truth, so the value in the container's-pflag and the value the server binds cannot drift apart. - The analyzer brands each port by protocol, so wiring a UDP port into a field that wants a TCP one is a static error even though both are integers.
Passing configuration into an import
Only the root application reads the host environment and the command line. A
library receives its values explicitly from whoever imports it — declaring an
env: or arg: key inside a library is rejected (LIBRARY_ENV_KEY_REJECTED,
LIBRARY_ARG_KEY_REJECTED). Use the object form of an import entry:
imports:
Payments:
source: ./libs/payments
variables:
currency: EUR
apiBaseUrl: !cel "variables.apiBaseUrl"
secrets:
apiKey: !cel "secrets.paymentsKey"
That is the whole configuration boundary: one place binds the host, and everything below it is passed values. See Libraries.
Local development
The CLI loads .env and .env.local automatically, so you rarely export
anything by hand:
# greeting-api/.env.local
GREETING=Hej
DATABASE_URL=postgres://localhost/dev
It reads the manifest's own directory, and — when a telo-workspace.yaml sits
somewhere above it — every directory up to and including that one, so a
monorepo keeps shared development values in one file at the root instead of a
copy beside every manifest. The marker's release.modules list has no say here,
so a manifest outside every release subtree is covered too. With no marker above
the manifest, only its own directory is read.
A marker can narrow both halves of that — how far the walk climbs, and which
filenames it collects — with an env: block,
which is how a monorepo keeps one team's .env out of another's runs.
The nearest declaration wins — a value in the manifest's directory overrides
the same key at the root, .env.local overrides .env within one directory,
and a variable already exported in your shell overrides every file. Run with
--debug to see which files were loaded; a file that exists but cannot be read
is always reported, whatever the flags.
.env may be committed when it holds only non-secret development values — a
shared default for the whole team. .env.local never is: it holds secrets and
personal overrides, so keep it in .gitignore. Neither is a configuration
mechanism for production; telo package never carries either.
telo env template writes both files from the manifest, so nobody transcribes
variable names by hand:
telo env template ./telo.yaml # print both files
telo env template ./telo.yaml --write # add what is missing beside the manifest
Every variable the application reads is one commented-out line — variables:,
ports: and non-sensitive need fields in .env, secrets: and need fields
marked sensitive (a database URL holding a password) in .env.local:
# variables
# BUCKET= # required
# TRACK_CRON='*/15 * * * *'
Nothing is uncommented: an empty NAME= would set the variable to the empty
string, and a written default would override a later change to the manifest's
default:. A value shown is the default the application uses while the
variable is unset; a secret's default is never written. --write adds only the
variables a file does not already mention, so it is safe to re-run after adding
an input, and warns when .env.local is not ignored by git.
Being asked for what is missing
Run an application at a terminal without setting everything it needs, and
telo run asks instead of failing — one input at a time, on stderr:
2 inputs have no value — enter each below (an empty line is an empty value; Ctrl-D stops).
GOOGLE_CLIENT_ID The OAuth client id [string; variables.googleClientId]
GOOGLE_CLIENT_ID: 1234.apps.example.com
GOOGLE_CLIENT_SECRET [string; secret; secrets.googleClientSecret]
GOOGLE_CLIENT_SECRET (hidden):
Save GOOGLE_CLIENT_ID to .env and GOOGLE_CLIENT_SECRET to .env.local? [y/N]
- Only what nothing supplies is asked: an entry with no argument, no
environment variable and no
default:, and each required field of a need nothing supplies. An entry with a default, an optional need and a value that is set but invalid are never asked — an invalid value still fails the load, without asking anything. - An answer is read like the environment variable —
type: integerreads3as3, anobjectis JSON, a path is anchored where you ran the command — and checked against the same schema. One it refuses is asked again, with the reason. A secret, and a need field marked sensitive, are read without echo. - Answers never become environment variables: a controller,
ctx.envand an application this one starts do not see them, and a need field's answer goes only into that need. - Once every answer is accepted,
telo runoffers to save them (default no): secrets to.env.local, the rest to.env, beside the manifest — the same files and rules astelo env template --write, written uncommented and only appended. The next run reads them and asks nothing. --watchkeeps the answers for the session: a reload asks only for an input added since, or one the edited manifest refuses.- It only happens with a person at the terminal — stdin and stderr both a
terminal.
telo run --no-input(before the path) refuses instead, and so does every runner. The refusal then ends with the variables to set and the two ways to set them. Ctrl-D stops asking; Ctrl-C ends the run.
See also
- Application environment variables — the normative rules.
- Application ports — protocol brands and wiring checks.
- Running in production — the
TELO_*variables the runtime itself reads.