feat: add native HashiCorp Vault integration for secrets management#491
feat: add native HashiCorp Vault integration for secrets management#491d0dg3r wants to merge 2 commits intoFinsys:mainfrom
Conversation
|
@d0dg3r can i ask you to sync your fork and rebase on main? sorry for the force push. |
- Add Vault client with Token, AppRole, and Kubernetes authentication - Add .secrets.yaml parser for stack-level secret mappings - Add automatic secret sync from Vault to encrypted stack environment variables - Add triggerRedeploy option for automatic redeployment on secret changes - Add granular sync modes (Git-only, Vault-only, All) - Add Vault settings UI with connection testing - Add comprehensive documentation and tutorial - Support self-signed TLS certificates BREAKING CHANGES: None (Vault integration is optional) Co-authored-by: Cursor <cursoragent@cursor.com>
- Update API calls to include Vault configuration retrieval - Introduce vaultEnabled state to manage UI based on Vault status - Modify sync button logic to conditionally display options for Git, Vault, or both based on Vault integration status - Ensure single sync button is shown when Vault is not active This update improves user experience by providing relevant sync options based on the availability of Vault.
642fdbe to
fd3c731
Compare
There was a problem hiding this comment.
Pull request overview
Adds an optional HashiCorp Vault integration to Dockhand so Git stacks can map Vault secrets (via .secrets.yaml) into encrypted stack environment variables, with new UI + API support for configuring Vault and triggering Git/Vault sync flows.
Changes:
- Introduces Vault client + secret sync pipeline (
vault.ts,vault-sync.ts,.secrets.yamlparsing) and associated API endpoints. - Adds Settings → Vault UI for configuring Vault auth/TLS options and testing connectivity.
- Updates stacks UI + Git sync API to support sync modes (
git/vault/all) and adds DB schema/migrations for persisted Vault config.
Reviewed changes
Copilot reviewed 30 out of 31 changed files in this pull request and generated 21 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/server/vault.ts | Vault client + auth methods + TLS skip support + connection test |
| src/lib/server/vault-sync.ts | Reads .secrets.yaml, fetches secrets from Vault, stores into stack env vars |
| src/lib/server/secrets-file.ts | Parser/normalizer for .secrets.yaml mappings and triggerRedeploy flags |
| src/routes/api/vault/config/+server.ts | CRUD API for Vault configuration (no sensitive values returned) |
| src/routes/api/vault/test/+server.ts | API to test Vault connectivity with provided or saved config |
| src/routes/api/vault/fetch-test/+server.ts | API to test secret fetch behavior and key presence |
| src/routes/api/git/stacks/[id]/sync/+server.ts | Adds sync mode support; integrates Vault sync and optional auto-deploy logic |
| src/routes/api/stacks/[name]/secrets/sync/+server.ts | Manual per-stack Vault secret sync endpoint |
| src/routes/settings/vault/VaultTab.svelte | Settings UI for configuring Vault + testing + saving |
| src/routes/settings/+page.svelte | Adds Vault tab to settings page |
| src/routes/stacks/+page.svelte | Adds separate Git/Vault/All sync buttons and vault-enabled detection |
| src/routes/stacks/GitDeployProgressPopover.svelte | Adds mode handling and sync endpoint selection |
| src/lib/server/db.ts | Adds Vault config persistence + upsertStackEnvVars helper |
| src/lib/server/db/schema/index.ts | Adds SQLite vault_config table schema + exported types |
| src/lib/server/db/schema/pg-schema.ts | Adds Postgres vault_config table schema |
| src/lib/server/db/drizzle.ts | Exposes vaultConfig and adds it to required tables list |
| drizzle/, drizzle-pg/ | Adds migrations + updates drizzle journals |
| src/lib/server/git.ts | Exposes repo-path helper and integrates Vault sync into Git stack sync |
| src/lib/server/host-path.ts | Improves HOST_DATA_DIR override behavior by still populating mount cache |
| src/lib/config/grid-columns.ts | Widens stacks actions column for extra buttons |
| docs/VAULT_INTEGRATION.md | Architecture + API documentation for the Vault feature |
| docs/VAULT_TUTORIAL.md | End-to-end tutorial including Docker dev Vault setup |
| docs/examples/vault-test/* | Example stack demonstrating Vault secret injection |
| .gitignore | Expands ignore patterns for typical build/artifact files |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // If sync detected changes that require redeploy (e.g., triggerRedeploy secrets changed), | ||
| // automatically deploy the stack | ||
| if (syncResult.success && syncResult.updated) { | ||
| console.log(`${logPrefix} Changes detected (updated=true), triggering auto-deploy...`); | ||
| // Use force: true because syncGitStack already detected changes and saved secrets | ||
| // The deploy's internal sync would see no changes since secrets are already updated in DB | ||
| const deployResult = await deployGitStack(id, { force: true }); |
There was a problem hiding this comment.
This endpoint now auto-deploys whenever syncResult.updated is true. In syncGitStack, updated is used for any Git/compose-dir change detection (not just triggerRedeploy secret changes), so clicking “sync” can unexpectedly redeploy stacks. If auto-deploy is intended only for triggerRedeploy secrets, return a separate flag/reason from sync and gate auto-deploy on that instead of updated.
| const fetchOptions: RequestInit & { tls?: { rejectUnauthorized: boolean } } = { | ||
| method, | ||
| headers, | ||
| body: body ? JSON.stringify(body) : undefined | ||
| }; | ||
|
|
||
| // Allow self-signed certificates if configured | ||
| if (this.skipTlsVerify) { | ||
| fetchOptions.tls = { rejectUnauthorized: false }; | ||
| } |
There was a problem hiding this comment.
fetchOptions.tls = { rejectUnauthorized: false } is Bun-specific and is ignored/invalid under Node’s fetch/undici. Since the repo supports a Node entrypoint, skipTlsVerify won’t work there. Consider implementing requests via https.Agent / undici Dispatcher when running under Node, or routing through an existing HTTPS helper to ensure consistent TLS behavior across runtimes.
| console.log(`[Vault Fetch Test] Fetching from path: ${fullPath}`); | ||
| console.log(`[Vault Fetch Test] Looking for keys: ${body.keys.join(', ')}`); | ||
|
|
There was a problem hiding this comment.
This handler logs Vault paths and requested keys to stdout. Even without values, key names/path structure can be sensitive and this will be noisy in production. Consider removing these logs or gating them behind a debug flag / structured logger with a configurable level.
| const now = new Date().toISOString(); | ||
|
|
||
| for (const v of variables) { | ||
| // Check if variable exists | ||
| let existing; | ||
| if (environmentId === null) { | ||
| existing = await db.select() | ||
| .from(stackEnvironmentVariables) | ||
| .where(and( | ||
| eq(stackEnvironmentVariables.stackName, stackName), | ||
| isNull(stackEnvironmentVariables.environmentId), | ||
| eq(stackEnvironmentVariables.key, v.key) | ||
| )) | ||
| .limit(1); | ||
| } else { | ||
| existing = await db.select() | ||
| .from(stackEnvironmentVariables) | ||
| .where(and( | ||
| eq(stackEnvironmentVariables.stackName, stackName), | ||
| eq(stackEnvironmentVariables.environmentId, environmentId), | ||
| eq(stackEnvironmentVariables.key, v.key) | ||
| )) | ||
| .limit(1); | ||
| } | ||
|
|
||
| const encryptedValue = v.isSecret ? (encrypt(v.value) ?? '') : v.value; | ||
|
|
||
| if (existing.length > 0) { | ||
| // Update existing | ||
| await db.update(stackEnvironmentVariables) | ||
| .set({ | ||
| value: encryptedValue, | ||
| isSecret: v.isSecret ?? false, | ||
| updatedAt: now | ||
| }) | ||
| .where(eq(stackEnvironmentVariables.id, existing[0].id)); | ||
| } else { | ||
| // Insert new | ||
| await db.insert(stackEnvironmentVariables).values({ | ||
| stackName, | ||
| environmentId, | ||
| key: v.key, | ||
| value: encryptedValue, | ||
| isSecret: v.isSecret ?? false, | ||
| createdAt: now, | ||
| updatedAt: now | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
upsertStackEnvVars performs a select (and then update/insert) per variable, which can become N+1 DB queries for stacks with many secrets. Consider batching this in a transaction and using an upsert/ON CONFLICT strategy (or a single query to fetch existing keys first) to reduce DB round-trips.
| const now = new Date().toISOString(); | |
| for (const v of variables) { | |
| // Check if variable exists | |
| let existing; | |
| if (environmentId === null) { | |
| existing = await db.select() | |
| .from(stackEnvironmentVariables) | |
| .where(and( | |
| eq(stackEnvironmentVariables.stackName, stackName), | |
| isNull(stackEnvironmentVariables.environmentId), | |
| eq(stackEnvironmentVariables.key, v.key) | |
| )) | |
| .limit(1); | |
| } else { | |
| existing = await db.select() | |
| .from(stackEnvironmentVariables) | |
| .where(and( | |
| eq(stackEnvironmentVariables.stackName, stackName), | |
| eq(stackEnvironmentVariables.environmentId, environmentId), | |
| eq(stackEnvironmentVariables.key, v.key) | |
| )) | |
| .limit(1); | |
| } | |
| const encryptedValue = v.isSecret ? (encrypt(v.value) ?? '') : v.value; | |
| if (existing.length > 0) { | |
| // Update existing | |
| await db.update(stackEnvironmentVariables) | |
| .set({ | |
| value: encryptedValue, | |
| isSecret: v.isSecret ?? false, | |
| updatedAt: now | |
| }) | |
| .where(eq(stackEnvironmentVariables.id, existing[0].id)); | |
| } else { | |
| // Insert new | |
| await db.insert(stackEnvironmentVariables).values({ | |
| stackName, | |
| environmentId, | |
| key: v.key, | |
| value: encryptedValue, | |
| isSecret: v.isSecret ?? false, | |
| createdAt: now, | |
| updatedAt: now | |
| }); | |
| } | |
| } | |
| if (variables.length === 0) { | |
| return; | |
| } | |
| const now = new Date().toISOString(); | |
| const keys = variables.map((v) => v.key); | |
| const existing = await db.select() | |
| .from(stackEnvironmentVariables) | |
| .where(and( | |
| eq(stackEnvironmentVariables.stackName, stackName), | |
| environmentId === null | |
| ? isNull(stackEnvironmentVariables.environmentId) | |
| : eq(stackEnvironmentVariables.environmentId, environmentId), | |
| inArray(stackEnvironmentVariables.key, keys) | |
| )); | |
| const existingByKey = new Map(existing.map((row) => [row.key, row])); | |
| await db.transaction(async (tx) => { | |
| for (const v of variables) { | |
| const existingRow = existingByKey.get(v.key); | |
| const encryptedValue = v.isSecret ? (encrypt(v.value) ?? '') : v.value; | |
| if (existingRow) { | |
| await tx.update(stackEnvironmentVariables) | |
| .set({ | |
| value: encryptedValue, | |
| isSecret: v.isSecret ?? false, | |
| updatedAt: now | |
| }) | |
| .where(eq(stackEnvironmentVariables.id, existingRow.id)); | |
| } else { | |
| await tx.insert(stackEnvironmentVariables).values({ | |
| stackName, | |
| environmentId, | |
| key: v.key, | |
| value: encryptedValue, | |
| isSecret: v.isSecret ?? false, | |
| createdAt: now, | |
| updatedAt: now | |
| }); | |
| } | |
| } | |
| }); |
| ```bash | ||
| # Save Vault configuration | ||
| curl --request PUT \ | ||
| --header "Content-Type: application/json" \ | ||
| --data '{ | ||
| "enabled": true, | ||
| "url": "http://localhost:8200", | ||
| "authMethod": "token", | ||
| "token": "myroot", | ||
| "defaultPath": "secret/data/dockhand-test", | ||
| "skipTlsVerify": false | ||
| }' \ | ||
| http://localhost:3000/api/vault/config |
There was a problem hiding this comment.
The curl example uses "url" in the request body for /api/vault/config, but the actual API expects address. Please update the tutorial examples to match the implemented API fields so copy/paste works.
| // Resolve environment ID from stack_sources if not provided | ||
| // This handles cases where git_stacks.environment_id is NULL but stack_sources has the correct value | ||
| let resolvedEnvId = environmentId; | ||
| if (resolvedEnvId === null || resolvedEnvId === undefined) { | ||
| try { | ||
| // Try to find the stack source with any environment ID | ||
| const { db, stackSources, eq } = await import('./db/drizzle.js'); | ||
| const sources = await db.select().from(stackSources) | ||
| .where(eq(stackSources.stackName, stackName)); | ||
|
|
||
| // Use the first source's environment ID if available | ||
| if (sources.length > 0 && sources[0].environmentId !== null) { | ||
| resolvedEnvId = sources[0].environmentId; | ||
| console.log(`[Vault] Resolved environment ID ${resolvedEnvId} for stack "${stackName}" from stack_sources`); |
There was a problem hiding this comment.
The environment ID auto-resolution queries stack_sources by stackName only and then picks the first row. Since stack_sources is unique on (stackName, environmentId), the same stack name can exist in multiple environments; this logic can resolve the wrong environment and write secrets to the wrong env (cross-environment secret leakage). Prefer requiring a non-null environmentId from the caller, or if you must resolve it, query in a way that guarantees a single unambiguous match (and fail fast if multiple rows exist).
| // Resolve environment ID from stack_sources if not provided | |
| // This handles cases where git_stacks.environment_id is NULL but stack_sources has the correct value | |
| let resolvedEnvId = environmentId; | |
| if (resolvedEnvId === null || resolvedEnvId === undefined) { | |
| try { | |
| // Try to find the stack source with any environment ID | |
| const { db, stackSources, eq } = await import('./db/drizzle.js'); | |
| const sources = await db.select().from(stackSources) | |
| .where(eq(stackSources.stackName, stackName)); | |
| // Use the first source's environment ID if available | |
| if (sources.length > 0 && sources[0].environmentId !== null) { | |
| resolvedEnvId = sources[0].environmentId; | |
| console.log(`[Vault] Resolved environment ID ${resolvedEnvId} for stack "${stackName}" from stack_sources`); | |
| // Resolve environment ID from stack_sources if not provided. | |
| // This handles cases where git_stacks.environment_id is NULL but stack_sources has the correct value. | |
| // Only resolve when there is exactly one distinct non-null environment ID for the stack name. | |
| let resolvedEnvId = environmentId; | |
| if (resolvedEnvId === null || resolvedEnvId === undefined) { | |
| try { | |
| const { db, stackSources, eq } = await import('./db/drizzle.js'); | |
| const sources = await db.select().from(stackSources) | |
| .where(eq(stackSources.stackName, stackName)); | |
| const candidateEnvIds = [...new Set( | |
| sources | |
| .map((source) => source.environmentId) | |
| .filter((id): id is number => id !== null) | |
| )]; | |
| if (candidateEnvIds.length === 1) { | |
| resolvedEnvId = candidateEnvIds[0]; | |
| console.log(`[Vault] Resolved environment ID ${resolvedEnvId} for stack "${stackName}" from stack_sources`); | |
| } else if (candidateEnvIds.length > 1) { | |
| return { | |
| success: false, | |
| synced: 0, | |
| errors: [ | |
| `Could not resolve environment ID for stack "${stackName}": multiple environments found in stack_sources (${candidateEnvIds.join(', ')})` | |
| ], | |
| skipped: false, | |
| secretsChanged: false, | |
| triggerRedeploySecrets: [] | |
| }; |
| const isChanged = oldValue === undefined || oldValue !== secret.value; | ||
|
|
||
| // #region agent log | ||
| fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:195',message:'secret comparison',data:{key:secret.key,oldValueExists:oldValue!==undefined,newValuePreview:secret.value?.substring(0,10),isChanged,triggerRedeploy:triggerRedeployMap.get(secret.key)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'B'})}).catch(()=>{}); |
There was a problem hiding this comment.
This debug logging includes a newValuePreview derived from the secret value (substring(0,10)). Even a preview can leak secrets into logs/telemetry. Remove any logging of secret values (including partials/hashes) from the sync path.
| fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:195',message:'secret comparison',data:{key:secret.key,oldValueExists:oldValue!==undefined,newValuePreview:secret.value?.substring(0,10),isChanged,triggerRedeploy:triggerRedeployMap.get(secret.key)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'B'})}).catch(()=>{}); | |
| fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:195',message:'secret comparison',data:{key:secret.key,oldValueExists:oldValue!==undefined,isChanged,triggerRedeploy:triggerRedeployMap.get(secret.key)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'B'})}).catch(()=>{}); |
| if (typeof item === 'string') { | ||
| return { | ||
| name: item.toUpperCase(), | ||
| key: item.toLowerCase(), | ||
| triggerRedeploy: undefined // Will use global default |
There was a problem hiding this comment.
For the “simple mapping” form, the code lowercases the Vault key (key: item.toLowerCase()). This contradicts the docs/examples that expect the Vault key name to match the env var name (e.g. DATABASE_PASSWORD). It will cause lookups to fail whenever Vault keys are uppercase or mixed-case. Consider defaulting key to the original string and only changing case when the user explicitly specifies a different key.
| if (typeof item === 'object' && item.name) { | ||
| return { | ||
| name: item.name, | ||
| key: item.key || item.name.toLowerCase(), |
There was a problem hiding this comment.
In the object form, key defaults to item.name.toLowerCase(). This has the same case-sensitivity problem as the simple form and can silently break secret fetching for stacks that use uppercase Vault keys. Default key to item.name (or leave undefined and treat as “same as name”).
| key: item.key || item.name.toLowerCase(), | |
| key: item.key || item.name, |
| import type { RequestHandler } from './$types'; | ||
| import { getGitStack } from '$lib/server/db'; | ||
| import { syncGitStack } from '$lib/server/git'; | ||
| import { getGitStack, getStackSource } from '$lib/server/db'; |
There was a problem hiding this comment.
getStackSource is imported but never used. Please remove it to avoid unused-import failures in typechecking/linting.
| import { getGitStack, getStackSource } from '$lib/server/db'; | |
| import { getGitStack } from '$lib/server/db'; |
Proposed change
Summary
This PR adds native HashiCorp Vault integration to Dockhand, enabling stacks to fetch secrets directly from Vault and inject them as environment variables during deployment.
Motivation
Note: This is more of a Proof of Concept than a polished PR. I found it easier to demonstrate the feature through working code rather than just describing it in words.
Users using HashiCorp Vault as their secrets management solution currently have no way to integrate it directly with Dockhand. This creates friction and potential security gaps when managing Docker stacks. This feature bridges that gap while maintaining Dockhand's simplicity and security focus.
I'm open to feedback on the approach and happy to refine the implementation based on your guidance.
Changes
New Files:
src/lib/server/vault.ts- Vault client with Token/AppRole/Kubernetes authsrc/lib/server/vault-sync.ts- Secret synchronization logicsrc/lib/server/secrets-file.ts-.secrets.yamlparsersrc/routes/api/vault/*- API endpoints for Vault configurationsrc/routes/settings/vault/VaultTab.svelte- Settings UI componentdocs/VAULT_INTEGRATION.md- Architecture documentationdocs/VAULT_TUTORIAL.md- Step-by-step setup guidedocs/examples/vault-test/- Example stack filesDatabase Migrations:
drizzle/0004_add_vault_config.sql/drizzle-pg/0004_add_vault_config.sqldrizzle/0005_add_vault_tls_skip.sql/drizzle-pg/0005_add_vault_tls_skip.sqlModified Files:
vaultConfigtablesrc/lib/server/git.ts- Vault sync integration in Git stack flowsrc/routes/stacks/+page.svelte- Added Git/Vault/All sync buttonssrc/lib/config/grid-columns.ts- Wider actions column for new buttonsFeatures
.secrets.yamltriggerRedeployflag for automatic redeployment on secret changeHow to Test
docker run -d --name vault-dev -p 8200:8200 \ -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' \ hashicorp/vault:latestConfigure in Dockhand: Settings > Vault > Enter URL and Token > Test Connection
Create Git stack with
.secrets.yaml:Full tutorial available in
docs/VAULT_TUTORIAL.mdDependencies
No new runtime dependencies (uses native
fetchfor Vault API)Breaking Changes
None. Vault integration is fully optional and disabled by default.
Closes #(issue or discussion)
Type of change