Skip to content

feat: add native HashiCorp Vault integration for secrets management#491

Open
d0dg3r wants to merge 2 commits intoFinsys:mainfrom
d0dg3r:feature/vault-integration
Open

feat: add native HashiCorp Vault integration for secrets management#491
d0dg3r wants to merge 2 commits intoFinsys:mainfrom
d0dg3r:feature/vault-integration

Conversation

@d0dg3r
Copy link
Copy Markdown

@d0dg3r d0dg3r commented Feb 6, 2026

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 auth
  • src/lib/server/vault-sync.ts - Secret synchronization logic
  • src/lib/server/secrets-file.ts - .secrets.yaml parser
  • src/routes/api/vault/* - API endpoints for Vault configuration
  • src/routes/settings/vault/VaultTab.svelte - Settings UI component
  • docs/VAULT_INTEGRATION.md - Architecture documentation
  • docs/VAULT_TUTORIAL.md - Step-by-step setup guide
  • docs/examples/vault-test/ - Example stack files

Database Migrations:

  • drizzle/0004_add_vault_config.sql / drizzle-pg/0004_add_vault_config.sql
  • drizzle/0005_add_vault_tls_skip.sql / drizzle-pg/0005_add_vault_tls_skip.sql

Modified Files:

  • Schema files for vaultConfig table
  • src/lib/server/git.ts - Vault sync integration in Git stack flow
  • src/routes/stacks/+page.svelte - Added Git/Vault/All sync buttons
  • src/lib/config/grid-columns.ts - Wider actions column for new buttons

Features

Feature Description
Multiple Auth Methods Token, AppRole, Kubernetes
.secrets.yaml Stack-level secret mappings in Git repos
Flexible Mapping Simple or explicit key mapping with path overrides
Auto-Redeploy triggerRedeploy flag for automatic redeployment on secret change
Granular Sync Separate Git / Vault / All sync buttons in UI
Self-signed TLS Skip TLS verification option for internal Vault servers

How to Test

  1. Setup Vault (Docker dev server):
docker run -d --name vault-dev -p 8200:8200 \
  -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' \
  hashicorp/vault:latest
  1. Create test secrets:
export VAULT_ADDR='http://localhost:8200'
export VAULT_TOKEN='myroot'
vault kv put secret/test DATABASE_PASSWORD="secret123"
  1. Configure in Dockhand: Settings > Vault > Enter URL and Token > Test Connection

  2. Create Git stack with .secrets.yaml:

vault:
  path: secret/data/test
secrets:
  - DATABASE_PASSWORD
  1. Sync and Deploy: Use the sync buttons to fetch secrets and deploy

Full tutorial available in docs/VAULT_TUTORIAL.md

Dependencies

No new runtime dependencies (uses native fetch for Vault API)

Breaking Changes

None. Vault integration is fully optional and disabled by default.

image image

Closes #(issue or discussion)

Type of change

  • Bug fix: non-breaking change which fixes an issue.
  • [ x] New feature / Enhancement: non-breaking change which adds functionality.
  • Breaking change: fix or feature that would cause existing functionality to not work as expected.
  • Other. Please explain:

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Feb 6, 2026

CLA assistant check
All committers have signed the CLA.

@d0dg3r d0dg3r marked this pull request as draft February 7, 2026 19:10
@lucas-fs
Copy link
Copy Markdown

lucas-fs commented Feb 7, 2026

@d0dg3r Nice work on this PoC! 🚀
This definitely can be used as inspiration to add the functionality mentioned on #243

@d0dg3r
Copy link
Copy Markdown
Author

d0dg3r commented Feb 7, 2026

@d0dg3r Nice work on this PoC! 🚀 This definitely can be used as inspiration to add the functionality mentioned on #243

Thanks, yes exactly.

@d0dg3r d0dg3r marked this pull request as ready for review February 15, 2026 13:29
@jotka
Copy link
Copy Markdown
Contributor

jotka commented Apr 20, 2026

@d0dg3r can i ask you to sync your fork and rebase on main?

  git fetch upstream
  git rebase upstream/main
  git push --force

sorry for the force push.

d0dg3r and others added 2 commits April 20, 2026 18:33
- 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.
@d0dg3r d0dg3r force-pushed the feature/vault-integration branch from 642fdbe to fd3c731 Compare April 20, 2026 16:33
Copilot AI review requested due to automatic review settings April 20, 2026 16:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yaml parsing) 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.

Comment on lines +74 to +80
// 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 });
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/server/vault.ts
Comment on lines +93 to +102
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 };
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +83
console.log(`[Vault Fetch Test] Fetching from path: ${fullPath}`);
console.log(`[Vault Fetch Test] Looking for keys: ${body.keys.join(', ')}`);

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/server/db.ts
Comment on lines +4891 to +4939
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
});
}
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
});
}
}
});

Copilot uses AI. Check for mistakes.
Comment thread docs/VAULT_TUTORIAL.md
Comment on lines +330 to +342
```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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +66
// 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`);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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: []
};

Copilot uses AI. Check for mistakes.
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(()=>{});
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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(()=>{});

Copilot uses AI. Check for mistakes.
Comment on lines +187 to +191
if (typeof item === 'string') {
return {
name: item.toUpperCase(),
key: item.toLowerCase(),
triggerRedeploy: undefined // Will use global default
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
if (typeof item === 'object' && item.name) {
return {
name: item.name,
key: item.key || item.name.toLowerCase(),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”).

Suggested change
key: item.key || item.name.toLowerCase(),
key: item.key || item.name,

Copilot uses AI. Check for mistakes.
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { syncGitStack } from '$lib/server/git';
import { getGitStack, getStackSource } from '$lib/server/db';
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStackSource is imported but never used. Please remove it to avoid unused-import failures in typechecking/linting.

Suggested change
import { getGitStack, getStackSource } from '$lib/server/db';
import { getGitStack } from '$lib/server/db';

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants