Credentials¶
How every service gets its secrets and config – in local development, CI, staging and production alike – with one schema, one resolution path and no per-scope copy to keep in sync.
The short version¶
Each service that needs credentials has a
services/<name>/.env, committed to git. Every value is one of three things:a plain literal, for config genuinely identical in every scope;
a
<scheme>://<item>reference, naming an item in some store;k8s://, meaning the Kubernetes manifests supply this key directly and Seal never touches it.
A reference names an item, never a store. Which vault, project or folder holds that item is declared once per project, in
seal-credentials-config.jsonat the project root, with a mapping per environment..env’s key names are the schema. There is noenv.json. Whether a key exists, and for which service, is entirely defined by which.envfiles mention it.seal run -- <cmd>runs one command with a service’s.envresolved into its environment.seal upresolves every service’s.envand fills the Kubernetes objects each overlay says Seal fills, then startstilt up. Use it instead oftilt up.seal ciis the same, wrappingtilt ci.
A .env is safe to commit: it holds a reference to where a secret lives,
never a resolved secret.
Why one system rather than several¶
Independent credential paths per scope invite three failure modes, and all three are expensive to find: dev, CI, staging and production drift apart with nothing checking they agree; duplicated key definitions go stale because nothing keeps them in sync; and a secret’s actual value ends up somewhere disconnected from the store’s own sharing and audit story – copied into a CI provider’s secret store, say, editable by anyone with write access to the repository’s settings.
One .env per service, resolved identically everywhere, removes all three
by construction. There’s no second list of key names anywhere, in any scope.
Writing a .env¶
Standard dotenv format – KEY=VALUE lines, # comments, blank lines
ignored:
# services/api/.env
DEBUG=0
DJANGO_SECRET_KEY=pass://django-secret-key/password
DATABASE_URL=k8s://
Plain literals¶
Only for config that is genuinely identical in every scope. There is no
separate dev-only override file, so a literal here is the value
everywhere, production included. Where a key would be dangerous somewhere,
pick the safest value: DEBUG=0, never DEBUG=1.
References¶
Only a project whose .env files actually use these needs its provider’s
CLI on PATH; a project whose values are all literals and k8s:// markers
reaches no store at all, and declares no provider.
DJANGO_SECRET_KEY=pass://django-secret-key/password
The scheme (pass://) says which of the project’s declared providers
resolves this value. Everything after it is the item, in whatever shape
that provider wants – here an item name and a field within it.
Important
A reference names the item and nothing else. It never names the vault, and it is never a template.
That is what makes the same line valid in every environment. Which vault the
item is read from is the project’s declaration, not this value – see
Declaring where a store is below. A value
containing ${ is rejected by
seal check: a value that
expands is a value whose meaning depends on where it is read, which is the
property this format exists to avoid.
There is no fallback when an item doesn’t exist or you aren’t logged in.
Seal fails loudly, on purpose: this same .env is what production
resolves too, so a silent insecure fallback here would be a silent insecure
fallback there.
k8s://¶
DATABASE_URL=k8s://
This key is supplied directly by the Kubernetes deployment manifests – a
literal env: entry, typically pointing at an in-cluster service like a
bundled dev Postgres. Seal never resolves or injects it, in any scope.
There’s nothing to fill in; the marker is there so the .env still names
every key the service needs.
Declaring where a store is¶
seal-credentials-config.json, at the project root beside the
Tiltfile. A project whose .env files are all literals and k8s://
markers needs no such file at all.
{
"default_env": "dev",
"provider": [
{
"scheme": "pass://",
"name": "Proton Pass",
"cli": "pass-cli",
"install_hint": "https://proton.me/pass/download",
"form": "run",
"reference": "pass://{vault}/{item}",
"command": ["pass-cli", "run", "--env-file", "{env_file}",
"--", "{command}"],
"environments": {
"dev": { "vault": "project-seal-dev" },
"prod": { "vault": "project-seal-prod" }
}
}
]
}
Three fields carry the whole difference between one store and the next:
environments maps the credentials environment a run selects to whatever
this provider calls a scope. A mapping rather than a template, because a
scope is not always derivable from an environment’s name: one store scopes
by a vault named after the environment, another by a UUID that no string
built from the word prod will ever produce.
reference rebuilds the provider-native reference from the item the
.env declared plus that scope. It is what lets a store whose scope lives
inside the URI work without the .env carrying it. A store that names its
scope on the command line instead leaves this at its default, {item}, and
puts the mapping’s values in command.
command is the invocation, in the form the store supports. form says
which of four that is:
|
How the CLI hands values over |
|---|---|
|
Wraps a command, putting the values in its environment. |
|
Reads a file of references and writes it back, resolved. |
|
Dumps a whole scope; Seal keeps only what was asked for. |
|
One item in, one value out, invoked once per reference. |
All four are implemented. get is the one every surveyed product supports,
so a store with no wrapping form at all is still declarable.
Placeholders are a closed set. Seal substitutes names and never interprets what it substituted.
Note
Because a store is declared rather than coded for, adding one is a change
to your project, not to Seal. Nothing in python/ or tilt/ names a
product – a test greps the deliverable to keep it that way.
Which environment a run reads¶
In order: the --credentials_env flag, then the
SEAL_CREDENTIALS_ENV environment variable, then the config’s
default_env.
seal up --credentials_env prod
Important
This is independent of --k8s_overlay.
Which store the values come from and which manifests they land in are separate choices, and a run states each. Neither flag implies the other.
The flag is read from Seal’s own arguments, before any --, so it
never reaches Tilt or a wrapped command.
The objects a service’s values land in¶
Seal fills objects your overlay already declares, rather than
inventing one. Mark an object with the service whose .env fills it:
apiVersion: v1
kind: Secret
metadata:
name: example-api-secrets
annotations:
seal-test.dev/fill-from: api
type: Opaque
That object claims every key services/api/.env declares which no other
object claims – so adding a secret stays one line in the .env.
To split a service across several objects, name keys on one of them:
metadata:
name: example-api-config
annotations:
seal-test.dev/fill-from: api
seal-test.dev/keys: DEBUG
It then claims exactly those, and the object naming no keys takes the rest. Splitting is opt-in, and only a split object names its keys.
Both kinds work: a Secret’s values are base64-encoded, a ConfigMap’s are
plain. Which an object is decides RBAC and what kubectl describe prints,
so it is your manifest’s call, not Seal’s.
Values arrive as a strategic-merge patch, so exactly one object reaches
the cluster – yours, with its own name, labels and annotations intact and
only data supplied. A project that would rather run its own operator in
the session cluster marks no objects at all: Seal then fills nothing,
and the overlay deploys exactly as written.
seal check reports any container whose sources supply nothing for a
key its service declares, in every overlay – so a service wired into one
overlay and forgotten in another fails before it deploys. An overlay that
splits a service across objects has to repoint every container that reads
it, not just one.
seal run -- <cmd>¶
Run from inside services/<name>/ (or a subdirectory of it), to work on a
service outside the cluster:
cd services/api
uv sync --group dev
seal run -- uv run python src/manage.py migrate
seal run -- uv run pytest tests/
What it does:
Finds the project root – searching upward for a directory with both a
Tiltfileand aservices/subdirectory.Works out which service you’re in from your current directory.
Reads that service’s
.envand drops anyk8s://keys.Resolves what’s left: a literal as itself; a reference through whichever provider declares its scheme, against the store that provider’s
environmentsmap names for this run. A purely literal/k8s://.envreaches no provider, and needs no CLI installed at all.Runs your command with the result.
An already-set environment variable wins over .env for values injected
directly.
seal up and seal ci¶
seal up # from the project root
seal ci -- --build_type test # what CI runs
Conceptually seal run -- tilt up, except there’s no .env at the
project root. Instead, before starting Tilt, it resolves each service’s
.env once and fills every overlay’s annotated objects with the result,
writing the patches under .workspace/seal-env/<overlay>/.
Every overlay, not just the one this run deploys: which overlay Tilt selects
is computed in the Tiltfile from --k8s_overlay and the project’s own
default_overlay, so the values are put beside each one and the Tiltfile
reads whichever it picked. It’s cheap – only an overlay carrying
annotations has anything filled, and each service resolves once however many
overlays name it.
Then it execs tilt up (or tilt ci), forwarding every argument you passed
through unchanged.
Warning
A plain tilt up, without ever having run seal up, does not
refuse. It deploys the annotated objects empty, so containers start without
their credentials and the failure surfaces as a readiness timeout or an
application error, well away from the cause. If you edit a .env, re-run
seal up – Ctrl-C the running session first.
seal ci differs from seal up in two ways: which tilt
subcommand it runs, and that it runs
seal check first – a
manifest that cannot report readiness honestly, a .env that disagrees with
the declaration, or a key no container sources each make the whole run’s
result untrustworthy, and all of them cost a directory walk to find out.
Both take --credentials_env.
CI, staging and production¶
CI resolves credentials the same way local development does – seal ci calls the same code seal up does. Only two pieces are
CI-specific, and neither names your store:
Installing and authenticating the provider’s CLI, once per job, before
seal ci runs. The reusable workflow takes a provider_setup script
for this, and one provider_token secret that reaches it as
$SEAL_PROVIDER_TOKEN. What that token opens, and which store, is
your project’s business:
provider_setup: |
curl -fsSL https://example.invalid/install.sh | bash
echo "$SEAL_PROVIDER_TOKEN" | its-cli login
secrets:
provider_token: ${{ secrets.WHATEVER_IT_CALLS_ITS_TOKEN }}
Set the secret on the GitHub Environment the job binds to (Settings →
Environments → <name> → Secrets), not as a repository secret – one per
environment, each scoped to only what that environment should read.
Warning
The passthrough matters as much as the value. A reusable-workflow job
resolves an Environment-scoped secret only if the caller names it in its own
secrets: block – omit it and the value silently arrives empty even with
the secret correctly set.
Promoting to production still requires clearing GitHub’s own Environment protection rules – required reviewers and the rest – before that environment’s token, and therefore its secrets, is reachable at all.
Setting credentials_env, which selects the environment whose stores
this run reads. Seal’s reusable workflow leaves it at the project’s
own default, since nothing it runs reaches a real environment. A deploy
pipeline sets it to that environment’s own name. Which Kubernetes overlay
gets deployed is a separate input, set independently.
Note
A CI provider’s own secret store is not part of app credential resolution. The one thing it holds is infrastructure-level rather than app config: the provider token above.
Because the reusable workflow resolves each service’s own .env directly,
it never needs to know your application’s variable names – so your CI file
never enumerates them. See
Continuous integration.
Deploying to a real environment¶
seal up/seal ci produce real, plaintext values and hand them
to Tilt to apply live. That’s fine for local development and for CI, which
only ever run against a throwaway cluster. A real staging or production
deploy never goes through seal ci or Tilt at all, and never has a
plaintext Secret applied to it from outside the cluster.
That’s a deployment concern rather than Seal’s. What matters here is
only that the resolution is the same resolution: a service’s credentials
in a real environment are whatever its own .env says they are, resolved
against that environment’s own store. The standard destination is a Sealed
Secret – a Secret
encrypted against an in-cluster controller’s public cert, committed to git
like any other manifest and reviewed through a normal pull request.
An environment that provisions its secrets some other way – an ExternalSecret, a CSI-driver target, an operator – simply doesn’t annotate those objects in its own overlay. Seal fills nothing there, and the consuming Deployment doesn’t change at all.
Adding a new secret¶
Add
KEY=to the relevantservices/<name>/.env, as a reference or a safe-by-default plain literal. Never a real secret value as a literal.If it’s a reference, create the matching item in the store each environment’s mapping names.
seal up, seal run and seal ci all pick it up
immediately. The object annotated for that service claims it without being
touched, and there is nothing else to keep in sync.
Using a different store¶
Declare it. Add a provider to seal-credentials-config.json with the
scheme your .env files will use, the CLI, the form its CLI supports, the
command that invokes it, and an environments mapping.
There is no list inside Seal to add it to, and no code to change. If
its CLI can do any one of run, resolve, fetch or get, it is
declarable – and get is the form every surveyed product supports.
seal check validates the declaration, and seal ci runs it
before anything expensive: a value naming a
scheme no provider declares, a provider some .env reaches with no mapping
for the selected environment, and a template naming a placeholder Seal
cannot supply are each reported by name.