Skip to content

Deployment Guide

This guide covers how system administrators deploy and configure dotvault across an organisation.

Architecture overview

dotvault runs as a per-user daemon. Each user has their own instance, their own Vault identity, and their own secrets. The administrator's role is to:

  1. Set up the Vault infrastructure (KV engine, auth methods, policies)
  2. Deploy the dotvault binary to machines
  3. Distribute a configuration file (or Group Policy on Windows)
  4. Arrange for dotvault to start in each user's session

Vault infrastructure

KV engine

Enable KVv2 and create the user prefix namespace:

vault secrets enable -version=2 -path=kv kv

Policies

Create a template policy that scopes each user to their own secrets. See KV Engine & Policies for the full policy file.

Auth method

Enable and configure at least one auth method. OIDC is recommended for desktop environments as it integrates with existing SSO.

Configuration distribution

Linux

Place the config file at the system-wide location:

/etc/xdg/dotvault/config.yaml

dotvault also checks paths listed in $XDG_CONFIG_DIRS.

Deploy with your existing configuration management (Ansible, Puppet, NixOS, etc.):

- name: Deploy dotvault config
  copy:
    src: dotvault/config.yaml
    dest: /etc/xdg/dotvault/config.yaml
    owner: root
    group: root
    mode: "0644"
environment.etc."xdg/dotvault/config.yaml".text = ''
  vault:
    address: "https://vault.example.com:8200"
    auth_method: "oidc"
  rules:
    - name: gh
      vault_key: "gh"
      target:
        path: "~/.config/gh/hosts.yml"
        format: yaml
        template: |
          github.com:
            oauth_token: "{{ "{{" }} .oauth_token {{ "}}" }}"
'';

macOS

Place the config file at:

/Library/Application Support/dotvault/config.yaml

Deploy via MDM (Jamf, Munki) or configuration management.

Windows

Place the config file at:

%ProgramData%\dotvault\config.yaml

Or use Group Policy to manage configuration centrally via the registry.

Registry takes precedence

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. The --config CLI flag is refused while a policy is present unless that policy opts in with a BypassSystemConfig REG_DWORD of 1 (the bypass_system_config: true equivalent); see Windows Group Policy.

Running as a user service

systemd (Linux)

Upgrading from a manually-created unit

Previous versions of this guide showed an example ~/.config/systemd/user/dotvault.service snippet. If you created one, remove it before enabling the packaged unit — the per-user path shadows /usr/lib/systemd/user/ and your hand-rolled unit (which lacks Type=notify, WatchdogSec, the env-file paths, etc.) will silently take precedence:

rm ~/.config/systemd/user/dotvault.service
systemctl --user daemon-reload
systemctl --user enable --now dotvault.service

Behavioural change to be aware of: services declaring After=dotvault.service now block until dotvault completes its initial sync (the packaged unit uses Type=notify and delays READY=1 until secrets are on disk). The previous hand-rolled unit had no readiness gate, so dependents started in parallel. If a dependent's startup ordering matters to you, this is the change to plan for.

The RPM, DEB, and APK packages all ship a dotvault.service user unit (a Type=notify service with WatchdogSec=120 and the OpenTelemetry-friendly logging settings) at the canonical /usr/lib/systemd/user/ path. dotvault is a per-user daemon — it authenticates to Vault with the OS user's identity and writes secrets into that user's $HOME — so installing it as a system service that runs as root would write to root's $HOME and authenticate to Vault as root, which is almost never what you want.

Enable per-user once the package is installed:

systemctl --user enable --now dotvault.service

The daemon watches ~/.dotvault-token itself (via inotify on Linux), so subsequent rewrites of the file (typically from an interactive dotvault login in another shell) trigger an immediate token re-read on the running daemon within seconds — no extra unit to enable. See Config reload for the full mechanism.

Or enable globally for every login session on the machine:

sudo systemctl --global enable dotvault.service

--global enables the unit in every user's session; each user runs their own instance and authenticates with their own Vault identity.

Enable lingering if the daemon must outlive a login session

A --user service normally stops when the user's last session ends, and $XDG_RUNTIME_DIR (where the SSH agent and local API socket live) is torn down with it. For a machine people reach over SSH — where a tmux job or the local API socket is expected to survive a disconnect — enable lingering so the user manager keeps running:

sudo loginctl enable-linger <user>

Environment-variable overrides (e.g. OTEL_EXPORTER_OTLP_ENDPOINT) can be set via four optional EnvironmentFile= paths referenced by the unit:

  • ~/.config/dotvault/env (preferred for per-user secrets)
  • ~/.config/dotvault.env
  • /etc/default/dotvault
  • /etc/sysconfig/dotvault

The system-wide paths are typically world-readable, so the per-user ~/.config/dotvault/env is the right place for anything sensitive (e.g. an OTLP bearer token in OTEL_EXPORTER_OTLP_HEADERS). Create the file with chmod 600; all four are silently ignored if absent.

%h vs ~ in custom unit drop-ins

The packaged unit references the per-user paths as %h/.config/dotvault/env and %h/.config/dotvault.env. %h is systemd's home-directory specifier — equivalent to ~ when you're creating the file at the shell. If you reference the file from a systemctl --user edit drop-in or a custom unit, write %h (or ${HOME}); systemd does not expand ~ in EnvironmentFile= directives, so a literal ~/.config/... would be silently skipped.

The unit hard-codes a couple of system paths that the package owns: ExecStart=/usr/bin/dotvault run, plus the EnvironmentFile= paths listed above. If you install dotvault into a non-standard location (e.g. /usr/local/bin), copy the unit out to ~/.config/systemd/user/dotvault.service and adjust those lines.

Slow initial sync and the systemd startup window

With Type=notify, two different deadlines govern dotvault's lifecycle:

  • TimeoutStartSec — the pre-READY=1 window. systemd waits this long for the daemon to finish auth + initial sync and signal ready. The packaged unit sets it to 300 seconds; the systemd default of ~90s is too tight for resource-constrained hosts (many rules, slow Vault, cold TLS handshake). If the daemon doesn't reach READY=1 in time, systemd marks the start a failure and restarts — causing a boot loop on chronically slow hosts.
  • WatchdogSec — the post-READY=1 liveness check. The daemon kicks the watchdog at half this interval after becoming ready; if the kicks stop, systemd restarts the unit. The packaged unit sets it to 120 seconds.

WatchdogSec does not extend the startup window — only TimeoutStartSec does. To raise the startup window (or the watchdog) on a host where the defaults are too tight, use a drop-in:

systemctl --user edit dotvault.service
# Under [Service], one or both of:
#   TimeoutStartSec=600
#   WatchdogSec=300

TimeoutStartSec=infinity disables the pre-ready timeout entirely if your environment can't bound the first sync.

Note also that anything declaring After=dotvault.service now blocks until the first sync completes — a behavioural change from the previous manually-created unit which had no Type=notify gate.

launchd (macOS)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.goodtune.dotvault</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/dotvault</string>
        <string>run</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/tmp/dotvault.err</string>
</dict>
</plist>

Deploy to /Library/LaunchAgents/ (all users) or ~/Library/LaunchAgents/ (single user).

Windows Task Scheduler

Create a scheduled task that runs at user logon:

$action = New-ScheduledTaskAction -Execute "C:\Program Files\dotvault\dotvault.exe" -Argument "run"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName "dotvault" -Action $action -Trigger $trigger -Settings $settings

Or deploy via Group Policy as a scheduled task.

Logging

dotvault writes all logs to stderr:

  • Text format when stderr is a TTY (interactive use)
  • JSON format otherwise (service/daemon use)

Control verbosity with --log-level:

dotvault run --log-level debug

Available levels: debug, info (default), warn, error.

Override the auto-selected format with --log-format:

dotvault run --log-format json   # force structured logs
dotvault run --log-format text   # force human-readable logs
dotvault run --log-format auto   # default — text on TTY, JSON otherwise

This is useful when running under a service manager that captures stderr but is connected to a TTY for debugging, or when forcing structured logs for ingestion into a log collector regardless of how the daemon was launched.

There is no file-based logging — integrate with your platform's log collection (journald, syslog, Windows Event Log via a wrapper, etc.). On systemd hosts the packaged unit routes stderr to the journal, so the OpenTelemetry collector's journaldreceiver can filter on _SYSTEMD_USER_UNIT=dotvault.service (or _SYSTEMD_UNIT when the unit was enabled with systemctl --global) to pick logs up directly.

Observability

dotvault can export OpenTelemetry metrics and logs to a local OTel collector — a single observability: block in config.yaml drives both signals against the same endpoint. Disabled by default; enable with:

observability:
  enabled: true
  endpoint: "127.0.0.1:4317"  # local OTel collector
  protocol: "grpc"            # or "http/protobuf"
  insecure: true              # disable TLS for the local hop
  export_interval: "15s"
  # headers:
  #   authorization: "Bearer …"

For http/protobuf, set endpoint to a base URL like https://otel.example — the SDK appends /v1/metrics and /v1/logs itself. A URL that already includes a signal-specific path (e.g. ending in /v1/metrics) routes both signals to the same wrong route.

Windows Group Policy

The observability block round-trips through the GPO/registry layer like every other section — author it under SOFTWARE\Policies\goodtune\dotvault\Observability (or generate the values with dotvault reg-import). Header values round-trip too (as REG_SZ values under Observability\Headers), so a .reg export carries the live tokens — treat the artefact as a secret. To keep tokens out of the policy hive and out of any exported config, leave headers empty and set them via the standard OTEL_EXPORTER_OTLP_HEADERS environment variable (through a machine-wide environment policy) instead. See Windows Group Policy for the full registry schema.

The standard OTEL_* environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, …) are also honoured by the SDK, so the endpoint/headers fields can be left empty and managed centrally via /etc/default/dotvault.

The exporter emits a bounded set of instruments:

Metric Type Attributes
dotvault.sync.ticks counter outcome={ok,error}
dotvault.sync.duration histogram outcome
dotvault.vault.calls counter op={read,write,lookup_self,renew_self}, status
dotvault.token.renewals counter outcome={renewed,reauth_required,failed}
dotvault.token.ttl_remaining histogram (no attrs)
dotvault.enrol.attempts counter engine, outcome={completed,error}
dotvault.web.requests counter route, status_class={1xx…5xx}
dotvault.config.reloads counter outcome={no_change,applied,error}
dotvault.sighup.received counter (no attrs) — each SIGHUP forces an immediate ~/.dotvault-token re-read and config reload

Log records

The OTel logs exporter is not a wholesale replacement for stderr — operational logging still goes through log/slog to stderr / journald. The OTel logger is reserved for deployment-fact records that should reach a central collector but must not noise up an end user's terminal. Currently the only emit is:

  • configuration loaded from Windows Registry (Group Policy); file-based config is ignored — WARN severity, attribute path=<would-be config file>. Fires once per daemon/sync startup on a GPO-managed Windows box. Replaces the per-invocation slog.Info line that previously leaked onto stdout for every CLI invocation on a GPO-managed install.

Health probes are served on whichever HTTP surfaces the daemon has: the loopback listener when web.enabled: true, and/or the per-user Unix socket when api.enabled: true. A deployment with neither enabled has nothing to probe; enable one of them, or rely on the systemd sd_notify(READY=1) signal instead. The OTel httpcheckreceiver speaks TCP, so it needs web.enabled; a socket-only daemon is probed with curl --unix-socket <path> http://localhost/readyz, which is the way to get a readiness check without opening a port at all.

  • GET /healthz — liveness, always 200 while serving
  • GET /readyz — readiness, 200 once the daemon is authenticated to Vault AND has completed its initial sync cycle, 503 otherwise. Mirrors the sd_notify(READY=1) contract so a Kubernetes readinessProbe or the OTel httpcheckreceiver never observes a green daemon before secrets exist on disk. The auth check reflects the cached in-memory token, not a per-probe Vault round-trip; a revoked token flips /readyz back to 503 within the lifecycle check cadence (default 5 min).

Both return JSON and are loopback-only, suitable for the OTel httpcheckreceiver.

Security considerations

  • File permissions — all managed files are written with 0600. dotvault warns if the config file is group or world writable.
  • Token security~/.dotvault-token is written with 0600. Secret values are never logged, even at debug level. dotvault uses this dotvault-specific filename rather than Vault's default ~/.vault-token so a concurrent vault CLI session cannot clobber the daemon's cached token.

Transitional — upgrading from v0.19.0 or earlier

Releases before v0.20.0 used Vault's default ~/.vault-token. There is no migration: dotvault re-authenticates once on first start, and any token it previously wrote to ~/.vault-token lingers at 0600 until it expires. If dotvault was the only writer of that file, delete it after upgrading. This note will be removed in a future release (around v0.23.0).

  • Atomic writes — all file writes use temp file + rename to prevent partial writes.
  • Web UI — loopback only, CSRF-protected, strict Content Security Policy.
  • Windows — DACL-based permission checks via the Windows Security API.

Config reload

SIGHUP is the running daemon's reload trigger. It does two things at once: re-reads ~/.dotvault-token immediately (picking up a token freshly written by dotvault login), and re-runs the configuration loader immediately instead of waiting for the next config-refresh tick.

What a reload can and cannot apply:

  • Applied in place — the dynamic sections: rules, enrolments, sync.interval, and remote_config itself (the overlay fetcher is rebuilt and the refresh cadence re-derived on the next pass; note that a remote document still cannot carry remote_config — the section is local-only). These are the same sections the daemon already re-reads periodically on its config-refresh tick (default: the sync interval; see remote_config.refresh_interval), whether the change came from an edited local config or the remote overlay. The signal just skips the wait.
  • Restart required — the static sections: vault, web, api, agent, observability, and the top-level bypass_system_config flag. These configure subsystems constructed once at startup (the Vault client, the web listener, the local API socket, the SSH agent, the OTel exporter). A reload that finds them changed logs a warning naming the changed sections; restart the daemon (systemctl --user restart dotvault.service) to apply them.

The packaged systemd unit wires SIGHUP as ExecReload=, so the canonical gesture on Linux is:

systemctl --user reload dotvault.service

This targets the unit's MainPID specifically — preferable to kill -HUP $(pgrep -x dotvault), which would also signal any unrelated dotvault sync or go run ./cmd/dotvault invocation the user happens to be running (SIGHUP's default disposition is to terminate, so those side processes would die). On macOS the equivalent targeted form is launchctl kill SIGHUP gui/$(id -u)/com.goodtune.dotvault.

On Windows, SIGHUP is never delivered to processes. The system-tray icon (installed by both dotvault.exe run and dotvaultw.exe) carries a Reload config menu entry that performs exactly the same token re-read + immediate config reload; static-section changes log the same restart-required warning. Alternatively, changes to the dynamic sections still converge on the next config-refresh tick with no action at all.

Token re-read is automatic on Linux

On Linux the daemon also watches ~/.dotvault-token directly with inotify and re-reads it the moment the file is created or replaced — so when an interactive dotvault login writes a fresh token, the running daemon picks it up within seconds without any signal. This is built into the daemon (internal/tokenwatch); there is no separate unit to enable, and it works regardless of how dotvault was started. Deletes are ignored — the daemon keeps using its current in-memory token until a replacement is written. The watcher is a no-op off Linux; operators who want automatic re-read on macOS should script the launchctl kill form above on a launchd WatchPaths trigger.

Earlier releases shipped a dotvault-token-watch.path user unit that achieved the same nudge by SIGHUP-ing the daemon. It has been removed; the package upgrade deletes the unit files, but an enabled symlink left in ~/.config/systemd/user/ by a previous systemctl --user enable will persist and keep firing a (now redundant, but harmless) SIGHUP. After upgrading, clear it with systemctl --user disable --now dotvault-token-watch.path.