Skip to content
IsMyENVPublic

Frameworks · 9 min read

Node.js .env security

How .env files leak in Node.js apps through static file serving, Docker images and frontend bundles, and how to configure Express, Next.js and Vite safely.

Last updated

How .env files leak in Node.js projects

Node.js applications usually aren't served from a document root the way PHP applications are, but environment files still leak through a handful of recurring patterns:

  • a static file middleware or dev server that serves the project directory,
  • secrets bundled into client-side JavaScript through public variable prefixes,
  • Docker images built with COPY . . that include the file,
  • a reverse proxy that serves the project directory for static assets.

Static file serving

The classic mistake is pointing static middleware at the project root:

server.js - don't do thisjs
import express from "express";

const app = express();
app.use(express.static(".")); // serves package.json, source code, backups, ...

Express's static middleware ignores dotfiles by default, so /.env itself usually returns 404. That is not a safety net you should rely on: everything else in the directory is served, including source code, configuration files and backups without a leading dot (for example env.backup). In Express 4, files inside dot-directories can be served as well. Serve a dedicated folder and deny dotfiles explicitly:

server.jsjs
import express from "express";
import path from "node:path";

const app = express();

app.use(
  express.static(path.join(import.meta.dirname, "public"), {
    dotfiles: "deny", // 403 for any path segment starting with a dot
    index: false,
  }),
);

The same applies to quick static servers: running npx serve . or python3 -m http.server in a project directory publishes its .env. If Nginx serves your static assets, add the dotfile rule from the Nginx guide.

Public variables in frontend bundles

Frontend build tools replace references to certain environment variables with their literal values at build time. Those values end up in JavaScript files that every visitor downloads:

ToolVariables embedded in client code
Next.jsNEXT_PUBLIC_*
ViteVITE_* (configurable with envPrefix)
Create React AppREACT_APP_*
ExpoEXPO_PUBLIC_*

A public prefix makes a value public

Never give an API secret, database URL or signing key one of these prefixes. Only put values there that you would be comfortable publishing on your homepage, such as a public analytics ID or a publishable payment key.

Custom webpack configurations are a related trap. Injecting the whole environment, for example with new DefinePlugin({ "process.env": JSON.stringify(process.env) }), copies every variable of the build machine into the bundle. Define individual keys instead.

In Next.js, variables without the prefix are only available on the server. Importing the server-only package in modules that read secrets makes the build fail if such a module is accidentally imported into a client component.

Docker images

COPY . . copies the .env file into the image unless it is excluded. Deleting it in a later step doesn't help, because every layer can be extracted from the image. Anyone who can pull the image can read the file.

.dockerignoretext
.env
.env.*
!.env.example
.git
node_modules

Pass configuration at runtime instead: docker run --env-file ./production.env, the env_file: key in Docker Compose, or orchestrator secrets.

Loading variables safely

Node.js can load an env file without dependencies:

Shellbash
node --env-file=.env server.js              # Node.js 20.6+
node --env-file-if-exists=.env server.js    # Node.js 22.9+: no error if the file is missing

With the dotenv package, load it as early as possible, for example with import "dotenv/config" at the top of the entry file. In both cases, validate required variables at startup so that a missing secret fails loudly instead of falling back to an unsafe default:

env.jsjs
import { z } from "zod";

const schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  SESSION_SECRET: z.string().min(32),
});

// Throws at startup with the names of missing or invalid variables.
export const env = schema.parse(process.env);

Never log the entire process.env object. It ends up in log files, error trackers and support tickets.

Production without a .env file

In production, the environment file is often unnecessary. Hosting platforms, container orchestrators and systemd can inject variables directly:

/etc/systemd/system/example-app.serviceini
[Service]
User=example-app
WorkingDirectory=/srv/example-app
# Readable only by root; systemd reads it before dropping privileges.
EnvironmentFile=/etc/example-app/production.env
ExecStart=/usr/bin/node server.js

Keep such files outside any directory that a web server or static middleware can reach, and restrict them with chmod 600. See the general protection checklist for the remaining layers.

Remediation checklist

  • Static middleware serves a dedicated public folder with dotfiles: "deny"
  • No secret uses a NEXT_PUBLIC_, VITE_, REACT_APP_ or EXPO_PUBLIC_ prefix
  • The build doesn't inject the whole process.env into client code
  • .dockerignore and .gitignore exclude .env files
  • Required variables are validated at startup, and the environment is never logged
  • Production variables come from the platform or a root-only file outside the application directory
  • The IsMyENVPublic check reports no exposure

Fundamentals

What is a .env file?

The dotenv format, where it comes from, what usually lives inside, and why it must stay private.

7 min read