Alchemy Effect resource for decrypting SOPS files into redacted secret outputs.
alchemy-sops decrypts SOPS files, parses the decrypted document, and returns
Alchemy outputs whose scalar leaves are Redacted<string>. It prefers the
native sops-age backend for age-encrypted JSON/YAML/dotenv files and keeps the
sops CLI backend for binary files, custom SOPS flags, and non-age backends.
- Install
- Usage
- Cloudflare Secrets Store Action
- Edge usage
- Inputs
- Outputs
- Security note
- Troubleshooting
bun add alchemy-sopsThe 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.
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 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",
},
});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.
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: 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.