Skip to content

Migrating from .env to OpaqueVault

This guide walks you from “I have a .env file and a running service” to “my service boots from OpaqueVault with no plaintext secrets on disk” without breaking production along the way.

If you just ran ov secret import and want to know what to do next, start at Import.


.env file ──(1) import──▶ OpaqueVault (ciphertext on server)
├─(2) verify count
├─(3) setup Linux: machine key + ov agent install
│ macOS: ov agent start (interactive)
├─(4) cut over edit systemd unit to `ov run ...`
├─(5) reboot-test prove the service comes back up with no .env
└─(6) delete .env mv .env .env.bak

Do not skip steps. Each one is there because skipping it has broken somebody’s service.


Terminal window
ov secret import /opt/myapp/.env.production --app myapp --env production

You should see per-secret lines and a final Imported N/M secret(s) into myapp/production summary. The CLI also prints platform-specific “Next steps”; this doc is the long-form version of that.


Terminal window
ov secret list --app myapp --env production

The list length must match the import count. If it doesn’t — stop. Re-run import with --dry-run to see what the parser picked up, fix the source file, and re-import. Don’t continue on a partial import.


This is where Linux servers and developer Macs diverge.

On a server, any reboot kills the in-memory KEK. If your service starts up before you’ve unlocked the vault, ov run will fail and the service will not come up. The fix is a machine key + systemd-managed agent:

Terminal window
# 1. Pre-create /etc/ov writable for your user so derive-machine-key can
# populate it WITHOUT sudo — it needs your own ov auth config which lives
# in your home directory, not root's. This is a TEMPORARY permission set;
# step 3 hardens /etc/ov back to root-owned.
sudo install -d -o $USER -g $USER -m 700 /etc/ov
# 2. Derive the machine key as YOURSELF (not sudo):
# The key is written 0600 owned by $USER — exactly the permissions the
# systemd unit (User=$USER) needs. No chown/chmod dance required on the
# key file itself.
ov auth derive-machine-key --out /etc/ov/myapp.key
# 3. Install the agent. `ov agent install` writes the systemd unit AND
# tightens /etc/ov/ to root-owned 0755 + writes the rendezvous file
# at /etc/ov/agent.socket as root-owned 0644 (OV-126 trust model).
# --app and --env are REQUIRED — they bake into ExecStart so the agent
# is not coupled to any config-file state of the service user. --user
# pins the systemd User= field to your service account (cannot be root).
sudo ov agent install \
--machine-key /etc/ov/myapp.key \
--app myapp --env production \
--user $USER --enable

Final state after step 3:

PathOwnerModeWhy
/etc/ov/root:root0755World-traversable so CLI clients can read the rendezvous file; only root can write
/etc/ov/agent.socketroot:root0644Single trust root for socket discovery; tamper-proof for same-UID attackers
/etc/ov/myapp.key$USER:$USER0600Matches the systemd User= that needs to read it

Why not just sudo everything? ov auth derive-machine-key reads your ov auth config (API key, KEK salt) from your own ~/.config/ov/config. Root has no such config and the command fails with “config not found.” That’s why step 2 runs as your regular user.

What ov agent install does:

  1. Writes /etc/systemd/system/ov-agent.service with User=$USER, RuntimeDirectory=ov-agent, and a fully self-contained ExecStart.
  2. Writes /etc/ov/agent.socket — one-line rendezvous file so MCP clients (ov mcp serve for Claude Code) find the agent without requiring $XDG_RUNTIME_DIR.
  3. With --enable, runs systemctl daemon-reload && systemctl enable --now ov-agent.

What ov agent install does NOT do: it does not make ov run work non-interactively on its own. The agent is for MCP tool calls (Claude via ov mcp serve). For headless service startup, your service’s systemd unit needs ov run --machine-key /etc/ov/<app>.key — see §4 below. The agent and the machine key are two separate non-interactive paths serving different callers.

No systemd, no /etc/ov, no persistence requirement — just unlock the agent for this session:

Terminal window
ov agent start # prompts for master password

The agent holds the KEK in memory and serves ov run requests until you stop it or log out. On the next login, run it again.


Confirm the service can read secrets from the vault before you change any unit files. On a server, pass --machine-key (see §3) so you don’t prompt for a password:

Terminal window
sudo systemctl stop myapp
ov run --app myapp --env production \
--machine-key /etc/ov/myapp.key \
--secrets DATABASE_URL,STRIPE_SECRET_KEY \
-- /opt/myapp/bin/server
# hit an endpoint, check logs, Ctrl-C

If that works, update the systemd unit:

/etc/systemd/system/myapp.service
[Service]
ExecStart=/usr/local/bin/ov run \
--app myapp --env production \
--machine-key /etc/ov/myapp.key \
--secrets DATABASE_URL,STRIPE_SECRET_KEY \
-- /opt/myapp/bin/server
# EnvironmentFile=/opt/myapp/.env.production # ← delete this line
Terminal window
sudo systemctl daemon-reload
sudo systemctl start myapp

Keep the .env file on disk. You’re not done yet.


Terminal window
sudo reboot

After the box comes back up:

Terminal window
systemctl status myapp # expect active (running)
journalctl -u myapp -n 50 # expect normal startup logs

If the service didn’t come back up, see Troubleshooting. Do not proceed to step 6 until at least one reboot has succeeded with the service loading secrets from the vault.


Now you can delete it — and use mv, not cp + rm, so there’s no window where two copies exist:

Terminal window
sudo mv /opt/myapp/.env.production /opt/myapp/.env.production.bak

Keep the .bak for one deploy cycle so you have an obvious rollback. After that, delete it and rotate any API keys that were in it, because the plaintext has been on disk.


A machine key on disk is a security downgrade compared to interactive unlock. The KEK-equivalent exists at rest on the filesystem, so the blast radius is bounded by filesystem ACLs rather than by memory-only residence. This is the necessary trade for unattended reboot survival. If your threat model cannot accept a KEK-at-rest, use interactive ov agent start instead and accept that reboots require a human.

A machine key is a 32-byte KEK-equivalent stored as a file. It is equivalent in sensitivity to your master password — it can decrypt every secret your account can, across every app. Treat it accordingly:

PropertyRule
Permissionschmod 600, owned by the service account. ov run hard-errors on wider permissions.
ScopeThe key decrypts all of your account’s secrets — there is no per-app cryptographic scoping. Per-app isolation for machine keys is tracked in OV-278. Bound the blast radius with one key per host and tight filesystem ACLs.
Storage/etc/ov/<app>.key on Linux. Never in a repo. Never in a container image. Bind-mount at runtime.
SymlinksRefused at the OS level (O_NOFOLLOW). Always point at the real file.
Memorymlocked and MADV_DONTDUMP — won’t page to swap, won’t appear in core dumps.
Rotationov auth derive-machine-key --force overwrites. Revoke the associated API key first if you suspect compromise.

A TPM-sealed machine key (where the KEK never lives unencrypted on disk, only inside the TPM’s secure element) is a strictly better story and is on the roadmap. Until then, the file + chmod 600 model is the best non-interactive option and is how we run our own production services.


OpaqueVault’s API server (api.opaquevault.com) cannot use OpaqueVault to manage its own secrets. The server’s TLS key, database password, and HMAC signing key must exist in plaintext somewhere readable at boot, because OV needs those to come up before any vault operation is possible.

If you run your own OV deployment, those bootstrap secrets go in systemd LoadCredential= or a cloud KMS — not in OV. Any other app on that same host can and should use OV normally.

This is the “turtles all the way down” problem every secret manager has. We don’t pretend to solve it; we just tell you about it so you don’t try to migrate OV’s own .env into OV.


Check in this order:

  1. Is the agent running?

    Terminal window
    systemctl status ov-agent

    If it’s inactive or failed, the machine key file is probably missing, wrong permissions, or owned by the wrong user.

  2. Is the machine key readable by the service account?

    Terminal window
    ls -la /etc/ov/myapp.key
    # -rw------- 1 deploy deploy 32 ...

    Must be chmod 600 and owned by whichever user ExecStart runs as. A common mistake is deriving the key as root with sudo but running the service as deploy.

  3. Is the routing context right?

    Terminal window
    ov run --app myapp --env production -- env | grep -c .

    --app / --env on ov run are routing flags — they select which (app, env) context the request binds to. A machine key itself is not app-scoped (it decrypts all of your account’s secrets), so a wrong-app failure is a routing mismatch: the secret you expected lives under a different (app, env) than the one you passed. Re-check the flags against ov secret list --app <slug> --env <env>.

  4. Did the systemd unit get daemon-reloaded? Editing /etc/systemd/system/myapp.service without systemctl daemon-reload means the old EnvironmentFile= line is still authoritative in the running config. The reboot fixes this implicitly; a missed daemon-reload between edits does not.

  5. Did you delete the .env too early? If steps 1-4 look fine but the service still won’t start, your .env may have had values that weren’t in the import (env-specific vars the import skipped, etc.). Restore .env.bak, start the service, diff env output before/after, and import the missing keys.

ov run works but Claude can’t read secrets via MCP

Section titled “ov run works but Claude can’t read secrets via MCP”

That’s a separate path — Claude talks to ov mcp serve, not the systemd agent. See ov mcp serve.

The machine key is derived from your KEK, which is derived from your master password. Rotating the password invalidates every machine key. Plan a redeploy:

  1. Rotate the password: ov auth change-password
  2. Re-derive every machine key: ov auth derive-machine-key --force --out /etc/ov/<app>.key
  3. Restart services.

  • ov agent — daemon lifecycle, socket auth
  • Machine Keys — security properties, Docker + systemd recipes
  • Quickstart — the 5-minute intro if you haven’t done one yet