Migrate from .env Files
You have a fleet of services reading credentials from .env files or environment variables, set by your deployment pipeline. This tutorial walks through moving them into ScaiVault with no downtime and a path back if anything breaks.
The pattern works for any "credentials in env" setup: 12-factor apps, Docker Compose, Kubernetes ConfigMap-as-env, GitHub Actions secrets, Heroku config vars. The principle is the same — read from a single canonical place at startup.
What you need#
- ScaiVault token with
secrets:readandsecrets:write. - One service to start with. Pick a low-stakes one for the first pass.
- Optionally: the Python SDK or CLI for scripting bulk imports.
1. Inventory what you have#
Before importing anything, list what you're trying to migrate. For a typical Python service:
1 2 3 4 5 6 7 | |
Categorize each one:
| Variable | Sensitive? | Should migrate? | Notes |
|---|---|---|---|
DATABASE_URL |
Yes (contains password) | Yes | Split into DB_HOST, DB_USER, DB_PASS, DB_NAME later |
STRIPE_SECRET_KEY |
Yes | Yes | High-value, rotate quarterly |
SALESFORCE_* |
Yes | Yes | Group as one JSON secret |
SLACK_WEBHOOK_URL |
Yes-ish (URL contains a token) | Yes | |
SENTRY_DSN |
No (intentionally public) | No | Keep in env |
Anything not sensitive — port numbers, feature flags, public URLs, Sentry DSNs — stays in env. ScaiVault is for things that should not appear in kubectl describe pod.
2. Decide your path scheme#
A path scheme is a contract: a service knows where its secrets live, the policy model knows what to permit, the dashboard knows what to show. Bad path schemes haunt you for years.
A scheme that scales:
1 | |
Examples:
1 2 3 4 5 | |
Recommendations:
environments/<env>/<service>/...for per-service per-env credentials.shared/...for things multiple services use (one Salesforce credential, many consumers).infra/...for backing-service root credentials (DB roots, AWS deploy keys) that should rarely be read by application code.
Don't put environment in the credential name (stripe-prod) — that gets out of sync with the actual deployment environment. Put it in the path.
3. Import one service#
For the billing service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Verify:
1 | |
4. Grant the service access#
Create a service account in ScaiKey for the billing service (or use one that exists), then a narrow policy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
The billing service's token can now read its own credentials and nothing else.
5. Adapt the service code#
Goal: minimal diff, ability to fall back to env vars if ScaiVault is unreachable at startup. This rollback path is the whole reason to do staged migration — without it, ScaiVault becomes a hard dependency before you've earned trust in it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | |
6. Roll out gradually#
Stage 1: deploy the code to staging with USE_SCAIVAULT=true and SCAIVAULT_FALLBACK_ALLOWED=true. Verify it picks up values from ScaiVault.
Stage 2: roll the same code to production with USE_SCAIVAULT=false. Wait a day. Nothing should change — you've just deployed code that can read from ScaiVault but isn't yet.
Stage 3: flip to USE_SCAIVAULT=true in production, keep fallback on. If anything breaks (token problem, network issue), the env values bail you out and you see warnings in logs.
Stage 4: after a week with no fallback warnings, set SCAIVAULT_FALLBACK_ALLOWED=false. The service now hard-fails if ScaiVault is unreachable. This is what you want long-term — silent fallback hides problems.
Stage 5: remove the env vars from the deployment pipeline. Now they only exist in ScaiVault.
7. Audit-check the migration#
1 2 3 4 5 6 | |
You should see steady reads from the paths you imported. If sa:billing-prod shows zero activity but the service is running, it's still using env vars — something didn't roll out.
8. Wire rotation policies#
Now that the secret is canonical, put a rotation policy on the ones that warrant it:
1 2 3 4 5 6 7 | |
For database with auto_generate: true, you also need a webhook to actually ALTER ROLE in Postgres — see the Postgres tutorial.
9. Bulk-import a fleet (optional)#
For dozens of services with similar .env files, scripting helps:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | |
Run it once. The output is deterministic and rerunnable — re-running just creates new versions, which is fine.
What you have now#
- Credentials in one canonical place per environment, identifiable by path.
- Audit log shows which service reads which credential when.
- A rollback path (env vars) in place for as long as you want it.
- A scheme to extend: when you add another service or another environment, the pattern carries over.
Failure modes worth knowing#
ScaiVault is unreachable at service startup. With fallback off, the service hard-fails to start. Good. Investigate, fix, restart. If you can't fix immediately, set fallback to true and rotate the values out of env later.
Secret missing. 404 secret_not_found from client.secrets.read(). Either the path is wrong (typo) or the import never ran. Check audit for the import and scaivault secrets list --prefix ....
Access denied even though scopes look right. Probably a group membership issue — the service account is bound via a group it doesn't belong to. scaivault auth whoami from inside the service shows current group membership; scaivault policies test <path> read --identity <sa> shows which rule allows or blocks.
What's next#
- Rotation tutorial — put quarterly rotation on imported credentials.
- Migrate from HashiCorp Vault — different starting point, same destination.
- Cookbook — common patterns for the steady state.