If you prefer to watch a video, here is the whole tutorial in under a minute:
Most Node.js apps keep their secrets in a .env file: a database password, an
API key, in plain text, in a file that gets copied to every laptop that runs the app. This
tutorial moves them into an encrypted Valt0 vault you commit with your code.
You need Node.js 20.6 or later and Valt0 installed
(valt0 status checks that its service is running). The examples use
pg and stripe, but the steps are the same for any library that
takes a secret.
1. Before: secrets in .env
Here is a typical .env. Two of its lines are settings, and two are secrets:
PORT=3000
LOG_LEVEL=info
DATABASE_URL="postgresql://app:example-password@localhost:5432/app"
STRIPE_KEY=sk_test_xxxxxxxxxxxx
The database password is tucked inside the URL, between the colon and the @,
and the Stripe key is right there. The app reads both straight from the environment:
import pg from 'pg';
import Stripe from 'stripe';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const stripe = new Stripe(process.env.STRIPE_KEY);2. Move the secrets into a vault
Create a vault for development. Valt0 prints its key once, so put it in your password manager now: without it nobody can open the vault, you included.
valt0 init dev
Then store each secret under the name it has in .env. Valt0 asks for the key,
then the value: paste it without any quotes it has in the file.
valt0 store dev:DATABASE_URLDo the same for STRIPE_KEY, and for any other secret you have.
The vault was imported into the local Valt0 service when you created it, and that service is what your app will talk to. All the app needs from you is the vault's public id:
valt0 info dev3. After: the app asks the vault
This is the part that makes the whole thing work: the app never sees the vault key. It opens the vault by its id, which is public, and asks the local service for each secret. Install the client:
npm install @valt0/client
In .env, the two secrets give way to the vault's id, the one
valt0 info printed. It is safe there:
PORT=3000
LOG_LEVEL=info
APP_VAULT_ID=your-vault-id
And app.mjs opens the vault once, then asks it for each secret by name where it
sets up the library that needs it:
import pg from 'pg';
import Stripe from 'stripe';
import { openVault } from '@valt0/client';
const vault = await openVault({ id: process.env.APP_VAULT_ID, name: 'app' });
const dbUrl = await vault.secret('DATABASE_URL');
export const pool = new pg.Pool({ connectionString: dbUrl.reveal() });
dbUrl.destroy();
const stripeKey = await vault.secret('STRIPE_KEY');
export const stripe = new Stripe(stripeKey.reveal());
stripeKey.destroy();
reveal() hands the library the value, and destroy() wipes the
client's copy straight after. Anywhere else, the secret object prints as
[REDACTED], so logging it by mistake gives nothing away. If the service can't be
reached or the vault can't be loaded, openVault throws, and the app stops at
startup instead of running without its secrets.
4. What changes from here
-
New teammates clone the repo and get one key from you instead of a file
of passwords, plus the public id for their own
.env. They runvalt0 import dev, andvalt0 import dev --forceafter pulling a vault that changed. -
New secrets go the same way: store each one and read it with
await vault.secret('NAME'). -
Changing a value is
valt0 store dev:STRIPE_KEY --forceand a restart. That changes the vault, not the provider: rotate the key or password there first. -
Production gets its own vault, with the same secret names and its own
APP_VAULT_ID, so a leakeddevkey leaves it alone. The Introduction explains why, Deployment covers servers, and From a container covers containers on your own computer.