How to buildOctober 11, 202611 min read
Docker and deploymentStep 5 of 8

Secrets and environment variables: how not to leak your keys

In short

Secrets — passwords, API keys, tokens and connection strings — do not belong in code: the app reads them from environment variables, locally from a .env file covered by .gitignore, while the repository holds only an .env.example template. In CI and on hosting, values live in their secret stores, and nothing reaches the browser bundle except variables with a public prefix such as NEXT_PUBLIC_. If a key does leak, you first revoke and reissue it, and only then decide whether the Git history needs rewriting.

How-toLevel: IntermediateSecrets ManagementPractice: ~30–60 min

After this you will be able to

  • separate config from secrets and set up .env, .env.example and .gitignore so secrets never reach Git
  • load variables in Node.js with --env-file and stop the app at startup if a required variable is missing
  • explain which variables Next.js and Vite inline into the client bundle, and check a build for leaks
  • pass secrets to GitHub Actions, hosting and containers without leaking them into images and logs, find a key in history with gitleaks and follow a plan when a key leaks

Verified with

Node.js 24 LTS (24.20)Next.js 16.4gitleaks 8.30.1git-filter-repo 2.47Docker Engine 29.6GitHub Actions

Checked against docs: October 11, 2026

Contents
  1. 01What counts as a secret
  2. 02.env, .env.example and .gitignore
  3. 03Loading in Node.js: --env-file and a startup check
  4. 04Frontend: what ends up in the bundle
  5. 05Secrets in CI, on hosting and in Docker
  6. 06Scanning: gitleaks and secret scanning
  7. 07A key has leaked: the action plan

What counts as a secret

A secret is any value that grants access: a database password, a payment provider key, a bot token, a JWT signing key, a private SSH key, a connection string with a login and password. Config is everything that differs between environments (development, staging, production) but grants no access: the port, the site URL, the log level. The Twelve-Factor App methodology requires strict separation of config from code and keeps config in environment variables. Its test is simple: could you open-source the repository right now without compromising a single credential?

PORT=3000, APP_URL, LOG_LEVEL

Secret?
no — config
Where to keep it
environment variables; defaults may live in code

DATABASE_URL with a password

Secret?
yes
Where to keep it
environment variables or a secret manager; locally — .env

payment provider key, bot token

Secret?
yes
Where to keep it
the same; a separate key per environment

browser maps key (NEXT_PUBLIC_MAPS_KEY)

Secret?
no — public by nature
Where to keep it
in the bundle; protected by provider restrictions: domains, quotas

routes, module wiring, build flags

Secret?
no — internal config
Where to keep it
in code: it does not change between environments

.env, .env.example and .gitignore

Locally, variables are convenient to keep in a .env file — one NAME=value per line. The .env file itself never goes into Git. The repository holds an .env.example template instead: the same names, no real values, and comments on where to get them. A new developer copies the template to .env and fills it in — the list of required variables is always visible and changes together with the code.

.env.example
# Copy to .env and fill in. Never commit .env — only this template.

# --- Config (not secret: may differ between dev, staging and production) ---
PORT=3000
APP_URL=http://localhost:3000
LOG_LEVEL=info

# --- Secrets (get them from the team's password manager, never from chat) ---
# PostgreSQL connection string with login and password
DATABASE_URL=postgres://app:CHANGE_ME@localhost:5432/leads
# Payments: test key from the provider dashboard, test mode only
PAYMENTS_SECRET_KEY=sk_test_YOUR_KEY
# Anti-spam API key
SPAM_API_KEY=YOUR_API_KEY
The template: names and explanations, placeholders instead of values
.gitignore
node_modules/
# Local secrets: every .env file except the template
.env
.env.*
!.env.example
Ignores .env and any .env.* (.env.local, .env.production) except the template

You can check that the rule works with git check-ignore -v .env: it prints the .gitignore line that excludes the file. If .env has already been committed, .gitignore will not help — it only affects untracked files. Remove the file from the index and keep it on disk:

bash
echo ".env" >> .gitignore
git rm --cached .env
git commit -m "Stop tracking .env"
The file is no longer tracked but stays in history — treat its values as leaked

Loading in Node.js: --env-file and a startup check

Since Node.js 20.6, .env can be read without the dotenv package: the --env-file flag loads the file into process.env, and as of versions 24.10 and 22.21 it is no longer experimental. According to the documentation, if a variable is already set in the environment, the environment wins — so hosting values override the local file. You can pass several files: each subsequent one overrides the previous. If the file does not exist, Node.js exits with an error; for an optional file there is --env-file-if-exists, and from code — process.loadEnvFile().

package.json
{
  "scripts": {
    "dev": "node --env-file=.env --watch src/main.ts",
    "start": "node src/main.ts"
  }
}
Fragment: locally variables come from .env, in production from the environment set by the hosting platform

Reading process.env all over the code means discovering a missing variable at the moment of the first payment. It is more reliable to build the config in one module and check required variables at startup: an app without its key should not start at all.

src/config.ts
// Reads configuration once at startup and fails fast if something is missing.
const required = ['DATABASE_URL', 'PAYMENTS_SECRET_KEY', 'SPAM_API_KEY'] as const;

const missing = required.filter((name) => !process.env[name]);
if (missing.length > 0) {
  // Name the variables, never print their values
  console.error(`Missing environment variables: ${missing.join(', ')}`);
  process.exit(1);
}

const paymentsKey = process.env.PAYMENTS_SECRET_KEY!;
if (process.env.NODE_ENV === 'production' && paymentsKey.startsWith('sk_test_')) {
  console.error('PAYMENTS_SECRET_KEY: a test key in production');
  process.exit(1);
}

export const config = {
  port: Number(process.env.PORT ?? 3000),
  appUrl: process.env.APP_URL ?? 'http://localhost:3000',
  logLevel: process.env.LOG_LEVEL ?? 'info',
  databaseUrl: process.env.DATABASE_URL!,
  paymentsSecretKey: paymentsKey,
  spamApiKey: process.env.SPAM_API_KEY!,
};

// For logs: show that a secret is set, but not the secret itself
export const mask = (value: string) => `${value.slice(0, 8)}… (${value.length} chars)`;
Config in one place: a startup check, names of missing variables without values, a mask for logs
src/main.ts
import { config, mask } from './config.ts';

console.log(`port=${config.port} log=${config.logLevel}`);
console.log(`payments key: ${mask(config.paymentsSecretKey)}`);
The entry point: only the key mask reaches the log
terminal
$ node --env-file=.env src/main.ts
port=3000 log=debug
payments key: sk_test_… (28 chars)
$ PORT=4000 node --env-file=.env src/main.ts
port=4000 log=debug
payments key: sk_test_… (28 chars)
$ node src/main.ts; echo "exit code: $?"
Missing environment variables: DATABASE_URL, PAYMENTS_SECRET_KEY, SPAM_API_KEY
exit code: 1
$ NODE_ENV=production node --env-file=.env src/main.ts; echo "exit code: $?"
PAYMENTS_SECRET_KEY: a test key in production
exit code: 1
Real output in node:24-alpine (Node.js 24.20): loading from .env, environment precedence, missing variables and a test key in production
  • The error message names the variables but never prints values: more people read logs than have access to secrets.
  • The sk_test_ check in production catches a common deployment mistake: a test payment key on the live server.
  • The string type after the check is asserted with ! — the check above guarantees the value exists. For a large config a validation schema, for example with Zod, is more convenient.

Frontend: what ends up in the bundle

Anything that reaches the browser JavaScript can be read by any visitor: opening DevTools is enough. That is why bundlers do not expose variables to the client by default and only let through prefixed ones. In Next.js the prefix is NEXT_PUBLIC_: according to the documentation, next build substitutes the value into the bundle as a string. A variable without the prefix stays empty on the client. In Vite the prefix is VITE_, and the Vite documentation says plainly that such variables must not contain API keys.

.env.local
# Public: goes into the browser bundle at build time
NEXT_PUBLIC_MAPS_KEY=pk_test_FAKE_maps_browser_key
# Secret: server only
PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36
Next.js loads .env.local automatically; the create-next-app template covers it with .gitignore
app/map-widget.tsx
'use client';

export function MapWidget() {
  // NEXT_PUBLIC_: the value is inlined into the browser bundle
  const mapsKey = process.env.NEXT_PUBLIC_MAPS_KEY;
  // No prefix: on the client Next.js leaves this empty
  const secret = process.env.PAYMENTS_SECRET_KEY;
  return (
    <p>
      maps key: {mapsKey}, payments key on the client: {secret || 'not available'}
    </p>
  );
}
A Client Component: the public key is inlined, the secret one is not
lib/payments.ts
import 'server-only';

// Any import of this module from a Client Component fails the build
export async function createPayment(amount: number) {
  const key = process.env.PAYMENTS_SECRET_KEY;
  if (!key) throw new Error('PAYMENTS_SECRET_KEY is not set');
  // A real app would call the payment provider's API with this key
  return { amount, keyIsSet: key.length > 0 };
}
A server module with the secret; the server-only import keeps it out of client code

A check worth repeating before every release is searching the built client files for secret values:

terminal
$ npx next build
▲ Next.js 16.4.0 (Turbopack)
- Environments: .env.local
…
$ grep -rl "pk_test_FAKE_maps_browser_key" .next/static
.next/static/chunks/3r_kr6wnxt_zd.js
$ grep -rl "sk_test_FAKE7d2c9e41b8a05f36" .next/static || echo "not in the client bundle"
not in the client bundle
Real output: the public key is in a client chunk, the secret is not in .next/static

If a Client Component tries to import lib/payments.ts, the build stops:

terminal
$ npx next build
> Build error occurred
Error: Turbopack build failed with 2 errors:
./lib/payments.ts:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
    Learn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components
> 1 | import 'server-only';
    | ^^^^^^^^^^^^^^^^^^^^^
Ecmascript file had an error
Import traces:
  Server Component:
    ./lib/payments.ts
    ./app/page.tsx
  Client Component Browser:
    ./lib/payments.ts [Client Component Browser]
    ./app/map-widget.tsx [Client Component Browser]
…
Real next build 16.4 output (shortened at …): server-only keeps the secret module out of client code. The message mentions the Pages Router, but Import traces show the real cause — an import from a Client Component

Secrets in CI, on hosting and in Docker

There is usually no .env file on a server: values are set in the hosting dashboard or the CI secret store, and the platform passes them to the process as environment variables. In GitHub Actions you create a secret in Settings → Secrets and variables → Actions or with gh secret set NAME, and read it via secrets.NAME. Environment secrets (environment: production) are available only to the job that references that environment.

.github/workflows/migrate.yml
name: Migrate

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  migrate:
    runs-on: ubuntu-24.04
    environment: production # the job gets production secrets only
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
      - run: npm ci
      - name: Run migrations
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }} # an env var, not a command-line argument
        run: npm run migrate
The secret reaches the step through env: GitHub's documentation advises against passing secrets as command-line arguments. Assumes package.json has a migrate script
  • GitHub masks secret values in logs, but according to its own documentation not reliably: if a secret is transformed (Base64, JSON), masking can fail. Do not store structured data as a single secret, and never print secrets, not even "for debugging".
  • A pull request from a fork receives no secrets. Pull request checks must work without them — see the material on CI/CD with GitHub Actions.
  • On hosting, separate config from secrets. For example, in Vercel a Secret-type variable is write-only after saving, and its value is masked in build logs when it is 32 characters or longer.
  • Use different values for production and preview/staging: preview builds are made from any branch and must not see live keys.

Docker is a separate trap. The Docker documentation warns that build arguments and environment variables are inappropriate for passing secrets to a build, because they persist in the final image. We checked it on an image with ARG PAYMENTS_SECRET_KEY and ENV SPAM_API_KEY=… in the Dockerfile:

terminal
$ docker build -q -t env-leak --build-arg PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36 .
sha256:1eda9198ee8f…
$ docker history --no-trunc env-leak | grep -o "PAYMENTS_SECRET_KEY=[a-zA-Z0-9_]*" | head -1
PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36
$ docker inspect env-leak --format "{{json .Config.Env}}"
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","SPAM_API_KEY=FAKE_spam_key_baked_into_image"]
Real output: the ARG value is visible in docker history, the ENV value in the image metadata. Anyone who pulls the image can read them

Pass secrets to a container at run time (docker run --env-file, orchestrator environment variables or a mounted file), and if a secret is needed during the build — for example, a private npm registry token — use BuildKit secret mounts (RUN --mount=type=secret). OWASP adds an engineering trade-off: environment variables are visible to every process in the container and can end up in dumps, so for sensitive systems files mounted by the orchestrator or a secret manager (Vault, cloud Secret Managers) are preferable.

Scanning: gitleaks and secret scanning

The mistake will happen anyway: a key pasted into a test, into a config "for a minute", into a Markdown example. A scanner finds such strings by the patterns of known keys (Stripe, AWS, GitHub and dozens more) and by entropy. gitleaks is an open-source scanner that runs locally and in CI. The git mode checks the whole commit history via git log -p, the dir mode checks files in a directory.

A test repository: in the first commit the key is written straight into src/payments.ts, in the second it is replaced with process.env.PAYMENTS_SECRET_KEY. The current files contain no key, but gitleaks finds it in history:

bash
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo
--redact hides the value in the output: a scanner report must not become a leak itself
terminal
$ docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo
Finding:     REDACTED'
Secret:      REDACTED
RuleID:      stripe-access-token
Entropy:     4.521641
File:        src/payments.ts
Line:        1
Commit:      d7e5da9132cc2f72e810470d91a16146c0f49549
Author:      Dev
Email:       dev@example.com
Date:        2026-10-12T01:52:59Z
Fingerprint: d7e5da9132cc2f72e810470d91a16146c0f49549:src/payments.ts:stripe-access-token:1

1:53AM INF 2 commits scanned.
1:53AM INF scanned ~218 bytes (218 bytes) in 172ms
1:53AM WRN leaks found: 1
$ echo $?
1
Real gitleaks 8.30.1 output: the key is found in the first commit although the file has been fixed; exit code 1

In the same directory, the dir mode found the key in .env — the file is not in Git but is on disk. So use git for a repository and dir for whatever is shipped as a whole: a build directory or an image's contents. To catch a key before it is committed, gitleaks is wired in as a pre-commit hook (the README has a ready configuration), and in CI as a separate job on every pull request:

.github/workflows/secrets.yml
name: Secret scan

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  gitleaks:
    name: gitleaks
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0 # the whole history, not just the last commit
          persist-credentials: false
      - name: Scan git history
        run: >-
          docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1
          git --redact --no-banner /repo
fetch-depth: 0 — without it checkout downloads only the last commit, and the history goes unchecked

A deliberate test key is marked with a gitleaks:allow comment on the same line, or the finding's fingerprint is added to .gitleaksignore. OWASP recommends agreeing on standard test secrets within the team, so false positives do not have to be triaged on every project.

gitleaks

What it does
finds secrets in history, files and stdin; pre-commit hook and CI job
Where it is available
everywhere: open source, runs locally and in any CI

GitHub secret scanning

What it does
scans the whole history of all branches, plus issues, pull requests and wikis; reports partner keys to the provider
Where it is available
public repositories — free; private organization repositories — with GitHub Secret Protection on Team and Enterprise Cloud

GitHub push protection

What it does
blocks a push containing a detected secret before it reaches the repository
Where it is available
for users it is on by default and protects public repositories; per repository it requires Secret Protection

A key has leaked: the action plan

A leak is any appearance of a secret where outsiders can read it: a commit (even to a private repository, even one removed by the next commit), a CI log, a screenshot, a chat message, a client bundle. OWASP and GitHub agree on the main point: first the key is revoked and replaced; cleaning the history comes second.

  1. Revoke and reissue the key

    Create a new key in the provider's dashboard and revoke the old one. While the old key is valid, rewriting history protects nothing: a copy may already sit in someone's clone, fork or cache.

  2. Replace the value everywhere

    Hosting, CI and environment secrets, the team password manager, developers' .env files. Restart the apps so they pick up the new value.

  3. Check whether the key was used

    Provider and in-house logs: requests from unknown addresses, payments, mailings, data access since the moment of the leak. OWASP advises recording who had access to the secret and when it was last rotated.

  4. Find every copy

    Run gitleaks git over every repository where the key could have ended up, check CI logs and chats. Delete GitHub Actions run logs that expose the secret.

  5. Decide whether to rewrite history

    According to GitHub's documentation, once the secret is revoked, rewriting is often unnecessary. If you still need it (personal data, a key that cannot be revoked), use git filter-repo --sensitive-data-removal.

  6. Analyse the cause

    How did the key get into the code and why was it not caught: add a scanner to pre-commit and CI, enable push protection, update .env.example and the team rules.

bash
git clone https://github.com/YOUR-USERNAME/YOUR-REPOSITORY
cd YOUR-REPOSITORY
echo 'sk_test_FAKE7d2c9e41b8a05f36' > ../leaked.txt
git filter-repo --sensitive-data-removal --replace-text ../leaked.txt
git push --force --mirror origin
Rewriting history as GitHub's documentation describes: a fresh clone, replacing the string in every commit, a forced push of all branches and tags
terminal
$ git filter-repo --sensitive-data-removal --replace-text ../leaked.txt
NOTICE: Fetching all refs from origin to make sure we rewrite
        all history that may reference the sensitive data, via
      git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
…
You rewrote 2 (of 2) commits.

NOTE: First Changed Commit(s) is/are:
  d7e5da9132cc2f72e810470d91a16146c0f49549
…
$ git log --oneline
d931c47 Move payments key to env
dd4cd57 Add payments client
$ git show HEAD~1:src/payments.ts | head -1
const PAYMENTS_SECRET_KEY = '***REMOVED***';
$ docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo
1:53AM INF 2 commits scanned.
1:53AM INF no leaks found
Real git-filter-repo 2.47.0 output on a test repository (shortened at …): the string is replaced in every commit, commit hashes changed, gitleaks finds nothing anymore
  • The hashes of all commits after the leak change: links to commits break, commit signatures are dropped, closed pull requests lose their diffs.
  • Branch protection against force pushes has to be disabled temporarily. Colleagues must re-clone or move their branches over with rebase — a single git pull and git push from an old clone brings the secret back.
  • Copies in forks, GitHub cached views and pull requests are removed only through GitHub Support, and in other people's clones — not at all. One more reason why the first step is revoking the key.
  • The repository has an .env.example with the full list of variables and explanations; .env and .env.* are in .gitignore.
  • Secrets are shared through the team password manager, not in chats or email.
  • Each environment has its own least-privilege keys; test keys never go to production.
  • The app checks required variables at startup and never prints secret values.
  • Only NEXT_PUBLIC_ / VITE_ variables reach client code, and none of them is a secret.
  • Secrets are not passed to Docker through ARG or ENV.
  • gitleaks runs in pre-commit and in CI with fetch-depth: 0; secret scanning is on for public repositories.
  • A leaked key is revoked and reissued first; the Git history question comes after.
How this was verified5 · passed2 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.