20 May Building Metis Part 4: Moving All Secrets to OCI Vault
This is the last post in my four-part series on Metis, the AI CloudOps Co-pilot I have been building on OCI. In part 1 I covered the architecture, part 2 was on the ADB 26ai MCP endpoint, and part 3 walked through OCI Functions for the Strava sync. In this final post I want to talk about secrets, and specifically how I moved every secret out of .env and into OCI Vault.
When I did this it is the day I stopped worrying about a future-me accidentally committing them to git 🙂 .
Why Bother
Before I made this change, my .env had things like:
ADB_PASSWORD=XXxXxxxXXX
GEMINI_API_KEY=AAAXXxXxxxXXXASSDFAF
OCI_CLIENT_SECRET=ASDASD-ASDadcsdfds-ADasdfadasd
That is three secrets sitting on my laptop in plaintext, also potentially synced to Time Machine, also one careless git add .away from ending up in a public repo. Yes I had .env in .gitignore, but I have seen people make this mistake even with the gitignore in place. The cleanest fix is to not have the secrets on disk at all.
OCI Vault is the right tool. It is a managed service, secrets are encrypted at rest with keys you control, you get audit logs of every access, and you can rotate secrets without redeploying anything.
What Stayed in .ENV vs What Moved
Not everything is a secret. Identifiers like tenancy OCIDs, compartment OCIDs, and region codes are not sensitive — they identify resources but they do not grant access by themselves. I kept those in .env:
OCI_TENANCY_ID=ocid1.tenancy.oc1..aaaa...
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaa...
OCI_REGION=ca-toronto-1
ADB_OCID=ocid1.autonomousdatabase.oc1.ca-toronto-1.aaaa...
PORT=3000
What moved to Vault:
ADB_PASSWORD→ secretmetis-adb-passwordADB_USERNAME→ secretmetis-adb-usernameGEMINI_API_KEY→ secretmetis-gemini-api-keyOCI_CLIENT_SECRET(for the Identity Domain OIDC app) → secretmetis-oci-client-secret
Plus the secrets the OCI Function uses for Strava:
STRAVA_CLIENT_SECRET→ secretstrava-client-secretSTRAVA_REFRESH_TOKEN→ secretstrava-refresh-tokenSTRAVA_CLIENT_ID→ secretstrava-client-id(technically not a secret, but easier to manage them all together)
Creating the Vault and Master Key
If you have not used Vault before, the hierarchy is:
Vault (the encryption boundary)
└── Master Key (used to encrypt the secrets)
└── Secrets (the actual key/value pairs)
You need a vault and a key before you can create secrets. I created mine via the OCI Console (Identity & Security → Vault → Create Vault), but you can do it via CLI too:
oci kms management vault create \
--compartment-id ocid1.compartment.oc1..aaaa... \
--display-name metis-vault \
--vault-type DEFAULT
DEFAULT vault is fine for personal projects. If you need higher throughput or HSM-backed keys, there is a VIRTUAL_PRIVATE option but it costs more.
Then I created a master key inside the vault:
oci kms management key create \
--compartment-id ocid1.compartment.oc1..aaaa... \
--display-name metis-master-key \
--key-shape '{"algorithm":"AES","length":32}' \
--endpoint https://<vault-management-endpoint>
The endpoint URL comes from the vault details, it has a host-specific prefix.
Creating the Secrets
Here is the command I used to create the ADB password:
VAULT_ID=ocid1.vault.oc1.ca-toronto-1.aaaa...
COMPARTMENT_ID=ocid1.compartment.oc1..aaaa...
KEY_ID=ocid1.key.oc1.ca-toronto-1.aaaa...
oci vault secret create-base64 \
--compartment-id $COMPARTMENT_ID \
--vault-id $VAULT_ID \
--key-id $KEY_ID \
--secret-name "metis-adb-password" \
--secret-content-content $(printf 'XXXXXXXXXXXX' | base64)
A few things to watch out for:
Use printf, not echo -n. On macOS with zsh, echo -n sometimes adds characters depending on the version of echo. printf is consistent.
Be careful with special characters in your password. When I used double quotes around "XXXXXXXXXXXX!", zsh added literal backslashes before each bang, and those backslashes got base64-encoded into the secret. The first time I verified the secret with:
oci secrets secret-bundle get --secret-id <ocid> \
--query "data.\"secret-bundle-content\".content" --raw-output | base64 --decode
I got XXXXXXXXXXXX\!XXXXXXXXXXXX\! back. Two extra backslashes, totally invalid as my actual ADB password. The fix is single quotes or using printf 'value'.
Loading Secrets at Startup
On the application side, I added a function that runs before app.listen() to pull every secret from Vault and stuff them into process.env:
const SECRET_OCIDS = {
ADB_PASSWORD: "ocid1.vaultsecret.oc1.ca-toronto-1.aaaa...",
GEMINI_API_KEY: "ocid1.vaultsecret.oc1.ca-toronto-1.aaaa...",
ADB_USERNAME: "ocid1.vaultsecret.oc1.ca-toronto-1.aaaa...",
OCI_CLIENT_SECRET: "ocid1.vaultsecret.oc1.ca-toronto-1.aaaa...",
};
async function loadVaultSecrets() {
const secrets_sdk = require("oci-secrets");
const client = new secrets_sdk.SecretsClient({
authenticationDetailsProvider: _ociProvider(),
});
const results = {};
for (const [name, secretId] of Object.entries(SECRET_OCIDS)) {
try {
const resp = await client.getSecretBundle({ secretId });
const b64 = resp.secretBundle.secretBundleContent.content;
results[name] = Buffer.from(b64, "base64").toString("utf8");
console.log(` ✓ Vault: ${name} loaded`);
} catch (e) {
console.error(` ✗ Vault: ${name} failed — ${e.message}`);
results[name] = process.env[name] || "";
}
}
return results;
}
// In the boot sequence:
(async () => {
console.log("\n🔐 Loading secrets from OCI Vault...");
const vaultSecrets = await loadVaultSecrets();
process.env.ADB_PASSWORD = vaultSecrets.ADB_PASSWORD;
process.env.GEMINI_API_KEY = vaultSecrets.GEMINI_API_KEY;
process.env.ADB_USERNAME = vaultSecrets.ADB_USERNAME;
// ...
app.listen(PORT, () => {
console.log(`\n🚀 Metis running on http://localhost:${PORT}`);
});
})();
IAM Policy for the Secret Reads
For Vault reads to work, the user (or in production, the Instance Principal) needs read secret-bundles in the compartment:
Allow user xxxxx@domain.com to read secret-bundles in compartment metis
If you are running on OCI Compute later, this becomes:
Allow dynamic-group metis-instances-dg to read secret-bundles in compartment metis
…where the dynamic group matches your compute instance.
What This Looks Like at Startup
🔐 Loading secrets from OCI Vault...
✓ Vault: ADB_PASSWORD loaded
✓ Vault: GEMINI_API_KEY loaded
✓ Vault: ADB_USERNAME loaded
✓ Vault: OCI_CLIENT_SECRET loaded
↺ ADB token cache cleared
✓ OIDC: client initialized
🚀 Metis running on http://localhost:3000
Four secrets loaded in about 800 ms. Not free, but acceptable for a once-per-startup cost.
Updating a Secret
When the Strava refresh token expired and I needed to update the value in Vault, I used:
oci vault secret update-base64 \
--secret-id ocid1.vaultsecret.oc1.ca-toronto-1.aaaa... \
--secret-content-content $(printf 'new-refresh-token' | base64)
Each update-base64 creates a new version of the secret. The previous version is retained for rollback. Cool ehh 😉
Closing Thoughts
Moving to Vault removed an entire class of problems (secrets on disk, secrets in git, secrets in screenshots), and it gave me audit logs for every secret access. The OCI Vault free tier covers the volumes I need easily, and the SDK integration is one line.
If you have an OCI application with secrets in a config file, do yourself a favor and move them to Vault. The 30 minutes you spend on the migration will save you a future bad day.
And that wraps up the Metis series. Four posts, one personal AI co-pilot, and a lot of lessons learned along the way. If you want to follow along with future versions, I am planning to add Instance Principal auth, ADB private endpoint, and full HTTPS/TLS next, keep an eye on the blog.
As always, hit me up if you have questions or want me to go deeper on any of the pieces. Adios for now.
No Comments