← All articles
CI/CD Secrets Management for Developers: .env, Vaults, and... 2026-02-09 · 4 min read · secrets · security · dotenv

Secrets Management for Developers: .env, Vaults, and Best Practices

CI/CD 2026-02-09 · 4 min read secrets security dotenv environment-variables devops

Secrets Management for Developers: .env, Vaults, and Best Practices

Every application has secrets — API keys, database passwords, signing keys, OAuth tokens. How you manage them determines whether they end up in a git commit, a Slack message, or a breach notification. Most developers know not to hardcode secrets, but the gap between "don't commit secrets" and "actually manage secrets well" is wider than expected.

.env Files (Development)

The .env pattern is the standard for local development. Store secrets in a .env file, load them as environment variables, and gitignore the file.

# .env (gitignored)
DATABASE_URL=postgresql://dev:dev@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=sk_test_abc123
JWT_SECRET=local-dev-secret-not-for-production
STRIPE_SECRET_KEY=sk_test_456
# .env.example (committed — template without real values)
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
JWT_SECRET=generate-a-random-string
STRIPE_SECRET_KEY=sk_test_your-key

Loading .env Files

Node.js/Bun:

Bun loads .env automatically. Node.js 20.6+ supports --env-file:

# Bun — automatic
bun run app.ts

# Node.js
node --env-file=.env app.js

For older Node.js versions:

import 'dotenv/config';
// process.env.DATABASE_URL is now available

Python:

from dotenv import load_dotenv
import os

load_dotenv()
db_url = os.environ["DATABASE_URL"]

.gitignore

# .gitignore
.env
.env.local
.env.*.local
!.env.example

Always commit a .env.example with placeholder values so new developers know what variables to set.

Secret Scanning

git-secrets

Prevent committing secrets with a pre-commit hook:

brew install git-secrets

# Install hooks in your repo
cd my-project && git secrets --install

# Add patterns to detect
git secrets --register-aws  # Catches AWS keys

# Add custom patterns
git secrets --add 'sk_live_[a-zA-Z0-9]{24}'  # Stripe live keys
git secrets --add 'ghp_[a-zA-Z0-9]{36}'       # GitHub personal tokens

gitleaks

gitleaks scans your entire git history for leaked secrets:

brew install gitleaks

# Scan current state
gitleaks detect

# Scan entire git history
gitleaks detect --log-opts="--all"

# Use in CI (GitHub Actions)
# .github/workflows/security.yml
- name: Gitleaks
  uses: gitleaks/gitleaks-action@v2
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GitHub Secret Scanning

GitHub automatically scans public repositories for known secret patterns (AWS keys, Stripe keys, Twilio tokens, etc.) and notifies you. For private repos, enable it in repository settings → Security → Secret scanning.

Secrets in CI/CD

GitHub Actions Secrets

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: ./deploy.sh

GitHub masks secret values in logs automatically. Never echo secrets — echo $API_KEY shows *** in logs, but echo ${API_KEY:0:5} would leak the first 5 characters.

Limitations:

Environment-Specific Secrets

GitHub supports deployment environments with their own secrets:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Uses production secrets
    steps:
      - env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}  # Production URL
        run: ./deploy.sh

Production Secret Stores

1Password CLI (op)

1Password works as a secrets manager, not just a password manager. The CLI retrieves secrets at runtime:

brew install 1password-cli

# Sign in
op signin

# Read a secret
op read "op://Vault/Database/password"

# Inject secrets into a command
op run --env-file=.env.tpl -- bun run start
# .env.tpl (template with 1Password references)
DATABASE_URL=postgresql://app:{{ op://Production/Database/password }}@db.example.com:5432/myapp
API_KEY={{ op://Production/Stripe/api-key }}

op run replaces the references with actual values and passes them as environment variables to the command. The secrets never touch disk.

Doppler

Doppler is a dedicated secrets management platform. It syncs secrets across development, staging, and production.

brew install dopplerhq/cli/doppler

# Setup
doppler setup  # Interactive — select project and environment

# Run with injected secrets
doppler run -- bun run start

# Fetch a specific secret
doppler secrets get DATABASE_URL --plain

Doppler's strength is managing secrets across environments. You define secrets once and configure them per environment (dev, staging, production). It integrates with GitHub Actions, Vercel, AWS, and most deployment platforms.

Pricing: Free for 5 users and 3 projects. Paid starts at $4/user/month.

SOPS (Secrets OPerationS)

SOPS encrypts secrets files so they can be committed to git. Only specific keys (AWS KMS, GCP KMS, age, PGP) can decrypt them.

brew install sops age

# Generate an age key
age-keygen -o key.txt
# Public key: age1abc...

# Create a SOPS config
cat > .sops.yaml << 'EOF'
creation_rules:
  - path_regex: secrets/.*\.yaml
    age: age1abc...
EOF

# Encrypt a file
sops secrets/production.yaml
# Opens your editor — save and the file is encrypted
# secrets/production.yaml (encrypted — safe to commit)
database_url: ENC[AES256_GCM,data:abc123...,tag:def456...]
api_key: ENC[AES256_GCM,data:ghi789...,tag:jkl012...]
sops:
    age:
        - recipient: age1abc...
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ...
# Decrypt and use
sops -d secrets/production.yaml
# Or pipe to a command
sops -d secrets/production.yaml | yq '.database_url'

SOPS is good for small teams that want secrets in git (versioned, auditable) without a separate secret store.

Best Practices

  1. Never commit secrets. Use .env.example for templates, gitignore actual .env files, and run gitleaks in CI.

  2. Use different secrets per environment. Dev, staging, and production should have separate database passwords, API keys, and signing keys.

  3. Rotate secrets regularly. When someone leaves the team, rotate every secret they had access to.

  4. Prefer short-lived tokens. OAuth tokens that expire in an hour are safer than API keys that last forever.

  5. Audit access. Know who has access to production secrets. Use tools that log access (1Password, Doppler, AWS Secrets Manager).

  6. Don't pass secrets as command-line arguments. They show up in ps output. Use environment variables or files.

# Bad — visible in process list
./app --db-password=secret123

# Good — environment variable
DATABASE_URL=postgresql://... ./app

# Good — file reference
./app --config /run/secrets/db-password
  1. Least privilege. Give each service only the secrets it needs. Your frontend doesn't need the database password.