App Deployment·7 min read·

Keep Node.js Secrets Out of Git with env Files

Load production secrets from a root-owned env file, keep them out of git, and wire EnvironmentFile so systemd injects those values at start time.

NB

Netbay Cloud Team

Netbay Engineering

On this page

A production Node process needs a database URL, a session secret, and maybe an API token. None of those belong in git, in a unit file that every sudoer can cat, or in a .env that the deploy user can publish by accident. systemd already has EnvironmentFile. Use it. This post is the layout that survives a leaked repository and a curious colleague: secrets live in one root-owned file on the host, the repo tracks an example with empty values, and the app reads process.env as if the shell had exported everything.

dotenv is a development convenience. On a VPS supervised by systemd it is optional and often harmful, because a .env sitting next to server.js is one chmod away from the git remote.

What goes in git, and what never does

Commit .env.example with names and no values. Add .env and .env.local to .gitignore. Add app.env and anything under /etc/nodeapp to .gitignore as well, even if those paths should never be inside the repo, because someone will copy them there during a late-night debug. Review git log -p for KEY= and SECRET= before the first public push. Rotation is cheaper than pretending history is private.

bash
printf '%s\n' 'NODE_ENV=production' 'PORT=3000' 'DATABASE_URL=' 'SESSION_SECRET=' > /srv/app/.env.example
printf '%s\n' '.env' '.env.*' '!.env.example' >> /srv/app/.gitignore
sudo mkdir -p /etc/nodeapp
sudo install -o root -g nodeapp -m 0640 /dev/null /etc/nodeapp/app.env
sudo tee /etc/nodeapp/app.env >/dev/null << 'EOF'
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://nodeapp:change-me@127.0.0.1:5432/app
SESSION_SECRET=replace-with-a-long-random-string
EOF
sudo chmod 0640 /etc/nodeapp/app.env
sudo chown root:nodeapp /etc/nodeapp/app.env

The here-document delimiter is quoted so nothing expands while you paste. The file is 0640, owner root, group nodeapp. The process can read it because systemd injects the values into the environment; the nodeapp user does not need to open the file at runtime if you use EnvironmentFile. That is stricter than dropping a .env in /srv/app that the app user can rewrite.

Do not put quotes around values unless the value itself contains spaces, and never wrap a secret in backticks. systemd EnvironmentFile is KEY=VALUE, one per line, no export keyword, no shell substitution. A dollar sign in a password is literal. That is a feature.

Point the unit at the file, not at the secret

Environment= lines in the unit are world-readable through systemctl cat and through the systemd D-Bus API to users in the right groups. EnvironmentFile= keeps the values out of the unit text. Use the leading dash form only if the file is optional, which production secrets are not. Fail closed.

ini
[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/app
EnvironmentFile=/etc/nodeapp/app.env
ExecStart=/usr/bin/node /srv/app/server.js
Restart=on-failure

After you change app.env, restart the unit. systemd snapshots the file at start; editing it under a running process does nothing until the next ExecStart. That is the same rule as changing a dotenv file and forgetting to bounce the process, except now it is documented.

Read the values in process without a template literal:

javascript
const url = process.env.DATABASE_URL;
const secret = process.env.SESSION_SECRET;
if (!url || !secret) {
  console.error('missing DATABASE_URL or SESSION_SECRET');
  process.exit(1);
}

Failing at boot is the correct behavior. A process that starts with an empty secret and then mints unsigned cookies is worse than a failed unit. journalctl will show the error on the same stream as every other startup line.

Rotation, copies, and the things people forget

Backups of /etc are how secrets leave the box in a tar file. Encrypt those backups or exclude /etc/nodeapp. Staging and production get different files on different hosts, not a NODE_ENV switch that still contains the production URL. CI injects secrets as masked variables at build time only if the build actually needs them, which most front-end builds do not. A Next.js or Vite build that inlines NEXT_PUBLIC_ values is a different contract: those are not secrets, they are public, and they will appear in the JavaScript bundle. Do not put a private API key in a name that starts with PUBLIC.

npm scripts that dump the environment (npm run env, debug printers, leftover console.log of process.env) are a leak. Grep the repo for SESSION_SECRET and DATABASE_URL and make sure the only prints are length checks or redacted logs.

If you must let the app user read the file, keep 0640 and never 0644. World-readable secrets on a multi-user VPS are public. Even on a single-app host, a web-accessible static file server has a long history of serving /.env because someone pointed nginx at the project root.

Secrets live on the host, not in the repository git repo .env.example only /etc/nodeapp/app.env root:nodeapp 0640 process.env injected at start EnvironmentFile= in the unit systemctl cat must not print the session secret Lucknow VPS, restart the unit after every rotation

The takeaway: git holds names, the host holds values, systemd copies those values into the process at start, and the app refuses to boot if a required name is empty. That is all the secret machinery a single Node VPS needs.

Store the env file on a Netbay Ubuntu 24.04 instance and keep the repository boring — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance