Manage secrets and environment variables
Your manifest is committed to version control; your secrets are not. This
guide shows how to feed credentials and other environment values into a
stack without ever writing them into lightshuttle.yml, how to audit what
a stack needs before it boots, and how to read the diagnostics when
something is missing.
It assumes you have already booted a stack once. If you have not, start
with the getting started tutorial, which
introduces secrets at the end. For the exhaustive command surface, see the
secrets CLI reference.
The one rule to remember: a ${env.<NAME>} reference resolves from two
sources, in this order of precedence.
- A dotenv file (
.envin the working directory by default). - The process environment.
When both define the same name, the dotenv file wins. A value set to an empty string counts as unset.
The examples below use the runnable
examples/05-secrets
project. Clone it to follow along, or copy the manifest into a fresh
directory.
Require a secret
Use ${env.<NAME>} with no default for a value the stack cannot run
without. If it resolves to nothing, lightshuttle up refuses to boot and
names the variable, so a misconfigured stack fails fast instead of
half-starting.
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: secrets-demo
description: "Secrets from a .env file: required and optional references"
resources:
db:
postgres:
version: "16"
# Required: `up` refuses to boot while DEMO_DB_PASSWORD is unset.
password: ${env.DEMO_DB_PASSWORD}
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo token=$API_TOKEN && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
# Optional: falls back to `dev-token` when unset.
API_TOKEN: ${env.DEMO_API_TOKEN:-dev-token}
Provide the value through a .env file next to the manifest, and make sure
that file is ignored by git:
$ echo 'DEMO_DB_PASSWORD=local-dev-password' > .env
$ echo '.env' >> .gitignore
Boot as usual; DEMO_DB_PASSWORD is now injected into the Postgres
resource:
$ lightshuttle up
Make a secret optional
Append :-<default> to give a reference a fallback. The form
${env.DEMO_API_TOKEN:-dev-token} resolves to dev-token whenever the
variable is unset or empty, so the stack always boots, and a developer can
override it locally without touching the manifest:
env:
API_TOKEN: ${env.DEMO_API_TOKEN:-dev-token}
Precedence still applies: a value in the .env file overrides the default,
and a value in the .env file also overrides the same name in the process
environment.
To emit a literal ${...} instead of interpolating it, double the braces:
${{not.interpolated}} renders the string ${not.interpolated} verbatim.
Audit before boot with secrets check
lightshuttle secrets check reports every ${env.*} reference the
manifest contains, with its status and source, without starting anything:
$ lightshuttle secrets check
secrets for project `secrets-demo`:
DEMO_DB_PASSWORD set (.env)
DEMO_API_TOKEN default (dev-token)
all required secrets are set
The status column tells you exactly where each value comes from:
| Status | Meaning |
|---|---|
set (.env) | resolved from the dotenv file |
set (env) | resolved from the process environment |
default (...) | unset, falling back to the declared default |
missing | unset, and at least one reference has no default |
When at least one variable is missing, the command exits non-zero.
validatedoes not check secrets.lightshuttle validateparses the manifest, resolves${resources.*}references and checks the dependency graph, but it deliberately does not resolve${env.*}values. Usesecrets checkto audit secrets, and rely on the fail-fast preflight ofupas the final guard. The two read from the same engine, sosecrets checkpredicts whatupwill accept.
Point at another .env file
Both up and secrets check accept --env-file <path> to read from a
file other than .env. This is how you keep one set of values per
environment, for example a .env.ci checked against in a pipeline:
$ lightshuttle secrets check --env-file .env.ci
The implicit .env is loaded only when it exists and is silently skipped
when absent. A file passed with --env-file is explicit, so it must
exist; the command errors if it does not.
Diagnose a failed boot
When up aborts at the preflight, it lists every required variable that
resolved to nothing. Reproduce the same diagnosis without a Docker daemon
by running secrets check, which uses the identical resolution engine:
$ rm .env
$ lightshuttle secrets check
secrets for project `secrets-demo`:
DEMO_DB_PASSWORD missing
DEMO_API_TOKEN default (dev-token)
DEMO_DB_PASSWORD is missing and the command exits non-zero. Restore the
value (in .env, in the process environment, or via --env-file) and the
check passes again. Because check needs no container runtime, it is the
fastest way to confirm a fix before re-running up.
Spot a divergent default
The same variable can be referenced in several places. When two references
declare different defaults, that is almost always a mistake: the value
your stack uses then depends on which resource reads it first. secrets check surfaces this by listing every distinct default it saw, sorted and
joined with |:
project:
name: divergent-demo
resources:
app:
container:
image: alpine:3.20
env:
LOG_A: ${env.LOG_LEVEL:-info}
LOG_B: ${env.LOG_LEVEL:-debug}
$ lightshuttle secrets check
secrets for project `divergent-demo`:
LOG_LEVEL default (debug | info)
all required secrets are set
A single default in the parentheses is normal. Two or more is a signal:
pick one value and make every reference agree, or set LOG_LEVEL
explicitly so the defaults no longer matter.
Gate a CI pipeline on secrets
Because secrets check exits non-zero when a required variable is missing,
it doubles as a cheap pipeline gate. Run it against the environment file
the pipeline provides, and the job fails before any container starts:
$ lightshuttle secrets check --env-file .env.ci
Pair it with lightshuttle validate --strict to catch both structural
manifest errors and missing secrets in the same stage.