Effect-native SOPS decoding and optional Alchemy resources with redacted secret outputs.
alchemy-sops decrypts SOPS files with a native sops-age backend. Use
alchemy-sops/Schema to decode imported SOPS JSON directly with Effect Schema,
or use the package root for Alchemy resources whose scalar leaves are
Redacted<string>. The sops CLI backend remains available for binary files,
custom SOPS flags, and non-age backends.
- Install
- Changelog
- Usage
- Examples
- Typed from a JSON import
- Alchemy-free Schema decoding
- Provider credentials from a ConfigProvider
- Cloudflare Secrets Store Action
- Edge usage
- Inputs
- Outputs
- Security note
- Troubleshooting
bun add alchemy-sops effecteffect is a required peer. alchemy is optional and is only needed when you
import the package root for SopsFile or the Cloudflare Secrets Store API:
bun add alchemyThe native backend does not require a sops binary. Install sops only when
you need backend: "cli" or automatic fallback for SOPS features not supported
by sops-age. See CHANGELOG.md for release notes.
import * as Alchemy from "alchemy";
import * as Output from "alchemy/Output";
import { SopsFile, SopsFileProvider } from "alchemy-sops";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
const AppSecrets = Schema.Struct({
database: Schema.Struct({
url: Schema.RedactedFromValue(Schema.String),
}),
api: Schema.Struct({
token: Schema.RedactedFromValue(Schema.String),
}),
});
export default Alchemy.Stack(
"App",
{
providers: SopsFileProvider({ memoize: true }),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const secrets = yield* SopsFile("Secrets", {
path: "./secrets.enc.yaml",
format: "yaml",
ageKey: Config.redacted("SOPS_AGE_KEY"),
schema: AppSecrets,
});
return {
sourceHash: secrets.sourceHash,
topLevelKeys: secrets.topLevelKeys,
databaseUrl: Output.map(secrets.value, (s) => s.database.url),
};
}),
);For local files, backend: "auto" is the default. It tries sops-age first for
structured age-encrypted files, then falls back to the CLI when a local path
source is available. Use backend: "sops-age" to require the native backend or
backend: "cli" to force the binary.
path can also be an ordered array of local SOPS files. Each file is decrypted
independently and object documents are deep-merged in order, so later files
override earlier ones. This is useful for common.sops.json plus stage-specific
overrides without materializing plaintext between files.
SopsFileProvider({ memoize: true }) memoizes decrypt calls in the current
process. This is useful when multiple lazy resource paths request the same
encrypted source during one deploy. It does not replace resource cache; cache
controls persisted Alchemy output reuse across deploys.
Successful decrypts log the top-level keys without logging values. Pass a
service-free Schema.Struct from effect/Schema as schema to validate the
decrypted document and return a typed secrets.value output. Use
Schema.RedactedFromValue(...) for fields that should remain redacted in the
typed output.
The older secrets selector map is still supported when you need a flat
dot-path selection, and types: true or types: { exportName: "AppSecrets" }
still returns generated TypeScript definitions in secrets.types; no files are
written.
TypeScript types JSON imports natively. Pass the imported encrypted SOPS
document as json, and that inferred type flows through to secrets.data —
with no schema and no secrets keys to declare:
import * as Alchemy from "alchemy";
import * as Output from "alchemy/Output";
import { SopsFile, SopsFileProvider } from "alchemy-sops";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import encrypted from "./secrets.enc.json" with { type: "json" };
export default Alchemy.Stack(
"App",
{
providers: SopsFileProvider(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const secrets = yield* SopsFile("Secrets", {
json: encrypted,
ageKey: Config.redacted("SOPS_AGE_KEY"),
});
// secrets.data is fully typed from the import: scalar leaves are
// Redacted<string> and the top-level `sops` metadata key is removed.
return {
databaseUrl: Output.map(secrets.data, (data) => data.database.url),
};
}),
);The encrypted SOPS JSON keeps its plaintext key structure, so importing it
gives TypeScript the document shape for free. At runtime the object is
re-serialized and decrypted as inline content; values and key order are
preserved, so the SOPS MAC still verifies. Every scalar leaf is mapped to
Redacted<string> via the exported SopsRedactedDocument<T> type, matching
the redacted data output. Reach for schema instead when you also want
runtime validation or non-redacted leaf types.
Use alchemy-sops/Schema to decrypt an imported SOPS JSON document and decode
it with an Effect 4 Schema.Struct without configuring an Alchemy Stack. The
curried API defines the schema once, then decodes each encrypted import with
Schema.decodeEffect(AppSecrets)(encrypted):
import * as Schema from "alchemy-sops/Schema";
import * as Effect from "effect/Effect";
import * as EffectSchema from "effect/Schema";
import encrypted from "./secrets.enc.json" with { type: "json" };
const AppSecrets = EffectSchema.Struct({
database: EffectSchema.Struct({
url: EffectSchema.RedactedFromValue(EffectSchema.String),
}),
api: EffectSchema.Struct({
token: EffectSchema.RedactedFromValue(EffectSchema.String),
}),
});
const loadSecrets = Effect.gen(function* () {
return yield* Schema.decodeEffect(AppSecrets)(encrypted);
});This subpath and alchemy-sops/edge are Alchemy-free. The package root remains
the Alchemy resource API and therefore requires the optional alchemy peer at
runtime.
At compile time, EncryptedFor<S> derives required keys, nesting, and property
optionality from Schema.Codec.Encoded<S>. TypeScript checks that structure
against the inferred JSON import shape and requires the top-level sops
metadata object. Encrypted scalar leaves accept JSON scalar values, so
TypeScript cannot prove that a ciphertext decrypts to the expected plaintext
value or satisfies a refinement.
At runtime, decodeEffect serializes the imported document, decrypts it with the
native sops-age backend, then decodes the decrypted JSON with
Schema.decodeUnknownEffect through Schema.fromJsonString. Missing required
fields, invalid decoded types, failed transformations, and failed refinements
produce a Schema.SchemaError in the Effect error channel. Decryption failures
produce SopsDecryptError.
Pass Effect parse options when stricter document validation is required:
const loadStrictSecrets = Effect.gen(function* () {
return yield* Schema.decodeEffect(AppSecrets, {
errors: "all",
onExcessProperty: "error",
})(encrypted);
});Identity discovery follows sops-age defaults. To supply an age identity
explicitly, keep it redacted and load the real value from secret configuration:
import * as Redacted from "effect/Redacted";
const ageKey = Redacted.make("<age-secret-key>");
const loadWithKey = Effect.gen(function* () {
return yield* Schema.decodeEffect(AppSecrets)(encrypted, { ageKey });
});Treat the decoded object as secret material. Never log it or reveal decoded
Redacted values for inspection; reveal them only at the provider boundary
that must consume the plaintext.
This API intentionally accepts imported JSON only so the encrypted document
shape remains available to TypeScript. Use SopsFile or the low-level edge APIs
for path, URL, extract, and ordered merge workflows; those sources cannot carry
a static JSON import shape.
SopsFile decrypts secrets inside a stack, which is too late for the
credentials the stack itself needs to authenticate. alchemy-sops/Config covers
that earlier moment: it turns a SOPS document into an Effect ConfigProvider,
and because Alchemy resolves provider credentials through Config.redacted /
Config.string rather than reading process.env directly, a deploy can
authenticate with nothing in the environment — no sops exec-env wrapper and no
exported variables.
import * as SopsConfig from "alchemy-sops/Config";
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
export default Alchemy.Stack(
"MyApp",
SopsConfig.provideCredentials(
{
path: "secrets.sops.json",
secrets: {
CLOUDFLARE_API_TOKEN: "secrets.CLOUDFLARE_API_TOKEN",
CLOUDFLARE_ACCOUNT_ID: "secrets.CLOUDFLARE_ACCOUNT_ID",
},
},
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
),
body,
);Use provideCredentials rather than wiring the layer yourself: a stack's state
store initializes before its providers and resolves credentials
independently, so supplying the layer to only providers fails during state
initialization with a missing-credential error that points nowhere near the
cause.
The secrets map is optional. Without it the decrypted document is exposed as
it is, and nested values are addressed with Config.nested. With it, a nested
document answers flat lookups, which is what provider credentials expect — so
there is no need to flatten the document on disk.
Decryption is lazy and happens at most once. layerAdd registers the provider
behind the environment, so an ambient CLOUDFLARE_API_TOKEN=… bun run deploy
still wins and the document is never decrypted on that path:
import * as SopsConfig from "alchemy-sops/Config";
const credentials = SopsConfig.layerAdd({ path: "secrets.sops.json" });
// or SopsConfig.layerAdd({ ... }, { asPrimary: true }) to consult SOPS first,
// or SopsConfig.layer({ ... }) to replace the ambient provider outright.A path the document does not contain is reported as absent rather than as a
failure, so composing with ConfigProvider.orElse and other providers behaves
the way Effect expects. Only an unreadable or undecryptable document fails, as a
ConfigProvider.SourceError that names the source but never includes decrypted
content.
This entry point does not import alchemy, so it is usable from non-Alchemy
programs that just want SOPS-backed configuration.
Alchemy only consults environment-style credentials non-interactively when
CI=1. Otherwise it requires a configured profile and refuses to continue, which means theConfigProvideris never reached. SetCI=1in automation, or runalchemy loginfor interactive use.
Use CloudflareSopsSecrets when Cloudflare Workers should receive secrets from
Cloudflare Secrets Store instead of Alchemy state. It is the high-level wrapper
around the exported CloudflareSopsSecretsAction.
The wrapper reads a local encrypted SOPS file before registering the Action, passes ciphertext into Action state, decrypts during deploy, and imports selected values into the target store. Plaintext is sent to Cloudflare Secrets Store but is not persisted as Action input.
A stack using the Action needs:
- Cloudflare providers and state configured in the Alchemy stack
- A
Cloudflare.SecretsStoreresource or{ accountId, storeId }reference - A deploy-time SOPS identity, preferably passed as
Redacted<string> - A
secretsmap from Cloudflare secret names to decrypted dot-path selectors, or nosecretsmap when all scalar leaves should be imported
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import { CloudflareSopsSecrets, cloudflareSopsWorkerBindings } from "alchemy-sops";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"Worker",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const store = yield* Cloudflare.SecretsStore("Secrets");
const imported = yield* CloudflareSopsSecrets("WorkerSecrets", {
path: "./secrets.enc.yaml",
format: "yaml",
backend: "sops-age",
store,
ageKey: Redacted.make(process.env.SOPS_AGE_KEY!),
scopes: ["workers"],
comment: "imported by alchemy-sops",
secrets: {
API_TOKEN: "api.token",
DATABASE_URL: "database.url",
},
});
const worker = yield* Cloudflare.Worker("Api", {
main: "./src/worker.ts",
});
yield* worker.bind(
"sops-secrets",
cloudflareSopsWorkerBindings(imported, ["API_TOKEN", "DATABASE_URL"]),
);
return {
url: worker.url,
};
}),
);secrets maps Cloudflare secret names to paths in the decrypted document. Omit
it to import every scalar leaf; generated names are derived from dot paths, and
namePrefix can add a prefix to every generated name.
cloudflareSopsWorkerBindings(imported, ["API_TOKEN"]) binds a Worker variable
to the Secrets Store secret with the same name. Pass an object when the Worker
binding name should differ from the stored secret name:
yield *
worker.bind(
"sops-secrets",
cloudflareSopsWorkerBindings(imported, {
API_TOKEN: "WORKER_API_TOKEN",
}),
);Run the stack with your normal Alchemy deploy command. The Action runs when its input changes, including the encrypted file content, backend options, selected secret paths, scopes, comments, and target store.
Existing Secrets Store entries are replaced by default because Cloudflare does
not allow patching a secret value. Set replaceExisting: false when you only
want to converge scopes and comments for an existing secret name. The Cloudflare
credentials used by the stack must be allowed to manage the target Secrets
Store, and Worker deploy permissions are also needed when the same stack binds
those secrets to a Worker.
Most stacks should call CloudflareSopsSecrets. Use
CloudflareSopsSecretsAction directly only when the encrypted content is
already available and you want to pass the Action input yourself:
import { CloudflareSopsSecretsAction } from "alchemy-sops";
import * as Redacted from "effect/Redacted";
const imported =
yield *
CloudflareSopsSecretsAction("WorkerSecrets", {
path: "secrets.enc.yaml",
content: encryptedSopsYaml,
format: "yaml",
backend: "sops-age",
store: {
accountId: "account-id",
storeId: "store-id",
},
ageKey: Redacted.make(process.env.SOPS_AGE_KEY!),
secrets: {
API_TOKEN: "api.token",
},
});Use GitHubSopsSecrets to materialize selected SOPS values as repository or
environment secrets through Alchemy's GitHub.Secret resource. It requires a
static map of GitHub secret names to SOPS dot paths so that Alchemy can register
one managed resource for each secret.
The stack needs both GitHub.providers() and SopsFileProvider(). GitHub
credentials are resolved by Alchemy; a token needs repo scope for private
repositories or public_repo for public repositories.
import * as Alchemy from "alchemy";
import * as GitHub from "alchemy/GitHub";
import { GitHubSopsSecrets, SopsFileProvider } from "alchemy-sops";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"GitHubActions",
{
providers: Layer.mergeAll(
GitHub.providers(),
SopsFileProvider(),
),
},
Effect.gen(function* () {
yield* GitHubSopsSecrets("DeploySecrets", {
path: "./secrets.enc.yaml",
format: "yaml",
owner: "my-org",
repository: "my-repo",
environment: "production", // omit for repository-wide secrets
ageKey: Redacted.make(process.env.SOPS_AGE_KEY!),
secrets: {
DEPLOY_TOKEN: "github.deployToken",
DATABASE_URL: "database.url",
},
});
}),
);Each secret remains redacted while it flows from SopsFile to GitHub.Secret.
Changing the encrypted SOPS source updates the corresponding GitHub Actions
secret. As with SopsFile, use an Alchemy state store you trust because state
must retain redacted values to reconcile resources.
Alchemy programs can avoid local filesystem and process APIs by using inline encrypted content or a URL source with the native backend:
import { SopsFile } from "alchemy-sops";
const secrets =
yield *
SopsFile("WorkerSecrets", {
content: encryptedSopsJson,
format: "json",
backend: "sops-age",
ageKey: workerEnv.SOPS_AGE_KEY,
});The Alchemy resource entrypoint still imports Alchemy. For code that is bundled
directly into an edge runtime, use the low-level alchemy-sops/edge subpath:
import { runSopsAge } from "alchemy-sops/edge";Every string-like option accepts the same shapes as Alchemy SecretInput:
stringRedacted<string>Effect<string | Redacted<string>>Config<string | Redacted<string>>
Supported options:
path,content,url, orjson: exactly one encrypted source is required.pathmay be an ordered array of local files, merged left to right after decryption.jsonaccepts an imported (encrypted) SOPS JSON document, drives the typeddataoutput, and defaultsformattojson.cwd,sopsBinarybackend:auto,sops-age, orcliformat:auto,json,yaml,dotenv,text, orbinaryinputType,outputType: input/output format hintsextract: passed tosops --extractfor CLI and as a key path forsops-agesopsArgs: extra CLI args; requiresbackend: "cli"or CLI fallbackenv,ageKey,ageKeyFile: SOPS environment inputs;sops-ageuses directageKey/SOPS_AGE_KEYschema: service-freeSchema.Structfromeffect/Schema; validates the decrypted document and enables the typedvalueoutputsecrets: output-name to dot-path selectors; optional legacy flat selectiontypes: return generated TypeScript definitions for the redacted data shape; usetrueor{ exportName: "AppSecrets" }cache,timeoutMs,retry
Provider options:
decrypt: custom decrypt backendmemoize:trueto share in-flight and completed decrypts by request in the current process, or{ key }to provide a custom memoization key
CloudflareSopsSecrets shares the decrypt options above and adds:
store: Cloudflare Secrets Store resource or{ accountId, storeId }namePrefix: prefix for generated Cloudflare secret names whensecretsis omittedscopes: Cloudflare Secrets Store scopes; defaults to["workers"]comment: free-form Cloudflare Secrets Store commentreplaceExisting: delete and recreate matching existing secrets so values converge; defaults totrue
CloudflareSopsSecretsAction also accepts content, the encrypted SOPS
ciphertext to use as Action input. The CloudflareSopsSecrets wrapper fills
that field by reading path.
GitHubSopsSecrets accepts the SopsFile source and decrypt options plus:
owner,repository,environment, andbaseUrl: forwarded to everyGitHub.Secret; omitenvironmentfor repository-wide secretssecrets: required map from GitHub Actions secret names to decrypted SOPS dot-path selectors
The resource returns:
data: nested document with scalar leaves redacted, typed from the imported document whenjsonis usedflat: dot-path map of all redacted leavessecrets: selected redacted leaves, or all leaves whensecretsis omittedvalue: schema-validated typed document whenschemawas providedtopLevelKeys: top-level keys from the decrypted documenttypes: generated TypeScript definitions whentypeswas requestedsourceHash: SHA-256 digest of the encrypted source plus non-secret optionspath,format,version
cache defaults to true. If the encrypted source digest and resource version
are unchanged, the provider returns the previous redacted output without
decrypting again. Set cache: false to force decryption on every deploy.
The Cloudflare Action returns accountId, storeId, path, and an imported
array containing each Cloudflare secret name, source dot path, secret id, and
status.
Redacted<string> prevents accidental printing and logging, but Alchemy state
stores still persist values so they can be revived later. Use a state store you
trust for decrypted secrets.
Symptom: A stack using alchemy-sops/Config dies immediately with a
TypeError inside effect/Context.js at lookup, with no mention of SOPS or
configuration. Stack frames reference two different effect paths, for example
effect@4.0.0-beta.102 and effect@4.0.0-beta.105.
Cause: Two copies of effect are installed. ConfigProvider is a
Context.Reference, so its identity is per-instance: a provider built by one
copy of effect cannot be read by a stack running another. This surfaces most
often when the layer is constructed in a shared workspace package that resolves
a different effect than the app.
Fix: Align effect to a single version across the workspace and reinstall.
For Bun, ls node_modules/.bun | grep '^effect@' should show one version for
application code. effect is a peer dependency of alchemy-sops for exactly
this reason — let the app own the version rather than nesting a second copy.
If a shared package must expose credentials, have it return plain data and let
each stack build the provider with its own effect import.
Symptom: TypeScript reports that Output<…> from secrets.value is not
assignable to the Output<…> expected by alchemy/Output helpers (for example
Output.map), often with a long path mentioning two different
node_modules/alchemy versions and incompatible bind(...) return types
(RuntimeContext vs ExecutionContext).
Cause: Two copies of alchemy are installed. alchemy-sops decrypts via one
instance (for example the version pulled in as its dependency), while your stack
imports alchemy, alchemy/Cloudflare, and alchemy/Output from another.
Output is not portable across versions — even patch differences in the Effect
runtime context break assignability.
Fix:
- Use one
alchemyversion everywhere. Pin the same release in your root / catalog (for example2.0.0-beta.43or newer) and ensure every workspace package that importsalchemyuses that pin, not an older catalog entry. - Deduplicate installs. After aligning versions, run
bun install(or your package manager’s equivalent) and confirm only onealchemyappears undernode_modules(for Bun:ls node_modules/.bun | grep '^alchemy@'should show a single version for app code). - Prefer
alchemyas a peer, not a nested dependency.alchemy-sopslistsalchemyas apeerDependencywith a semver range so your project’salchemyis the one both the stack andSopsFileuse. Avoid relying on a nested copy bundled inside another package.
Example (monorepo catalog):
{
"catalog": {
"alchemy": "2.0.0-beta.57"
}
}Ensure apps/* and packages/* use "alchemy": "catalog:" (or the same exact
version), then reinstall.
If you still see two versions, check overrides / patched dependencies and any
package that pins an older alchemy directly.