Env Vault
Inject env into your deploy with a single bootstrap token; later changes don't need a rebuild. Use getEnv() on the server or useEnv() on the React side — public/private split is automatic.
Overview#
Env Vault provides runtime env management for self-hosted applications. Instead of using process.env, your app authenticates to Sentroy core with a single API key and pulls every registered env. When an admin changes a value, your app picks it up after the cache TTL expires — no Docker rebuild required.
Flow
1. Create a project in the Sentroy admin panel (e.g. my-blog).
2. Add environments (e.g. dev, prod) and variables under the project. Each variable carries a public flag.
3. Generate a token for the project + environment pair. The plaintext is shown only once.
4. Set the token as SENTROY_ENV_API_KEY in your deploy environment.
5. Use getEnv("KEY") or useEnv("KEY") in your app.
Bootstrap#
Single env: SENTROY_ENV_API_KEY. Everything else comes from the vault.
# Coolify (or any deploy environment)
SENTROY_ENV_API_KEY=stk_...
# Optional — defaults to https://sentroy.com
SENTROY_ENV_API_URL=https://sentroy.comThat's it. Every other env (DATABASE_URL, BETTER_AUTH_SECRET, NEXT_PUBLIC_TURNSTILE_SITE_KEY, etc.) lives in the Sentroy admin panel.
Install#
The vault lives under the /vault subpath of @sentroy-co/client-sdk — same package as the mail/storage SDK.
npm install @sentroy-co/client-sdkServer-side: getEnv()#
Async, in-memory cache (TTL 5 min). One HTTP fetch on first call — every subsequent call is in-process.
import { getEnv, getEnvOrThrow, preloadEnv } from "@sentroy-co/client-sdk/vault"
// Load early at process boot — fail-fast on missing envs
await preloadEnv()
// Async — returns undefined if the env is missing
const dbUrl = await getEnv("DATABASE_URL")
// Throws if missing — config-validation pattern
const turnstile = await getEnvOrThrow("BETTER_AUTH_TURNSTILE_SECRET")Cache
Default TTL is 5 minutes. For webhook- or admin-driven invalidation use refreshEnvCache(). To change the TTL at runtime use setEnvCacheTTL(seconds).
React: useEnv()#
Inject public envs from a server component during SSR; the client useEnv() hook reads them synchronously.
// app/layout.tsx (server component)
import { getPublicEnvs } from "@sentroy-co/client-sdk/vault"
import { EnvProvider } from "@sentroy-co/client-sdk/vault/react"
export default async function RootLayout({ children }) {
const envs = await getPublicEnvs()
return (
<html>
<body>
<EnvProvider envs={envs}>{children}</EnvProvider>
</body>
</html>
)
}// any client component
"use client"
import { useEnv } from "@sentroy-co/client-sdk/vault/react"
export function CaptchaWidget() {
const siteKey = useEnv("TURNSTILE_SITE_KEY")
if (!siteKey) return null
return <Turnstile siteKey={siteKey} />
}CLI#
Sync a local .env file to the vault from your terminal or CI.
The SDK ships a sentroybinary. After install it's available on $PATH; npx sentroy ... works without a global install. Auth uses the same SENTROY_ENV_API_KEY as getEnv() (or pass --token=stk_env_...). The token's (project, environment) scope is implicit.
# Push local file to the vault. --delete-missing makes it a full sync;
# without it, push is upsert-only. The CLI prompts before deletes.
sentroy env push .env.production --delete-missing
# Show the diff but write nothing.
sentroy env push .env.production --dry-run
# Pull the vault into a local file. --force overwrites.
sentroy env pull .env.staging --force
# List keys (add --values for KEY=value, --public-only to filter).
sentroy env list --valuesREST API#
Direct access via curl/fetch, without the SDK.
GET /api/env-vault/fetch
Returns every env in the token's scope (public + private). For server-side use.
curl -H "Authorization: Bearer stk_..." \
https://sentroy.com/api/env-vault/fetchGET /api/env-vault/public
Only variables flagged public: true. Browser-safe.
curl -H "Authorization: Bearer stk_..." \
https://sentroy.com/api/env-vault/publicWebhooks#
Real-time invalidation — skip the 5 min cache TTL when a value changes.
Configure a webhook on a project + environment in the vault dashboard. Whenever any variable changes (create, update, or delete), Sentroy POSTs to your URL with an HMAC-SHA256 signature. The default SDK handler verifies the signature and calls refreshEnvCache() — the next getEnv() hits a fresh fetch.
// app/api/sentroy/vault-webhook/route.ts
import { createVaultWebhookHandler } from "@sentroy-co/client-sdk/vault"
export const POST = createVaultWebhookHandler({
secret: process.env.SENTROY_VAULT_WEBHOOK_SECRET!,
})The receiver URL goes into the dashboard; the secret comes back once at create-time and is set as SENTROY_VAULT_WEBHOOK_SECRET in the consuming app.
Payload
{
"event": "vault.variable.changed",
"project": "<projectId>",
"environment": "prod",
"action": "create" | "update" | "delete",
"keys": ["DATABASE_URL", "..."],
"timestamp": 1731430000000
}Headers: X-Sentroy-Signature: sha256=<hex> (HMAC over the raw body), X-Sentroy-Event: vault.variable.changed, X-Sentroy-Webhook-Id. Delivery is fire-and-forget with a 5 sec timeout — last status + error are recorded in the dashboard for visibility but failed deliveries are not retried automatically.
Custom handler
Override the default cache-clear with onChange — useful for targeted invalidation, structured logging, or downstream notifications.
export const POST = createVaultWebhookHandler({
secret: process.env.SENTROY_VAULT_WEBHOOK_SECRET!,
async onChange(payload) {
console.log("vault changed", payload.action, payload.keys)
await refreshEnvCache()
},
})Encryption#
Variable values are encrypted at rest with AES-256-GCM.
The master key lives on Sentroy core in the SENTROY_ENV_MASTER_KEY env. Plaintext is never written to the database — only ciphertext + nonce + auth tag (v1:iv:tag:cipher base64). Decryption happens in the token-auth fetch response.
# Generate a master key (one-time, store in Coolify env)
openssl rand -base64 32
# Add to platform: SENTROY_ENV_MASTER_KEY=<output>Audit log#
Every change records who/what/when — never the value itself.
The audit log never stores the value itself; it writes a sha256(plaintext)checksum as before/after. That makes "did the value change?" comparable, while a log compromise will not leak any plaintext.