Working with Secrets
Kubricate treats secrets as first-class citizens — and manages them via an explicit Secret Manager that wires together sources (Connectors) and delivery methods (Providers). You declare the secrets your stacks need, choose where those values come from, and decide how they are delivered to Kubernetes — all at build time, before deploy.
Design once. Swap backends per environment. Validate before rollout.
What You’ll Learn
- The three players: SecretManager, Connectors, Providers
- How to register secrets in a central setup file
- How to inject secrets into stacks with
.useSecrets(...)
- How to target different injection styles (
env
,envFrom
, file mounts, or provider-specific outputs) - Swapping providers per environment without rewriting templates
Concepts
SecretManager — A local orchestrator
The SecretManager is defined per project or per config file. It orchestrates:
- which Connectors are available (where values come from),
- which Providers are available (how values are delivered),
- what secrets are declared (their logical names), and
- a fluent API that stacks can call via
.useSecrets(...)
to bind secrets.
Note: multiple SecretManagers can exist inside a broader SecretRegistry, which aggregates them across modules or repos. Don’t confuse the two — the manager is a unit of orchestration, while the registry collects managers.
Connectors — Where secrets come from
A Connector loads values from a source of truth — e.g. .env
, 1Password, Vault, Azure Key Vault, or a custom source. Connectors are about retrieval, not rendering.
Providers — How secrets are delivered
A Provider decides how the secret is materialized/consumed. Examples:
- Emit a Kubernetes
Secret
(typeOpaque
,kubernetes.io/dockerconfigjson
, etc.) and reference it from workload specs (envFrom
,env
, volume mounts). - Emit provider-specific resources (e.g.
ExternalSecret
for ESO) or bridge to external systems.
Because Connectors and Providers are decoupled, you can switch per environment (e.g.
.env
in dev, Vault in prod) without touching stack templates.
Full Example — With Secret Manager
This example shows a minimal but real flow using a local .env
connector and an Opaque Secret provider.
1) Configure the Secret Manager
// @filename: src/setup-secrets.ts
import { EnvConnector } from '@kubricate/plugin-env'
import { OpaqueSecretProvider } from '@kubricate/plugin-kubernetes'
import { SecretManager } from 'kubricate'
export const secretManager = new SecretManager()
// 1) Sources of truth
.addConnector('EnvConnector', new EnvConnector())
// 2) Delivery methods
.addProvider('OpaqueSecretProvider',
new OpaqueSecretProvider({
name: 'app-secrets'
})
)
// 4) Declare secrets (logical names)
.addSecret({ name: 'DATABASE_URL' })
.addSecret({ name: 'API_KEY' })
.addSecret({ name: 'JWT_SECRET' })
Explanation: Configuring the Secret Manager
Create a new SecretManager
tsexport const secretManager = new SecretManager()
This initializes a local orchestrator that will control how secrets are loaded and delivered for your stacks.
Add a Connector
ts.addConnector('EnvConnector', new EnvConnector())
- Connector = where secrets come from. Here we use
EnvConnector
so values are read from your local.env
file. Example:DB_PASSWORD=super-secret
inside.env
.
- Connector = where secrets come from. Here we use
Add Providers
ts.addProvider('OpaqueSecretProvider', new OpaqueSecretProvider({ name: 'app-secrets' }) )
- Provider = how secrets are delivered.
OpaqueSecretProvider
will generate a standard KubernetesSecret
of typeOpaque
and inject values as environment variables into your containers.
Declare Secrets
ts.addSecret({ name: 'DATABASE_URL' }) .addSecret({ name: 'API_KEY' }) .addSecret({ name: 'JWT_SECRET' })
- Each
.addSecret
registers a logical secret name with the manager. - All secrets will use the default provider (
OpaqueSecretProvider
) to create environment variables, and also use the default connector (EnvConnector
) to read values from.env
.
- Each
For multiple providers and connectors, you can specify per secret which to use (see Use Multiple Secrets Providers and Connectors).
Why this matters
- You separate what secrets exist from how they’re implemented.
- Later, your stacks can simply say: “I need
DATABASE_URL
” — without caring whether it’s coming from.env
, Vault, or which Secret type it’s rendered as. - Switching from
.env
to Vault or from Opaque to ExternalSecret doesn’t change your stack code — only this setup file.
Next, let’s see how to use these declared secrets inside a stack.
2) Use secrets inside a Stack
// @filename: src/stacks.ts
import { namespaceTemplate, simpleAppTemplate } from '@kubricate/stacks'
import { Stack } from 'kubricate'
import { secretManager } from './setup-secrets'
const namespace = Stack.fromTemplate(namespaceTemplate, { name: 'my-namespace' })
const myApp = Stack.fromTemplate(simpleAppTemplate, {
namespace: 'my-namespace',
imageName: 'nginx',
name: 'my-app',
})
.useSecrets(secretManager, c => {
c.secrets('DATABASE_URL').inject()
c.secrets('API_KEY').forName('APP_API_KEY').inject()
c.secrets('JWT_SECRET').inject()
})
export default { namespace, myApp }
Let's break down each binding in your example:
c.secrets('DATABASE_URL').inject()
- Uses the logical secret
DATABASE_URL
. - Uses the default name — the environment variable will be called
DATABASE_URL
. - Calls
inject()
with no args → provider default applies (Opaque → environment variable).
c.secrets('API_KEY').forName('APP_API_KEY').inject()
- Uses the logical secret
API_KEY
. - Renames the in-container env var to
APP_API_KEY
(instead of defaultAPI_KEY
). - This shows how you can customize the environment variable name.
c.secrets('JWT_SECRET').inject()
- Uses the logical secret
JWT_SECRET
. - Uses the default name — the environment variable will be called
JWT_SECRET
. - All secrets use the same provider (OpaqueSecretProvider) for consistent delivery.
Quick mental model
forName()
→ “What should this be called at the destination?” (e.g. env var name)inject()
→ “How should it be delivered?”- No args → “Use the provider’s smart default.”
- With args → “Override the target or path explicitly.”
That's it—your example uses sensible defaults for environment variable injection, keeping the tutorial simple while still showing renaming via forName(...)
.
Now, let’s see how to register the manager in your config.
3) Register the manager in config
// @filename: kubricate.config.ts
import { defineConfig } from 'kubricate'
import { secretManager } from './src/setup-secrets'
import simpleAppStack from './src/stacks'
export default defineConfig({
stacks: { ...simpleAppStack },
secret: {
secretSpec: secretManager,
conflict: {
strategies: {
// intraProvider: 'error',
// crossProvider: 'error',
// crossManager: 'error',
},
},
},
})
Conflict strategies control how to handle duplicate keys across scopes (within one provider, across providers, or across managers). Default behavior is strict (fail early) unless you override.
4) Setup .env and apply the secrets
Create a .env
file in the project root, the EnvConnector
will read the prefixed key with KUBRICATE_SECRET_
, and the rest is match with the logical secret names:
# .env
KUBRICATE_SECRET_DATABASE_URL=postgres://user:password@localhost:5432/mydb
KUBRICATE_SECRET_API_KEY=supersecretapikey
KUBRICATE_SECRET_JWT_SECRET=verysecretjwt
Kubricate has cli to set secrets directly to target providers, in the examples, we use OpaqueSecretProvider
to create a kubernetes secret, so we can use kubricate secret appply
command to set the secrets:
bun kubricate secret apply
This will create a kubernetes secret named app-secrets
with the keys and values from the .env
file, for the Opaque secret, it's automatically encoded in base64 format.
5) Generate
bun kubricate generate
You’ll get manifests for:
- a
Namespace
, - an app stack (
Deployment
/Service
) referencing secrets, - one or more
Secret
resources produced by the configured providers.
Swapping Environments (no rewrites)
The idea here is to separate your stack logic from your environment configuration. Your application stacks only declare what secrets they need by name. They never care about where those secrets come from or how they’re delivered.
In practice, this means:
Development: You can keep things simple and local. Use an
EnvConnector
to read values from a.env
file, and anOpaqueSecretProvider
to render them as plain KubernetesSecret
objects. This is lightweight and perfect for testing locally.Staging / Production: Without touching the stack templates, you can change the configuration so that secrets are loaded from a secure external store like Vault or 1Password. Then, instead of creating Opaque Secrets directly, you can deliver them through a different provider such as an
ExternalSecret
(ESO). This shifts responsibility to a controller that syncs with your secret backend.
Because stacks reference only the logical secret names, you don’t need to rewrite or duplicate them per environment. The SecretManager setup is the only piece that changes. This makes environment promotion safer, reduces copy-paste YAML, and ensures the same application logic is used consistently everywhere.