Configuration Reference¶
dotvault uses a YAML configuration file. The file location depends on your platform:
| Platform | Path |
|---|---|
| Linux | /etc/xdg/dotvault/config.yaml (also checks $XDG_CONFIG_DIRS) |
| macOS | /Library/Application Support/dotvault/config.yaml |
| Windows | %ProgramData%\dotvault\config.yaml |
That file is system-wide and normally administrator-owned. There is also an optional per-user file, which may carry only the fuse section and is merged over the system configuration:
| Platform | Path |
|---|---|
| Linux | ${XDG_CONFIG_HOME:-~/.config}/dotvault/config.yaml |
| macOS | ~/Library/Application Support/dotvault/config.yaml |
| Windows | %APPDATA%\dotvault\config.yaml |
Every other section is a hard error there, so the per-user file can never re-point the Vault, open a listener, or alter telemetry. See Per-user preferences.
You can override the config path with --config:
--config is gated by the system-wide configuration
--config is honoured only when there is no system-wide configuration, or the system-wide configuration explicitly opts in with bypass_system_config: true. If a system config is present and does not set the flag, dotvault refuses the override and exits with an error rather than silently loading the command-line file. This is identical on every platform; the "system-wide configuration" is the Windows Group Policy registry policy when present, otherwise the system YAML file. See bypass_system_config below.
Windows Group Policy
On Windows, if Group Policy registry keys exist at HKLM\SOFTWARE\Policies\goodtune\dotvault, dotvault loads all configuration from the registry and ignores the YAML file entirely. Pointing dotvault at a different file with --config requires bypass_system_config: true in that policy (the registry equivalent is a BypassSystemConfig REG_DWORD of 1 directly under the policy key). See Windows Group Policy for details.
Full example¶
vault:
address: "https://vault.example.com:8200"
auth_method: "oidc"
auth_role: "default"
auth_mount: "oidc"
oidc_callback_port: 8250
kv_mount: "kv"
user_prefix: "users/"
ca_cert: "/etc/ssl/certs/internal-ca.pem"
tls_skip_verify: false
sync:
interval: "15m"
web:
enabled: true
listen: "127.0.0.1:9000"
login_text: |
Welcome to dotvault. Click **Login** to authenticate via SSO.
secret_view_text: |
These secrets are synchronised from Vault to your local machine.
editable_paths:
- personal
api:
enabled: true
unix:
path: "" # default: $XDG_RUNTIME_DIR/dotvault/api.sock
fuse:
enabled: true
mountpoint: "~/.dotvault"
read_write: false
cache_ttl: "30s"
rules:
- name: gh
vault_key: "gh"
target:
path: "~/.config/gh/hosts.yml"
format: yaml
template: |
github.com:
oauth_token: "{{ .oauth_token }}"
- name: ssh-key
vault_key: "ssh"
target:
path: "~/.ssh/id_ed25519"
format: text
- name: netrc
vault_key: "netrc"
target:
path: "~/.netrc"
format: netrc
enrolments:
gh:
engine: github
settings:
scopes:
- repo
- read:org
- gist
Top-level options¶
| Field | Type | Default | Description |
|---|---|---|---|
bypass_system_config |
bool | false |
Permit the --config command-line override on this machine (see below) |
bypass_system_config¶
By default, when a system-wide configuration is present, the --config command-line flag is refused — a managed deployment (a Windows Group Policy registry policy, or a system config file shipped by configuration management) cannot be sidestepped from the command line. Setting bypass_system_config: true in the system-wide config re-enables the override on that machine.
The intended workflow: an administrator normally pins the system config, but flips this flag when they need to trial a hand-edited config without un-deploying the policy. It only has an effect when set in the authoritative system config — setting it inside a file passed to --config is meaningless, because that file is only loaded once the override has already been allowed.
The behaviour is identical on every platform. On Windows GPO the equivalent registry value is a BypassSystemConfig REG_DWORD of 1 directly under HKLM\SOFTWARE\Policies\goodtune\dotvault.
Vault section¶
| Field | Type | Default | Description |
|---|---|---|---|
address |
string | (required) | Vault server URL |
auth_method |
string | — | Authentication method: oidc, ldap, token, mtls, mtls+tpm, or mtls+os (any base method also accepts a +tpm suffix) |
auth_mount |
string | — | Vault auth mount path (e.g. oidc, ldap) |
auth_role |
string | — | Vault auth role to request |
oidc_callback_port |
int | 8250 |
Fixed local TCP port the OIDC CLI flow (dotvault login) binds for the OAuth redirect_uri; falls back to a random port if unavailable. See OIDC & SSO Authentication |
policies |
list | — | Least-privilege policy set the working token should carry (see below) |
no_default_policy |
bool | false |
Strip the implicit default policy from the working token (see below) |
kv_mount |
string | kv |
KVv2 secrets engine mount path |
user_prefix |
string | users/ |
Prefix for per-user secret paths (trailing slash enforced) |
ca_cert |
string | — | Path to CA certificate for TLS verification |
tls_skip_verify |
bool | false |
Skip TLS certificate verification (development only) |
disable_token_renewal |
bool | false |
Never call RenewSelf; TTL expiry still triggers re-auth |
token_socket |
string or list | ~/.ssh/dotvault.sock, ~/.ssh/dotvault.*.sock |
Peer dotvault socket patterns to borrow a token from and fan peer actions out to (see below); [] disables |
borrow_only |
bool | false |
Forbid this host from ever running its own fresh-auth flow; it only ever borrows a token via token_socket (see below) |
Secret paths are constructed as: {kv_mount}/data/{user_prefix}{username}/{vault_key}
policies / no_default_policy — least-privilege tokens¶
By default dotvault runs with whatever policies its auth role (OIDC/LDAP/cert) grants the user. For a human that is often the union of everything that user can do in Vault — far more than dotvault needs to mirror a handful of secrets. A token that is cached on disk (~/.dotvault-token) is a standing credential; over-provisioning it widens the blast radius if the file ever leaks.
Set policies to the minimal set dotvault actually needs (typically a read-only policy over the user's KV prefix). When it is non-empty, dotvault does not use the login token directly: immediately after authenticating it exchanges that token for a child token restricted to exactly those policies and runs with — and persists — the child. Vault enforces that the requested set is a subset of the login token's own policies, so this can only ever drop privilege, never escalate it. The narrowing applies identically on every auth path (CLI OIDC/LDAP/mTLS and the web UI). The token auth method is exempt — there you supply the token and own its scope.
no_default_policy: true additionally strips Vault's implicit default policy from the working token. Combine the two to pin the token to precisely the capabilities dotvault uses.
vault:
address: "https://vault.example.com:8200"
auth_method: "oidc"
policies:
- dotvault-sync # a read-only policy over kv/data/users/<you>/*
no_default_policy: true
Your policy must grant token self-management
Stripping the default policy also strips the token-self paths dotvault relies on, so any policy you name in policies must grant all three of the following. This is not optional — two of the three fail in ways that look like something else entirely:
# Downscope exchanges the login token for a least-privilege child.
# Missing: login fails closed with "permission denied" on auth/token/create.
path "auth/token/create" {
capabilities = ["create", "update"]
}
# The token lifecycle manager's health check, every 5 minutes.
# Missing: the check returns 403, which dotvault reads as "token invalid",
# so the daemon re-authenticates in a permanent loop while holding a
# perfectly good token. This is the nastiest of the three to diagnose.
path "auth/token/lookup-self" {
capabilities = ["read"]
}
# Renewal at 75% of TTL.
# Missing: every token runs to expiry and forces an avoidable re-auth.
path "auth/token/renew-self" {
capabilities = ["update"]
}
The dev stack's dotvault policy in docker-compose.yaml includes these three. The requirement is verified by test/integration/mtls_test.go, which exercises a real downscoped login end to end.
Certificate auth needs two more
Under mtls, mtls+tpm, or mtls+os the daemon also rotates its own certificate and retires the one it replaces, both headless and both using this same downscoped token. That needs pki/sign/<role> (mint the replacement) and pki/revoke (retire the superseded certificate) on top of the three above. Without pki/sign the certificate runs to expiry and the host needs a fresh human bootstrap; without pki/revoke rotation still works but each superseded certificate stays valid at the CA until its own TTL ends.
pki/revoke is not scopeable to a host's own certificates — read the trade-off in What your Vault admin must set up before granting it, and note that revoke_superseded: false is the supported way to decline it without collecting a warning on every rotation. The dev stack's dotvault policy grants both and is a working reference for the whole cert-auth lifecycle.
This is a per-deployment concern — dotvault ships no default policy list, because the right policy name(s) depend entirely on your Vault policy layout. The downscoped child token is renewable and managed by the normal token lifecycle; when it expires dotvault re-authenticates and re-narrows.
Staged rollout toward 1.0
Today no_default_policy defaults to false and an unset policies keeps the historical "carry every granted policy" behaviour — so existing installs are unaffected. dotvault logs a one-line warning at each fresh login when no restriction is configured, nudging operators to opt in. A future release will flip the no_default_policy default to true, and the 1.0 release will remove the ability to run with the default policy attached at all. dotvault is pre-1.0, so this deliberately-breaking transition runs over a few releases; configure policies now to be ready. On Windows GPO the equivalents are a Policies REG_MULTI_SZ and a NoDefaultPolicy REG_DWORD under HKLM\SOFTWARE\Policies\goodtune\dotvault\Vault.
token_socket — dotvault-to-dotvault token sharing¶
When token_socket points at a Unix-domain socket served by another dotvault daemon's web API, dotvault tries to borrow a live Vault token from that peer before falling back to its own authentication. The borrow is attempted everywhere dotvault would otherwise authenticate interactively or block waiting for a token: on a fresh login (dotvault login, or daemon/CLI startup when no cached token is usable — a still-valid cached token short-circuits first and never reaches the borrow); during headless daemon startup, where a daemon with no web UI and no terminal borrows directly instead of idling until a token file is written; and on the lifecycle manager's recovery path after a cached token has gone invalid. A healthy token that is merely being renewed at 75% TTL (RenewSelf) does not trigger a borrow. It is the programmatic equivalent of:
token_socket is a list of patterns — literal paths or globs whose metacharacters sit in the final path segment (~/.ssh/dotvault.*.sock). A single string is still accepted. Every match is a member of a pool: token borrows try the most recently seen live socket first, while the peer actions (browse, notify, clipboard) are sent to every live socket and succeed if any accepts. A socket that cannot be reached within a few seconds is evicted from the pool — unless it appeared less than two seconds ago, since a forward's socket exists on bind() a moment before it listen()s and a refusal that fresh is a peer about to be fine — and readmitted when it is recreated (immediately on Linux via inotify; on the next attempt elsewhere) or after five minutes. When the key is absent the default is the pair above; set token_socket: [] to disable peer sockets entirely.
Why per-workstation sockets. A single shared path is a race the moment two workstations forward to the same remote: whichever connects last takes over the socket, and the other's forward is silently unbound. The observed case is a laptop and a desktop both managing a forward to the same headless host — the desktop is always up, the laptop wakes sporadically and steals the path, and when the laptop's lid closes the remote has nowhere to borrow from even though the desktop is right there. Naming each workstation's socket after itself (see managed SSH forwards) and letting the borrower hold a pool of every match removes the race instead of papering over it.
The intended deployment: a workstation (e.g. Windows) runs dotvault with the web UI enabled and authenticates interactively. You then SSH from the workstation to a second machine (e.g. a Linux dev box or server), and the SSH RemoteForward exposes the workstation daemon's loopback HTTP listener as a Unix socket created on the remote (devbox) side:
# ~/.ssh/config on the workstation, where `ssh devbox` runs
Host devbox
# Creates /home/me/.ssh/dotvault.laptop.sock ON devbox, forwarding to
# the workstation's web UI at 127.0.0.1:9000.
# One socket per workstation — see the managed-forwards guide.
RemoteForward /home/me/.ssh/dotvault.laptop.sock 127.0.0.1:9000
The remote dotvault then sets token_socket (or leaves the default) and borrows the workstation's token instead of needing its own browser or TTY to authenticate. If the RemoteForward above is itself managed by a keyless sync rule, note the daemon syncs those rules before it authenticates — the file that creates the socket cannot be made to wait on the token that arrives over it. Because the socket listener lives on the borrowing host, this side should be Linux or macOS, where AF_UNIX is fully supported; the workstation only needs the loopback TCP web UI.
On Linux the daemon also watches the socket (inotify) and re-borrows as soon as it materialises or is replaced — so an SSH RemoteForward that connects after the daemon started, or drops and reconnects, is picked up within moments rather than only on the next periodic check.
This socket dies with the SSH session
Everything above depends on the SSH connection being up. A process that outlives the session it was started in — a tmux job, a long-running service — will fail its next borrow once the forward is gone. Enable the api section on the remote host so the long-lived daemon serves the borrow endpoint from a stable path, and local clients keep working across disconnects.
One behaviour change for existing deployments: a peer's /api/v1/token now returns 401 once that peer's daemon knows its own token has gone invalid and is awaiting re-authentication, instead of handing out a credential it knows is dead. Borrowers already validate what they receive, so this only removes a known-bad answer earlier.
The borrow is best-effort and never fatal: if the socket path is empty, the socket file is missing, the socket is stale (left over from a dead SSH session, no listener), the peer is reachable but holds no token, or the response is malformed, dotvault silently carries on with its normal auth flow. A leading ~ is expanded to the user's home directory. The borrowed token is held in memory only — it is not written to the local token file, so the peer remains the single owner and the remote re-borrows on its next login or recovery rather than caching a copy that could go stale.
The same borrow is available to the dotvault client libraries (Go client/ and the Python bindings): their cached-auth entry point (AuthenticateCached) borrows from the configured peer socket after the DOTVAULT_TOKEN env var and token file come up empty, before reporting that a login is required. Because it is a plain socket read with no browser or prompt, a Go or Python program on a host with no local token but a live peer socket reads secrets without an interactive login of its own.
The socket carries traffic the other way too. dotvault browse <url> posts a URL to the peer's POST /api/v1/remote/browse endpoint so the browser opens on the workstation — the machine that actually has one — falling back to the local browser when the peer is unreachable. Set BROWSER="dotvault browse" on the headless host and OAuth login pages launched there land in the workstation's browser. dotvault notify <level> <title> [description] is the same shape for desktop notifications (POST /api/v1/remote/notify), so a long-running job on the headless box can raise a toast/notification on the workstation where a human is looking. dotvault clipboard [text] completes the set (POST /api/v1/remote/clipboard): it puts a value — a one-time token, a device code — on the workstation's clipboard, so after browse opens a login page the user's paste is already loaded with what the page asks for.
dotvault status reflects the borrow too. When no local token is present but the configured peer socket holds one, the auth line reports authenticated and adds a source: borrowed from peer socket (<path>) line, so a host that authenticates purely by borrowing — with no token file at rest — no longer misreports as not authenticated. If the socket is configured but the peer holds no token, status says so explicitly and prints the socket path rather than the bare no token message.
The socket grants the token to anyone who can connect
Any local process or user that can connect() to the forwarded socket can read the Vault token from it — and, via POST /api/v1/remote/browse, POST /api/v1/remote/notify, and POST /api/v1/remote/clipboard, open arbitrary web pages (including phishing pages) in the workstation's browser, raise arbitrary desktop notifications on it, and replace the workstation's clipboard contents (a paste-hijacking primitive — e.g. swapping a copied wallet address or command). dotvault does not create the socket and cannot enforce its permissions — that is the SSH RemoteForward's responsibility (it creates the socket owned by, and typically readable only by, the SSH user). Only enable token_socket on hosts whose other local users you trust, and rely on the remote host's filesystem permissions on the socket path.
borrow_only — forbid a fresh-auth flow entirely¶
borrow_only: true takes the borrow above and makes it the only way this host can ever obtain a Vault token. auth_method (and the mtls block, if present) is simply not consulted: no OIDC browser, no LDAP prompt, no certificate bootstrap, and the web login view shows a waiting card instead of any credential form. Validated to require a non-empty token_socket, since without one this host could never authenticate at all.
The use case is a fleet where one machine — an operator's desktop — is the sole holder of a Vault identity, and every other host it reaches must receive that identity only by borrowing it, never by minting one of its own:
# Remote/headless host's config — shares vault.address, kv_mount, etc. with
# the desktop's config; auth_method can even be left as whatever the desktop
# uses, since it is ignored here.
vault:
address: "https://vault.example.com:8200"
token_socket: "~/.ssh/dotvault.sock" # forwarded from the desktop
borrow_only: true
Reuse of an already-cached token (the token file or DOTVAULT_TOKEN) still applies first, exactly as in every other mode. What happens once that reuse comes up empty then differs by caller. The daemon (dotvault run) idles and keeps retrying the borrow, watching both the token file (for a manually-dropped override) and the socket, rather than failing startup — the same shape a headless host with no interactive facility already uses while waiting for dotvault login to run elsewhere. dotvault sync/--once and login-check's fallback have a job to do that only needs a token, borrowed included, so they make one borrow attempt and fail immediately only once that comes up empty. dotvault login and the Go client/ library's Login are different: their entire purpose — force a fresh login, ignoring the cache — has no meaning under this mode regardless of whether a borrow would happen to succeed right now, so both refuse unconditionally without even attempting one (AuthenticateCached, which never runs a fresh-auth flow to begin with, is unaffected and keeps borrowing normally; the Python bindings expose only AuthenticateCached, not Login).
On Windows GPO the equivalent registry value is a BorrowOnly REG_DWORD under HKLM\SOFTWARE\Policies\goodtune\dotvault\Vault.
For example, with defaults and username jane, the rule vault_key: "gh" reads from kv/data/users/jane/gh.
Sync section¶
| Field | Type | Default | Description |
|---|---|---|---|
interval |
string | 15m |
Polling interval as a Go duration (e.g. 5m, 1h, 30s) |
On Enterprise Vault, dotvault also subscribes to the Events API via WebSocket for near-instant sync on secret changes. The polling interval serves as a fallback.
Web section¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the local web UI |
listen |
string | — | Listen address (must be loopback, e.g. 127.0.0.1:9000) |
login_text |
string | — | Markdown text displayed on the login page |
secret_view_text |
string | — | Markdown text displayed on the secret view page |
editable_paths |
list of strings | — | Folders of your own key space the web UI may create, edit and delete secrets in — one folder name per entry. Empty (the default) keeps the UI read-only |
Loopback only
The listen address must resolve to a loopback address (127.0.0.1, [::1], or localhost). dotvault will refuse to start if a non-loopback address is configured. This is a hard security invariant.
Editable key spaces¶
By default the web UI only reads. editable_paths opts a named part of your key space into full CRUD — see Editing secrets for what that looks like in the browser.
Each entry is a single folder name relative to kv/{user_prefix}{username}/ — your key space is one folder deep, so an entry names a folder and the secrets directly inside it become editable:
With user_prefix: users/ and a user of gary, that makes the secrets in users/gary/personal/ and users/gary/scratch/ editable — personal/token, scratch/todo and so on.
Four things are never editable, whatever this is set to:
- Anything more than one folder deep. Your key space is one folder deep and this setting is a view onto that layout, not a second one: an enrolment key is flat (
gh) or grouped exactly once (databricks/prod), so an entry here names a single folder and an editable secret sits directly inside it.personal/tokenis editable;personal/aws/devis not, and it says so rather than reporting the path as outside the subtree. An entry that is itself nested (scratch/notes) names a secret, not a folder, and is rejected at config load — accepting it would grant editing over a subtree that cannot exist. - The root of your key space.
personal/tokenis editable; a secret sitting at exactlyusers/gary/personalis not, and neither isusers/gary/gh. A root names a folder, and the secret that happens to share its name is a direct child of the key-space root like any other. Admitting the root would put every enrolment credential and every sync rule's source one gesture away from being replaced, which is the blast radius this setting exists to bound — so an entry naming it ("","/") is rejected at config load rather than quietly ignored. - Anything an enrolment writes. If
personal/ghis a configured enrolment key — a grouped enrolment whose group happens to be an editable subtree; a flat key likeghsits at the key-space root and is already excluded by the root rule below — it stays read-only even thoughpersonalis editable: the credential there belongs to the enrolment engine, which will overwrite an edit at its next run or refresh. The rule reads the configuration, not Vault, so it holds before the enrolment has ever run — the path cannot be created by hand and then silently overwritten the first time the engine claims it. The UI says so on the page rather than just omitting the controls. This is evaluated live, so an enrolment added by a remote config refresh takes a path out of reach without a restart. - Any other user's secrets. The path is always resolved beneath your own prefix, and
..segments are rejected rather than collapsed.
editable_paths is validated whether or not web.enabled is set, so a bad entry is reported when you stage the config rather than on the restart that turns the UI on. Entries are canonicalised (/personal/ becomes personal), and a duplicate is an error.
Each configured subtree is listed in the web UI's Secrets sidebar whether or not it exists in Vault yet — a subtree holds nothing until the first secret is written into it, and it has to be reachable for that first write to happen. A list denial or a 404 on a configured subtree is therefore treated as "empty" rather than reported, so a Vault policy that grants write on those paths without granting list on the folder above them still works. Listing failures outside the configured subtrees are still errors.
This is a UI capability, not a Vault permission
dotvault refuses a write outside these subtrees; Vault does not know about them. The token still carries whatever the auth role granted it, so this bounds what the browser can do, not what the daemon could. Narrow the token itself with vault.policies if that is what you need.
API section¶
The api section serves dotvault's web API over a per-user Unix domain socket, in addition to (or instead of) the loopback TCP listener the web section controls.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Serve the API over a Unix socket |
unix.path |
string | $XDG_RUNTIME_DIR/dotvault/api.sock |
Socket path; must be absolute (or ~-relative). Falls back to the cache directory when XDG_RUNTIME_DIR is unset (typical on macOS) |
Why it exists: surviving a dropped SSH session¶
vault.token_socket lets a headless host borrow a token from a workstation over an SSH RemoteForward. That socket dies with the SSH session. A process started inside that session but outliving it — a job in tmux, a long-running service — succeeds at first and then fails the moment it next needs a token, because the only socket it knew about is gone.
Recommended: let dotvault maintain the forward itself
The RemoteForward wiring below is a manual, external ssh process — it only exists while a human is sitting in the session that started it, which is the root cause of the problem this section exists to solve. Where the near-side host also runs dotvault (with agent.enabled), managed SSH forwards let the daemon maintain the connection itself instead: dotvault ssh add <host> registers the far end once, and the daemon reconnects with backoff on its own from then on, with no external process to babysit. The manual wiring below remains the right choice for a remote host that doesn't run a dotvault daemon at all.
Enabling api fixes that by putting a second, stable dotvault on the near side of the problem. The long-lived per-user daemon serves the same borrow endpoint from a path that no disconnect can take away, keeps its own token alive, and re-borrows across the forwarded socket whenever the SSH session comes back. Local clients borrow from the daemon instead of from the forward:
workstation (interactive login)
│ SSH RemoteForward → ~/.ssh/dotvault.<host>.sock ← comes and goes with the session
▼
devbox: dotvault daemon (vault.token_socket + api.enabled)
│ $XDG_RUNTIME_DIR/dotvault/api.sock ← always there while the daemon runs
▼
devbox: your tmux job, scripts, Python bindings
Configuration on the devbox is both settings together — borrow from the workstation, serve to everything local:
token_socket is deliberately absent here: the default pattern list already covers both the per-workstation sockets (~/.ssh/dotvault.*.sock) and the pre-0.34 shared path, so a devbox only needs the api half. Set it explicitly when the forwards live somewhere other than ~/.ssh, and set it as a list when you do:
vault:
token_socket:
- ~/.ssh/dotvault.*.sock # one socket per forwarding workstation
api:
enabled: true
Clients on that host need no extra configuration: they read the same config and derive the same socket path.
Borrow order¶
When both are configured, a token borrow tries the local API socket first, then the vault.token_socket pool, most-recently-seen first. The local socket is preferred because it is the more stable of the two, which is the entire point. This ordering applies to the daemon, the CLI, the Go client/ facade and the Python bindings alike. The daemon excludes its own socket from its list — it serves that one.
The peer actions (browse, notify, clipboard) deliberately keep using the vault.token_socket pool only, and are sent to every live peer in it. Their purpose is to reach the workstation where a human is looking; sending them to the local daemon would open a browser on the headless host nobody is sitting at.
Separate from web.enabled¶
The two surfaces are enabled independently because they have different audiences and different exposure. The TCP listener serves a browser UI and is reachable by every user on the machine; the Unix socket is created 0600 inside a 0700 directory and is reachable only by its owner. A headless host should not have to stand up a web UI to get the borrow endpoint, and turning the socket on does not widen anything web.enabled already exposes — if anything it is the tighter of the two.
With web.enabled: false, the socket serves the API routes (/api/v1/token, /api/v1/status, /healthz, /readyz, the peer actions, …) but not the browser-facing routes. The browser pages and the interactive login flows build redirect URIs from a bound TCP address that does not exist in that mode, so they are not registered at all rather than being published as a login flow that cannot complete.
systemd socket activation¶
On Linux, the packaged dotvault-api.socket unit (optional, not enabled by default) lets systemd bind this socket and hold the fd across daemon restarts, so borrowers queue instead of getting connection-refused while the daemon is down. api.enabled remains the master switch — the socket unit decides who binds, not whether the surface exists — and under activation the unit's ListenStream= path wins over unix.path, with the daemon logging any divergence and refusing an inherited socket whose mode is wider than 0600. See Socket activation in the deployment guide.
Running as a service¶
The socket lives in $XDG_RUNTIME_DIR, which systemd tears down when the user's last session ends — unless lingering is enabled:
This is required for any user service meant to outlive an SSH session, which is exactly the case here. The packaged dotvault.service also declares RuntimeDirectory=dotvault, so systemd creates $XDG_RUNTIME_DIR/dotvault at 0700 before the daemon starts and removes it on stop — a killed daemon leaves no stale socket behind.
dotvault status reports the socket path and whether it is currently present, which is the first thing to check when a client on the host cannot get a token.
The socket grants the token to anyone who can open it
Any process that can connect() to the socket can read the Vault token. The 0600/0700 permissions restrict that to the owning user, and dotvault enforces them on every bind (refusing to clobber a socket a live instance already owns). Do not relax them, and do not forward this socket onward unless you intend the far end to hold your token.
Unix only
api.enabled has no effect on Windows: the daemon logs a warning and serves nothing, so a config shared across a mixed-platform fleet is safe. The Windows analogue would be a named pipe with a protected DACL, as the SSH agent already serves; it is not implemented yet.
On Windows GPO, the equivalents are Enabled (REG_DWORD) and UnixPath (REG_SZ) under HKLM\SOFTWARE\Policies\goodtune\dotvault\API, and the section round-trips through reg-import/reg-export like every other.
Filesystem section¶
The fuse section mounts your Vault secrets as a filesystem: each secret becomes a .json file whose contents are its data section, so jq . ~/.dotvault/gh.json works and stat reports the secret's version timestamp as the file's mtime. The extension is what makes editors and IDEs treat the file as JSON rather than unknown text; directories carry none. See the Filesystem guide for the full write-up.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Mount the filesystem |
mountpoint |
string | ~/.dotvault |
Directory to mount on; must be absolute (or ~-relative). Created at mode 0700 if absent |
read_write |
bool | false |
Allow writing through the mount (replace, create, delete). Read-only otherwise, regardless of what the Vault token could do |
cache_ttl |
duration | 30s |
How long a listing or a rendered secret is reused before Vault is asked again. 0 disables caching; capped at 10 minutes, since the cache window is also how long a revoked secret stays readable |
Per-user preferences¶
Unusually for this file, the fuse section can also be set per-user, in ${XDG_CONFIG_HOME:-~/.config}/dotvault/config.yaml (macOS: ~/Library/Application Support/dotvault/config.yaml; Windows: %APPDATA%\dotvault\config.yaml) — a sibling of the env file and ssh.yaml. It is merged over the system configuration, and it is the only file a user can use to influence dotvault's configuration: every other section is a hard error there, so it can never re-point the Vault, open a listener, or alter telemetry.
The file must be a single YAML document, and dotvault refuses to read it if the file or its directory is group- or world-writable — it can turn a secrets mount on, so a path another account can rewrite is refused rather than warned about. A parse failure is fatal at startup and a skipped-with-warning overlay on later reloads, so a user's typo can never stop policy reaching the daemon.
Each field ratchets rather than overwriting: enabled may be turned on but not off, read_write may be turned off but not on, and mountpoint / cache_ttl are preferences the user's value simply wins. An omitted key is not a preference — only an explicitly written one is. See the Filesystem guide for the reasoning behind the two booleans ratcheting in opposite directions.
Downloads and exports carry the merged result
GET /api/v1/config/download and the /ui/config/ view show the running configuration, so a user preference that took effect appears there as though it were policy — the same way remote-config-merged rules do. Re-importing such a download as a system config would bake that preference in.
The mount root is your own KV prefix ({kv_mount}/{user_prefix}{username}/), bound at construction — a path through the mount cannot reach another user's secrets. The daemon mounts after its first successful Vault authentication and unmounts on shutdown; a mount failure is logged once and is never fatal.
Read-only is the default for a reason
The daemon's token can usually write to Vault. That capability exists for dotvault's own sync and enrolment work — exposing it through a filesystem makes every process running as you, and every mistyped shell redirect, one > away from replacing a credential. read_write: true is a separate decision from mounting.
One narrow filename collision
A secret and a folder sharing a KV name coexist fine — users/you/databricks is databricks.json and users/you/databricks/prod is databricks/prod.json. What does collide is a KV folder whose name already ends in .json, which competes with the secret of the same stem. The directory wins so the secrets underneath stay reachable, the daemon warns naming the path, and the shadowed secret has no path in the mount. The mount refuses to create such a directory, so it can only come from a KV tree already laid out that way.
Unix only
fuse.enabled has no effect on Windows: the daemon logs a warning and mounts nothing, so a config shared across a mixed-platform fleet is safe. There is no Windows equivalent planned — WinFsp is a DLL reached through cgo, and dotvault ships CGO_ENABLED=0 static binaries. Linux needs /dev/fuse and the fusermount3 helper (fuse3 package), and a systemd unit that does not set NoNewPrivileges= or any other seccomp-based sandbox directive, which disarm the helper — see Hardening and the FUSE mount; macOS needs macFUSE.
Reading a file in the mount calls Vault, so grep -r across the mount — or an editor indexing your home directory — reads every secret you have and puts each one in Vault's audit log. Reach for a specific path.
On Windows GPO, the equivalents are Enabled (REG_DWORD), Mountpoint (REG_SZ), ReadWrite (REG_DWORD) and CacheTTL (REG_SZ) under HKLM\SOFTWARE\Policies\goodtune\dotvault\FUSE, and the section round-trips through reg-import/reg-export like every other — an admin managing a mixed fleet from one policy sets it for the Linux and macOS machines that policy covers.
Docker volumes section¶
The docker section serves your secrets to containers as a Docker volume plugin (rootless Docker and Podman both consume the same protocol). A volume is a directory of plain files — gh.json per secret, rendered exactly as the filesystem renders it — that the daemon materialises when a container first mounts it, keeps current from Vault events (Enterprise) or on a refresh window (Community), and deletes when the last container lets go. See the Docker volumes guide for registration, the per-volume secrets/layout/mode/ttl options, and the refresh policy.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Serve the plugin |
socket |
string | $XDG_RUNTIME_DIR/dotvault/docker.sock |
Unix socket the engine connects to (0600 in a 0700 directory); must be absolute (or ~-relative). The engine is told about it by a one-line spec file, which the Linux packages create per user for rootless Docker — see the guide. Change this and you must write that file yourself, since the packaged drop-in names the default |
volume_dir |
string | $XDG_RUNTIME_DIR/dotvault/volumes |
Directory volumes are materialised under, one subdirectory each. Created at mode 0700 |
cache_ttl |
duration | 1m |
Default refresh window for a volume without its own ttl option. Governs Community, and Enterprise while the event subscription is down; must be positive and at most 10 minutes, since the window is also how long a rotated secret keeps being served to a container |
docker:
enabled: true
socket: "" # default: $XDG_RUNTIME_DIR/dotvault/docker.sock
volume_dir: "" # default: $XDG_RUNTIME_DIR/dotvault/volumes
cache_ttl: "1m"
The socket is deliberately not under an engine's own plugin directory: a rootless dockerd scans /run/docker/plugins inside its own mount namespace, which nothing outside it can populate, and a rootful one's is root-owned. Both engines accept a .spec file naming any socket, and dotvault status prints its path and contents. The daemon never writes that file — it does not edit another tool's configuration. The Linux packages do, for the rootless Docker case only, via a systemd user-tmpfiles drop-in that creates ~/.local/lib/docker/plugins/dotvault.spec at login; it never overwrites a file that already exists, and it is opted out of with ln -s /dev/null ~/.config/user-tmpfiles.d/dotvault-docker.conf. Note it names the default socket path, so a customised socket needs a hand-written spec. See Registering the plugin.
Linux only
docker.enabled has no effect on macOS or Windows: the daemon logs a warning and serves nothing. Docker Desktop and Podman machine run the engine in a virtual machine, where a host-side socket is unreachable, so there is nothing a build for those platforms could usefully bind. A config shared across a mixed-platform fleet is safe.
Volume definitions (names and options, never secret data) persist in {cache_dir}/docker-volumes.json, so a daemon restart under a running container resumes refreshing the directory that container still holds. The section is static — a change needs a restart — and is refused in a remote-config document, like every other section that opens a listener.
On Linux, the packaged dotvault-docker.socket unit (optional, not enabled by default) lets systemd bind this socket and hold the fd across daemon restarts, so an engine call landing mid-restart queues instead of failing. docker.enabled remains the master switch, and under activation the unit's ListenStream= path wins over socket — the .spec file must name that path. The unit's default ListenStream= and the default socket are the same path, so the packaged spec drop-in above is correct in both modes. See Socket activation and plugin registration.
On Windows GPO, the equivalents are Enabled (REG_DWORD), Socket (REG_SZ), VolumeDir (REG_SZ) and CacheTTL (REG_SZ) under HKLM\SOFTWARE\Policies\goodtune\dotvault\Docker, and the section round-trips through reg-import/reg-export like every other, for the same mixed-fleet reason as fuse.
Observability section¶
Exports OpenTelemetry metrics and logs over OTLP. Each signal is configured in its own nested metrics: / logs: block, so the two signals can go to separate backends or one can be switched off. See Observability in the deployment guide for the exported instruments and worked examples.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Master switch for both signals. A per-signal enabled: true cannot resurrect a disabled subsystem |
endpoint |
string | — | Deprecated shared default (see note below). OTLP collector endpoint; same value contract as the per-signal endpoint |
protocol |
string | — | Deprecated shared default. grpc or http/protobuf. Empty falls through to the standard OTEL_EXPORTER_OTLP_* env vars |
insecure |
bool | false |
Deprecated shared default. Disable transport TLS |
headers |
map | — | Deprecated shared default. OTLP headers, typically a vendor bearer token — treat as a credential |
export_interval |
string | SDK default | Metric export cadence as a Go duration (e.g. 30s). Not deprecated |
metrics / logs |
block | — | Per-signal configuration, fields below — the supported home for exporter settings |
Shared exporter fields are deprecated
The top-level endpoint / protocol / insecure / headers fields still work as shared defaults the per-signal blocks layer onto, but they are being retired in stages (#140): this release warns at startup and counts each use on the dotvault.config.deprecated metric (attribute field), a later release makes the warning louder, and 1.0 removes them. Configure each signal in its own block, or use the standard OTEL_EXPORTER_OTLP_* environment variables for values shared across both signals — the env-var fallthrough remains fully supported.
Per-signal override block (metrics: / logs:):
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | inherit | Tri-state: unset inherits the master switch, explicit false turns this signal off |
endpoint |
string | inherit | Non-empty overrides the shared endpoint (separate backend). A full URL is the recommended form: the scheme carries TLS intent (https → TLS, http → plaintext, on both protocols) and an explicit path is used verbatim — no mount path assumed; a path-less URL gets the standard /v1/metrics / /v1/logs appended (http/protobuf). Bare host:port (canonical gRPC) leaves TLS to insecure; dns:/// passes through to the gRPC resolver |
protocol |
string | inherit | Non-empty overrides the shared protocol |
insecure |
bool | inherit | Tri-state: unset inherits the shared value. Meaningful for scheme-less endpoints; an endpoint URL's scheme already carries the TLS intent, and an explicit true forces plaintext even over https:// — prefer stating the intent in the scheme |
headers |
map | inherit | Replaces the shared map wholesale — never merged, so one backend's token is not sent to the other. An explicitly empty headers: {} means "this signal sends no headers", distinct from omitting the field (inherit) |
temporality |
string | cumulative |
Metric temporality preference: cumulative, delta, or lowmemory — the OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE vocabulary and instrument-kind mapping (empty falls through to that env var). delta reports counters/observable counters/histograms as per-interval deltas, as Datadog and some other vendors expect. Metrics block only — setting it under logs: is a config error |
observability:
enabled: true
metrics:
endpoint: https://otel.internal.example
protocol: http/protobuf
temporality: delta # e.g. for a Datadog-fronted collector
headers:
authorization: "Bearer metrics-token"
logs:
endpoint: https://logs.vendor.example # separate backend
protocol: http/protobuf
headers: # with its own credentials
x-api-key: "logs-token"
Resource attributes¶
Every exported metric and every exported log record carries the same OTel resource, because the MeterProvider and the LoggerProvider share one. It identifies the emitting daemon: service.name (always dotvault), service.version, user.name, host.name, os.type, host.arch, and the process.runtime.* pair. These are process constants, so they add no time series — a backend that surfaces target_info (Prometheus, for instance) exposes them as one info series per daemon.
user.name and host.name leave the host
user.name is the OS account the daemon runs as (paths.Username(), DOMAIN\ prefix stripped) and host.name is this machine's fully-qualified name. Both are sent to whichever collector you configure, a third-party SaaS included. dotvault is a per-user daemon, so attributing a series to a user is the point of the attribute — a fleet view cannot otherwise answer "whose daemon is failing" — but it is a genuine disclosure of who is running the software and where. There is no per-attribute opt-out: the control is observability.enabled, which is false by default. A disabled deployment emits nothing at all and, per the note below, makes no DNS call either. Neither attribute ever carries secret material — no Vault path, key, or credential. If the account name is itself sensitive in your environment, run the daemon under a non-identifying service account or leave observability off.
host.name is resolved once at startup: os.Hostname(), and — only when that name has no dot — a single forward LookupCNAME to qualify it, the same mechanism hostname -f uses. This is an outbound DNS query at daemon startup, which matters on a locked-down or air-gapped network. It is bounded at two seconds and never fails startup: a resolver error, a timeout, an unqualified answer, or a localhost. alias all fall back to the short name, and an empty hostname omits the attribute entirely (as a failed user lookup omits user.name). No lookup happens when os.Hostname() is already qualified, and none happens at all when observability is disabled.
macOS often stays unqualified
dotvault ships as a pure-Go CGO_ENABLED=0 binary, so it uses Go's own resolver rather than the system one. On macOS that means scutil-managed search domains — the ones a VPN or corporate split-DNS profile installs — are not consulted, so host.name can stay the short name even where hostname -f in a shell returns the FQDN. The value is still correct, just less specific; treat host.name as possibly-short on macOS fleets.
Treat host.name as discovered, not attested. The CNAME answer comes from whatever resolver the host is pointed at, so a hostile or compromised one chooses the name your collector attributes the data to. Nothing is executed or connected to on the strength of it, and the guards above reject accidents rather than deliberate answers — a collector that needs trustworthy attribution should take it from the transport (mTLS, a per-host token), not from a resource attribute.
While the deprecated shared fields remain in play, a signal that overrides endpoint without setting its own headers inherits the shared map — including any shared bearer token, which then goes to the overridden backend. The daemon warns at startup when it sees that combination; state the intent with an explicit per-signal headers: ({} for none) to silence it. enabled: true with both signals explicitly off is rejected at config load.
Remote config section¶
See Remote Configuration for details. When remote_config.url is set, the local file/registry config becomes a base that is overlaid with dynamic sections (rules, enrolments, sync) fetched from a dotvault-config service.
Rules section¶
See Sync Rules for details.
Enrolments section¶
See Service Onboarding for details.
Validation¶
dotvault validates the configuration on startup and exits with an error if:
vault.addressis missing- No rules are defined (waived when
remote_config.urlis set — the remote document may supply them) - Rule names are not unique
- A rule omits
vault_key(a keyless rule) but also omitstarget.template— there is no secret data to write - A
target.formatis not one of:yaml,json,ini,toml,text,netrc,ssh_config - A rule sets
target.delete_nulls: trueon a format other thanjsonoryaml— the others have no null literal a template could render, and silently ignoring the flag would leave you believing a retired credential had been deleted (see Removing a field) web.listenresolves to a non-loopback address (when web is enabled)- A
web.editable_pathsentry names the root of your key space ("","/") rather than a folder in it — silently ignoring it would leave you believing you had granted editing that you had not - A
web.editable_pathsentry is more than one segment deep (scratch/notes) — your key space is one folder deep, so that names a secret rather than a folder; writescratch - A
web.editable_pathsentry is not a valid relative KV path (an empty segment,.,.., or an embedded NUL) - Two
web.editable_pathsentries name the same folder once canonicalised (personaland/personal/) - An enrolment entry has an empty
enginefield api.unix.pathis set to a relative path (it would resolve against each process's working directory, so the daemon and a client started elsewhere would disagree about where the socket is)fuse.mountpointis set to a relative path (same reason asapi.unix.path: the daemon and anyone reading the config would disagree about where the secrets appeared)fuse.cache_ttldoes not parse as a duration, or is negativedocker.socketordocker.volume_diris set to a relative path (same reason asapi.unix.path)docker.cache_ttldoes not parse as a duration, is zero or negative, or exceeds 10 minutes