Environment Variable Security: How to Stop Leaking Secrets in Your Code


Environment Variable Security: How to Stop Leaking Secrets in Your Code

Every non-trivial application has secrets: database passwords, API keys, OAuth tokens, encryption keys, webhook signing secrets. The standard advice is “use environment variables.” And that advice is correct — environment variables are better than hardcoding secrets in source code. But the advice is incomplete, because environment variables come with their own set of security pitfalls that most developers learn about the hard way.

The hard way usually looks like this: a Slack message from your CTO at 2 AM asking why your AWS bill just hit $47,000, or a security researcher emailing your team to let you know that your production database credentials have been indexed by GitHub search for the past three months.

This guide covers how secrets actually leak in practice, how to handle environment variables properly, and what tools exist to manage secrets at scale. If you have ever typed a password into a .env file and hoped for the best, this is for you.

How Secrets Leak: Real-World Examples

Secret leaks are not theoretical. They happen constantly, to companies of every size, and the consequences range from embarrassing to catastrophic.

Uber (2016): Two attackers accessed a private GitHub repository used by Uber engineers. Inside, they found AWS credentials stored in the codebase. Using those credentials, they accessed an S3 bucket containing personal data of 57 million riders and drivers. Uber paid the attackers $100,000 through their bug bounty program to destroy the data and keep quiet. The breach was not disclosed publicly until 2017, resulting in a $148 million settlement.

Samsung (2022): Samsung engineers accidentally uploaded internal source code to a public GitHub repository, including plaintext credentials for Samsung’s backend services. The exposed data included secret keys for Samsung SmartThings and private certificates used for server authentication. The credentials were live and accessible for weeks before being discovered and revoked.

Twitter (2023): A section of Twitter’s source code, including embedded credentials and internal API keys, was uploaded to a public GitHub repository by a former employee. Twitter filed a DMCA takedown to get the code removed, but the exposure window was long enough that the credentials had been scraped by automated bots.

CircleCI (2023): After a security breach, CircleCI advised all customers to rotate every secret stored in their platform. Every environment variable, every context, every API key — all compromised. This affected thousands of organizations that trusted their CI/CD platform to keep secrets safe.

These are the incidents that made headlines. For every public breach, there are hundreds of quiet ones: startups that got their AWS accounts hijacked because someone committed a .env file, freelancers whose Stripe keys ended up in a public repo, teams that shared database credentials over Slack and forgot about it.

The common thread is not malice. It is convenience. Developers take shortcuts because secure secret management is friction, and friction gets eliminated under deadline pressure.

Environment Variables 101

Before discussing security, it helps to understand what environment variables actually are at the operating system level.

An environment variable is a key-value pair maintained by the operating system for each process. When a process starts, it inherits a copy of its parent process’s environment. Programs can read these values at runtime without them appearing in source code.

How They Work Across Operating Systems

Linux and macOS use the same underlying mechanism. You set variables in the shell:

# Set for current session
export DATABASE_URL="postgres://user:password@localhost:5432/mydb"

# Or inline for a single command
DATABASE_URL="postgres://user:password@localhost:5432/mydb" node server.js

# View all environment variables
env

# View a specific variable
echo $DATABASE_URL

Variables set with export persist for the duration of the shell session. For persistence across sessions, you add them to ~/.bashrc, ~/.zshrc, or /etc/environment.

Windows uses a different syntax in Command Prompt and PowerShell:

# Command Prompt
set DATABASE_URL=postgres://user:password@localhost:5432/mydb

# PowerShell
$env:DATABASE_URL = "postgres://user:password@localhost:5432/mydb"

# Permanent (user level)
[System.Environment]::SetEnvironmentVariable("DATABASE_URL", "value", "User")

The .env File Pattern

Typing export for 15 different variables every time you open a terminal is tedious. The .env file pattern solves this by storing variables in a file that gets loaded automatically.

A .env file is just a text file with key-value pairs:

# .env
DATABASE_URL=postgres://user:password@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
API_KEY=sk-live-abc123def456
SMTP_PASSWORD=p@ssw0rd!secure
JWT_SECRET=a1b2c3d4e5f6g7h8i9j0

Libraries like dotenv (available for Node.js, Python, Ruby, PHP, Go, and nearly every other language) read this file at application startup and inject the values into the process environment:

// Node.js
require('dotenv').config();
// or in modern Node.js 20.6+
// node --env-file=.env server.js

const dbUrl = process.env.DATABASE_URL;
# Python
from dotenv import load_dotenv
import os

load_dotenv()
db_url = os.environ.get("DATABASE_URL")

The .env file is meant to be a local-only development convenience. It should never be committed to version control, deployed to production servers, or shared between team members through insecure channels. This is where most teams get into trouble.

Common Mistakes Developers Make

Environment variable security is not about sophisticated attacks. It is about everyday mistakes that create openings.

1. Committing .env Files to Version Control

This is the most common and most dangerous mistake. A developer initializes a new project, creates a .env file with real credentials, and runs git add . without thinking. The .env file gets committed, pushed to GitHub, and now every person with access to that repository — plus anyone who forks it, plus any automated scanner crawling public repos — has your secrets.

The critical detail most developers miss: removing the file in a subsequent commit does not fix the problem. Git stores the complete history of every file. Anyone can run git log --all --full-history -- .env and then git show <commit-hash>:.env to retrieve the file contents from any point in history. The secret remains in the repository forever unless you rewrite Git history.

Automated bots continuously scan GitHub for committed secrets. Tools like TruffleHog and services like GitGuardian report finding thousands of new secrets exposed on public repositories every single day. The window between committing a secret and an attacker finding it can be as short as minutes.

2. Using the Same Secrets Across Environments

When your development, staging, and production environments all share the same database password or API key, a compromise in any environment compromises all of them. A developer’s laptop gets stolen with the .env file for local development — and those same credentials work against production.

Each environment should have its own set of secrets, with production secrets known to the fewest people possible.

3. Sharing Secrets via Slack, Email, or Text Messages

“Hey, can you send me the API key for the payment gateway?”

This message appears in Slack channels thousands of times a day. Now that API key lives in Slack’s search index, in Slack’s database backups, on every device where that channel is synced, and in the memory of Slack’s servers. If anyone’s Slack account is compromised, the attacker gets a searchable history of credentials.

The same applies to email, SMS, and any other communication channel not designed for secret sharing.

4. Hardcoding API Keys in Frontend Code

Frontend JavaScript runs in the user’s browser. Every variable, every string, every API key in your frontend bundle is visible to anyone who opens browser developer tools. Minification and obfuscation are not protection — they are trivially reversible.

// This is visible to every user of your application
const stripe = Stripe('pk_live_abc123def456');

// This is also visible, even if you use environment variables at build time
const apiKey = process.env.REACT_APP_SECRET_API_KEY;
// After build, this becomes: const apiKey = "sk-actual-secret-value";

Any secret embedded in frontend code at build time is a public secret. Only publishable keys (like Stripe’s pk_ prefixed keys, which are designed to be public) belong in frontend code. Any key that grants write access or accesses private data must stay on the server.

5. Not Rotating Secrets

An API key created three years ago and never rotated has had three years of exposure surface. Every developer who has ever worked on the project has seen it. Every backup that contains it is a potential leak vector. Every log file that accidentally captured it is a risk.

Regular rotation limits the window of exposure. If a key is compromised but rotated monthly, the attacker has at most 30 days of access instead of indefinite access.

6. Overly Broad Permissions

When you create an AWS IAM key with AdministratorAccess because “it’s easier,” you are giving that key the ability to delete every resource in your account, create new users, access billing information, and launch cryptocurrency mining instances in every region. When that key leaks — and given enough time and enough hands touching it, secrets do leak — the blast radius is your entire infrastructure.

The same principle applies to database users (don’t use root), API keys (use scoped tokens), and OAuth tokens (request minimum required scopes).

7. Logging Environment Variables During Debugging

During a debugging session, it is tempting to dump the entire environment to figure out why something is misconfigured:

// Do not do this
console.log('Environment:', process.env);

// Or this
console.log('DB Config:', {
  host: process.env.DB_HOST,
  password: process.env.DB_PASSWORD  // Now in your log files
});

Log files are often stored in plain text, shipped to log aggregation services, shared during incident investigations, and retained for months or years. A password that appeared in one debug log line can persist in your infrastructure long after the debugging session is over.

Environment Variable Security Best Practices

Always Add .env to .gitignore

This should happen at project initialization, before the first commit. Not after. Not “when we get around to it.” At the very beginning.

# .gitignore
.env
.env.local
.env.*.local
.env.production
.env.staging

To verify that .env is not already tracked:

# Check if .env is tracked by git
git ls-files --error-unmatch .env 2>/dev/null && echo "WARNING: .env is tracked!" || echo "OK: .env is not tracked"

If it is already tracked, removing it from .gitignore alone will not stop Git from tracking it. You need to explicitly untrack it:

git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env from tracking"

But remember: the file contents are still in Git history. If real secrets were committed, you need to rotate those secrets immediately and rewrite Git history (covered later in this article).

Provide a .env.example File

Every project should include a .env.example (or .env.template) file that documents all required environment variables with placeholder values:

# .env.example - Copy to .env and fill in real values
DATABASE_URL=postgres://user:password@localhost:5432/dbname
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
SMTP_HOST=smtp.example.com
SMTP_PASSWORD=your-smtp-password
JWT_SECRET=generate-a-random-string-here

This file gets committed to version control. It tells new developers what variables they need to configure without exposing any actual secret values. When someone clones the repo, they copy .env.example to .env and fill in their own values.

Use Different Secrets Per Environment

Never reuse secrets across development, staging, and production. Each environment should have independently generated credentials:

# Development .env
DATABASE_URL=postgres://devuser:devpass@localhost:5432/myapp_dev
STRIPE_KEY=sk_test_development123

# Staging .env (stored in secrets manager, not in a file)
DATABASE_URL=postgres://staging_user:random32chars@staging-db:5432/myapp_staging
STRIPE_KEY=sk_test_staging456

# Production (stored in secrets manager, not in a file)
DATABASE_URL=postgres://prod_user:different_random32@prod-db:5432/myapp_prod
STRIPE_KEY=sk_live_production789

Production secrets should ideally never exist in .env files at all. They should live in a secrets manager and be injected at runtime.

Scan for Secrets in Your Codebase

Automated scanning catches what human review misses. Two excellent open-source tools exist for this:

gitleaks scans your Git repository for secrets using pattern matching and entropy detection:

# Install
brew install gitleaks

# Scan the current repo
gitleaks detect --source . --verbose

# Scan before committing (use as a pre-commit hook)
gitleaks protect --staged --verbose

git-secrets (by AWS) prevents you from committing secrets that match configurable patterns:

# Install
git clone https://github.com/awslabs/git-secrets.git
cd git-secrets && make install

# Initialize for your repo
cd /path/to/your/repo
git secrets --install
git secrets --register-aws

# Now any git commit that contains AWS keys will be rejected

You can integrate either tool as a pre-commit hook so that secrets are caught before they reach the repository:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

Never Log Sensitive Environment Variables

Build a habit of filtering sensitive values before logging. Create a utility function that redacts known secret keys:

// utils/sanitize-env.js
const SENSITIVE_KEYS = [
  'PASSWORD', 'SECRET', 'TOKEN', 'API_KEY', 'PRIVATE_KEY',
  'DATABASE_URL', 'REDIS_URL', 'SMTP_PASSWORD'
];

function sanitizeEnv(env) {
  const sanitized = {};
  for (const [key, value] of Object.entries(env)) {
    const isSensitive = SENSITIVE_KEYS.some(
      s => key.toUpperCase().includes(s)
    );
    sanitized[key] = isSensitive ? '[REDACTED]' : value;
  }
  return sanitized;
}

// Safe to log
console.log('Environment:', sanitizeEnv(process.env));
# Python equivalent
import os
import re

SENSITIVE_PATTERN = re.compile(
    r'(PASSWORD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|DATABASE_URL)', re.IGNORECASE
)

def sanitize_env():
    return {
        key: '[REDACTED]' if SENSITIVE_PATTERN.search(key) else value
        for key, value in os.environ.items()
    }

Apply Least Privilege to API Keys

When creating API keys or service accounts, grant only the permissions the application actually needs:

  • AWS: Create IAM policies that allow only specific actions on specific resources. Never use */* policies in production.
  • Database: Create application-specific users with SELECT, INSERT, UPDATE on only the tables they need. Never connect your application as root or postgres.
  • Stripe: Use restricted keys that only have access to the API endpoints your application uses.
  • GitHub: Use fine-grained personal access tokens scoped to specific repositories and permissions.

Rotate Secrets Regularly

Set a rotation schedule and treat it as non-negotiable infrastructure maintenance:

  • API keys: Rotate every 90 days, or immediately if a team member leaves.
  • Database passwords: Rotate every 90 days.
  • JWT signing secrets: Rotate every 90 days. Implement key rollover so existing tokens remain valid during the transition period.
  • Encryption keys: Implement key versioning so old data can be decrypted with old keys while new data uses new keys.

Automate rotation wherever possible. AWS Secrets Manager, for example, can rotate RDS database passwords automatically.

Secrets Management Tools Comparison

For teams beyond a few developers, .env files are not sufficient. Secrets management tools provide centralized storage, access control, audit logging, and rotation.

ToolTypePricingBest For
HashiCorp VaultSelf-hosted / CloudFree (OSS) / $1.58/hr (Cloud)Large enterprises with dedicated platform teams
DopplerSaaSFree tier / $19/user/month (Team)Teams wanting zero-setup secrets management
InfisicalOpen source / SaaSFree (OSS) / $8/user/month (Cloud)Teams wanting open-source with a SaaS option
1Password CLISaaS + CLI$7.99/user/month (Business)Teams already using 1Password for passwords
AWS Secrets ManagerCloud (AWS)$0.40/secret/month + $0.05/10k API callsAWS-native applications
GCP Secret ManagerCloud (GCP)$0.06/secret version/month + $0.03/10k access opsGCP-native applications
SOPSOpen source CLIFreeEncrypting config files in Git repos
Docker SecretsDocker SwarmFree (part of Docker)Docker Swarm deployments

HashiCorp Vault

Vault is the industry standard for secrets management in large organizations. It supports dynamic secrets (generating short-lived database credentials on demand), encryption as a service, and integrations with virtually every platform. The tradeoff is operational complexity — running Vault in production requires dedicated effort.

# Example: Reading a secret from Vault
vault kv get -field=password secret/myapp/database

# Dynamic database credentials
vault read database/creds/myapp-role
# Returns: username=v-token-myapp-abc123 password=random-generated-pw TTL=1h

Doppler

Doppler provides a centralized dashboard for managing environment variables across all environments and services. It integrates with most deployment platforms and can inject secrets directly into your application runtime:

# Install Doppler CLI
brew install dopplerhq/cli/doppler

# Run your app with secrets injected
doppler run -- node server.js

# Or export to .env format
doppler secrets download --no-file --format env

Infisical

Infisical is an open-source secrets manager that you can self-host or use as a SaaS. It provides SDKs for direct integration:

const { InfisicalClient } = require("@infisical/sdk");

const client = new InfisicalClient({
  siteUrl: "https://app.infisical.com",
});

const secret = await client.getSecret({
  environment: "production",
  projectId: "your-project-id",
  secretName: "DATABASE_URL",
});

SOPS (Secrets OPerationS)

SOPS, created by Mozilla, encrypts configuration files so they can be safely stored in Git. Unlike other tools, SOPS encrypts values while leaving keys in plaintext, making diffs readable:

# Encrypted with SOPS - keys visible, values encrypted
database:
    password: ENC[AES256_GCM,data:abc123def456,iv:...,tag:...,type:str]
    host: ENC[AES256_GCM,data:ghi789,iv:...,tag:...,type:str]
api:
    key: ENC[AES256_GCM,data:xyz456,iv:...,tag:...,type:str]
# Encrypt a file
sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p secrets.yaml > secrets.enc.yaml

# Decrypt and edit
sops secrets.enc.yaml

# Decrypt to stdout
sops --decrypt secrets.enc.yaml

Docker and Container Security

Containers add another layer of complexity to secrets management. A Docker image is a distributable artifact, and any secret baked into an image layer is extractable by anyone who has the image.

Never Bake Secrets into Docker Images

This is wrong:

# BAD - Secret is permanently embedded in the image layer
FROM node:20-alpine
ENV DATABASE_URL=postgres://user:password@prod-db:5432/myapp
COPY . .
RUN npm install
CMD ["node", "server.js"]

Even if you unset the variable in a later layer, the earlier layer still contains it. Anyone can run docker history --no-trunc <image> to see every layer’s commands and environment variables.

Also wrong:

# BAD - .env file is baked into the image
FROM node:20-alpine
COPY . .
# Even if you delete it later, it exists in a previous layer
RUN rm .env
CMD ["node", "server.js"]

Use Multi-Stage Builds

Multi-stage builds let you use secrets during the build process without including them in the final image:

# Build stage - has access to secrets for private package installation
FROM node:20-alpine AS builder
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
COPY package*.json ./
RUN npm install
RUN rm .npmrc

# Production stage - no secrets present
FROM node:20-alpine
COPY --from=builder /node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

With Docker BuildKit (default in modern Docker), you can use secret mounts that never appear in any layer:

# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) && \
    echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
    npm install && \
    rm .npmrc
docker build --secret id=npm_token,src=.npm_token .

Inject Secrets at Runtime

The correct pattern is to pass secrets when the container starts, not when the image is built:

# docker-compose.yml
services:
  app:
    image: myapp:latest
    env_file:
      - .env  # Loaded from host filesystem at runtime
    environment:
      - NODE_ENV=production

For Docker Swarm, use Docker’s built-in secrets management:

# Create a secret
echo "my-database-password" | docker secret create db_password -

# Use in a service
docker service create \
  --name myapp \
  --secret db_password \
  myapp:latest

Inside the container, the secret is available as a file at /run/secrets/db_password. Your application reads the file instead of an environment variable:

const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();

Add .env to .dockerignore

Just like .gitignore prevents Git from tracking files, .dockerignore prevents the build context from including files:

# .dockerignore
.env
.env.*
*.pem
*.key

Without this, COPY . . in your Dockerfile will copy your .env file into the image.

CI/CD Pipeline Security

CI/CD pipelines need access to secrets for deployments, running tests against real services, and publishing packages. Every major CI/CD platform provides a mechanism for this.

GitHub Actions Secrets

GitHub Actions stores secrets encrypted at rest and injects them as environment variables during workflow runs. They are automatically masked in log output:

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          ./deploy.sh

Important rules for GitHub Actions secrets:

  • Secrets are not available to workflows triggered by pull requests from forks (this is a security feature).
  • Use environment-specific secrets by creating GitHub Environments (production, staging) with separate secret sets and protection rules.
  • Never use echo to print secrets, even for debugging. GitHub will mask known secret values, but transformations of the value (base64, substring) will not be masked.

GitLab CI/CD Variables

GitLab provides CI/CD variables that can be marked as protected (only available on protected branches) and masked (hidden in job logs):

# .gitlab-ci.yml
deploy_production:
  stage: deploy
  environment: production
  variables:
    DATABASE_URL: $DATABASE_URL  # Set in GitLab UI as masked + protected
  script:
    - ./deploy.sh
  only:
    - main

General CI/CD Rules

Regardless of which platform you use, follow these rules:

  1. Never echo secrets in build scripts. Not even temporarily for debugging. Use set +x in bash scripts to disable command tracing when working with secrets.
#!/bin/bash
set -e

# Disable command tracing to avoid printing secrets
set +x

echo "Deploying with masked credentials..."
export DATABASE_URL="${DATABASE_URL}"

# Your deployment commands here
./deploy.sh

echo "Deployment complete."
  1. Use OIDC/workload identity federation instead of long-lived keys. AWS, GCP, and Azure all support short-lived credentials for CI/CD platforms. Instead of storing an AWS access key as a CI secret, configure OIDC so that GitHub Actions receives a temporary token that expires after the workflow completes.
# GitHub Actions with AWS OIDC - no stored AWS keys needed
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: us-east-1
  1. Limit secret access to specific branches and environments. Production secrets should only be available to workflows running on the main branch, not on feature branches or pull requests.

What To Do When Secrets Leak

If you discover that secrets have been exposed — in a Git commit, in a log file, in a Slack message, anywhere — treat it as a security incident. Speed matters. Every minute the compromised credential remains active is a minute an attacker could be using it.

Step 1: Revoke Immediately

Do not wait. Do not investigate first. Do not “see if anyone actually accessed it.” Revoke the compromised credential right now.

  • API keys: Regenerate them in the provider’s dashboard (AWS IAM, Stripe, GitHub, etc.).
  • Database passwords: Change them immediately. Update all services that depend on them.
  • OAuth tokens: Revoke them at the authorization server.
  • SSH keys: Remove the public key from all servers and authorized_keys files.

If your AWS access key leaked, assume the attacker used it to discover other credentials. Rotate everything the compromised key had access to. If your database password leaked, change it and also reset any application-level API keys that the database stores.

Step 3: Audit Access Logs

Now investigate what happened during the exposure window:

  • Check cloud provider audit logs (AWS CloudTrail, GCP Audit Logs) for unauthorized activity.
  • Review database query logs for unusual access patterns.
  • Check for new IAM users, roles, or policies that an attacker may have created for persistent access.
  • Look for unauthorized data exports or API calls.

Step 4: Remove Secrets from Git History

If secrets were committed to Git, removing the file in a new commit is not sufficient. You need to rewrite history.

BFG Repo-Cleaner (faster and simpler than git filter-branch):

# Install BFG
brew install bfg

# Remove the .env file from all history
bfg --delete-files .env

# Or replace specific strings in all history
echo "sk-live-abc123def456" > passwords.txt
bfg --replace-text passwords.txt

# Clean up and force push
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force

git filter-repo (the modern replacement for git filter-branch):

pip install git-filter-repo

# Remove a specific file from all history
git filter-repo --invert-paths --path .env

# Force push the rewritten history
git push --force --all

After rewriting history, every collaborator must re-clone the repository. Their local copies still contain the old history with the exposed secrets.

Step 5: Enable Automated Scanning

Prevent this from happening again:

  • GitHub Secret Scanning: Automatically detects committed secrets for public repositories (free) and private repositories (GitHub Advanced Security license). GitHub partners with providers like AWS, Azure, and Stripe to automatically revoke detected credentials.
  • Pre-commit hooks: Install gitleaks or git-secrets as pre-commit hooks so that secrets are caught before they reach the repository.
  • CI pipeline scanning: Add a gitleaks scan step to your CI pipeline that fails the build if secrets are detected.
# Add to your CI pipeline
- name: Scan for secrets
  uses: gitleaks/gitleaks-action@v2
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Step 6: Notify Affected Parties

If the leaked credentials could have exposed user data, you may have legal obligations to notify affected users (GDPR, CCPA, HIPAA, depending on your jurisdiction and industry). Consult your legal team. Document the timeline of the incident, the scope of potential exposure, and the remediation steps taken.

Putting It All Together: A Secure Setup

Here is what a well-secured project looks like in practice:

my-project/
  .gitignore          # Includes .env, .env.*, *.pem, *.key
  .dockerignore       # Includes .env, .env.*
  .env.example        # Placeholder values, committed to repo
  .env                # Real values, never committed
  .pre-commit-config.yaml  # gitleaks hook configured
  .github/
    workflows/
      deploy.yml      # Uses GitHub Actions secrets, OIDC for AWS
  docker-compose.yml  # Uses env_file directive, no inline secrets
  Dockerfile          # Multi-stage build, no ENV with secrets

For local development, each developer copies .env.example to .env and fills in their own development credentials. For staging and production, secrets live in a secrets manager (Doppler, Vault, AWS Secrets Manager, or similar) and are injected at deploy time. The CI/CD pipeline accesses secrets through the platform’s native secrets mechanism, with production secrets restricted to protected branches.

No secrets in Git. No secrets in Docker images. No secrets in log files. No secrets in Slack. That is the goal. It requires discipline and the right tools, but the alternative — a 2 AM phone call about compromised credentials — is worse.

The discipline of environment variable security is not glamorous work. Nobody gets promoted for properly configuring .gitignore. But the developers who build this muscle memory early are the ones who avoid the breaches that end careers and sink startups. Start with .gitignore and .env.example. Add gitleaks as a pre-commit hook. Pick a secrets manager that fits your team size. Then build from there.