DEV Community

Cover image for Your .env hit git. Here's how I keep secrets on a VPS
Amorizz
Amorizz

Posted on

Your .env hit git. Here's how I keep secrets on a VPS

I pushed a small Node + Postgres Compose stack to a private repo and only then noticed .env was in the first commit. DATABASE_URL had a real password. Adding .gitignore afterward did nothing useful: Git still had the blob, and git check-ignore stayed quiet while the path was tracked.

This is the layout I use on a VPS now. Everything below was run on the box on 2026-10-15 with git 2.47.3, Docker Engine 26.1.5, Compose 2.26.1 and Node 20. Numbers and command output are from those runs.

TL;DR

  • Put .env in .gitignore before the first commit. Commit .env.example with fake values. If .env already landed in git, git rm --cached only stops future commits. The old blob stays until you rewrite history or rotate the secret.
  • On the VPS, keep real secrets in a file mode 600 (for example secrets.env) and point Compose at it with env_file. Do not put ${POSTGRES_PASSWORD} in the YAML if that value comes from a project .env you might commit.
  • Compose interpolates ${...} in the Compose file itself, including inside command: strings. A JS template literal like `token=${t}` becomes empty before the container starts. Prefer a script file, or write $$t.

What are you actually protecting?

Three different places can hold the same password. Fixing one does not fix the others.

Place What goes wrong What fixes it
Git history Clone, fork, or a future public flip exposes the blob forever Never commit it; if you did, rotate and treat history as burned
File on the VPS Any local account can read a 644 secrets file chmod 600, owner = the deploy user
Process environment docker compose config, crash dumps, child processes Prefer env_file; avoid baking secrets into resolved YAML via ${}

.gitignore only affects the first column, and only for paths that are not already tracked.

How does .env end up in git?

I reproduced the failure in a throwaway repo. First commit included .env. Then I added:

.env
.env.*
!.env.example
Enter fullscreen mode Exit fullscreen mode

After that commit, Git still listed .env:

$ git check-ignore -v .env
# (no output: still tracked, so ignore rules do not apply)

$ git ls-files | grep env
.env
.env.example

$ git show "$(git rev-list --max-parents=0 HEAD):.env" | head -3
DATABASE_URL=postgres://app:[REDACTED]@db:5432/app
EPURE_DSN=https://[REDACTED]@ingest.example.com/1
NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode

git rm --cached .env plus a new commit makes check-ignore work again:

$ git rm --cached .env
$ git commit -m "stop tracking .env (too late for history)"
$ git check-ignore -v .env
.gitignore:1:.env   .env
Enter fullscreen mode Exit fullscreen mode

The first commit still has the file. git rev-list --objects --all still showed the .env blob. Ignore files are not a time machine.

The correct order is boring and it works. Create .gitignore and .env.example first, then add:

$ git status --short
A  .env.example
A  .gitignore
A  app.js
# .env is absent from the index

$ git check-ignore -v .env
.gitignore:1:.env   .env

$ git ls-files
.env.example
.gitignore
app.js
Enter fullscreen mode Exit fullscreen mode

No secret string in git grep across $(git rev-list --all).

What goes on the VPS?

I keep two files next to the Compose project:

  • .env.example in git: keys and fake values, so a teammate knows what to create.
  • secrets.env on the host only: real values, mode 600, listed in .gitignore.
$ chmod 600 secrets.env
$ stat -c '%a %n' secrets.env .env.worldreadable
600 secrets.env
644 .env.worldreadable
Enter fullscreen mode Exit fullscreen mode

On the lab box, Python reported other_read=False for 600 and other_read=True for 644. Same owner can read both. Every other account on the VPS can read the 644 copy.

A minimal systemd unit can load the same file:

[Service]
WorkingDirectory=/opt/app
EnvironmentFile=/etc/app/secrets.env
ExecStart=/usr/bin/node /opt/app/server.js
Enter fullscreen mode Exit fullscreen mode

Same rule: /etc/app/secrets.env is 600, not in git.

Compose: env_file vs ${PASSWORD} in the YAML

Compose loads a project .env for interpolation in the Compose file. That is separate from what the container sees as process.env.

Bad shape (host .env supplies ${POSTGRES_PASSWORD}, and the resolved config prints it):

# compose.bad.yml
services:
  db:
    image: postgres:16.15-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: app
  app:
    image: node:22.23.3-alpine
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      APP_SECRET: ${APP_SECRET}
Enter fullscreen mode Exit fullscreen mode
$ docker compose -f compose.bad.yml config | grep -E 'PASSWORD|DATABASE_URL|APP_SECRET'
      APP_SECRET: from-host-dotenv
      DATABASE_URL: postgres://app:HostInterpolatedSecret@db:5432/app
      POSTGRES_PASSWORD: HostInterpolatedSecret
Enter fullscreen mode Exit fullscreen mode

That was 2 plaintext hits for HostInterpolatedSecret in the full config dump. Fine for local debugging. Bad to paste into an issue, a CI log, or a screenshot.

Better shape: put secrets in secrets.env, mount them with env_file, and keep the YAML free of ${SECRET}:

# compose.yml
services:
  db:
    image: postgres:16.15-alpine
    env_file:
      - secrets.env
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      # POSTGRES_PASSWORD comes from secrets.env

  app:
    image: node:22.23.3-alpine
    working_dir: /app
    volumes:
      - ./print-env.js:/app/print-env.js:ro
    env_file:
      - secrets.env
    environment:
      POSTGRES_USER: app
      POSTGRES_HOST: db
      POSTGRES_DB: app
    command: ["node", "print-env.js"]
Enter fullscreen mode Exit fullscreen mode
// print-env.js
const u = process.env.POSTGRES_USER;
const p = process.env.POSTGRES_PASSWORD;
const h = process.env.POSTGRES_HOST;
const d = process.env.POSTGRES_DB;
console.log("password_in_process_env", Boolean(p));
console.log("password_len", (p || "").length);
console.log("password_equals_envfile", p === "EnvFileOnlySecret");
Enter fullscreen mode Exit fullscreen mode

With the project .env renamed away so only secrets.env could supply the password:

$ docker compose -f compose.better.yml run --rm --no-deps app
password_in_process_env true
password_len 17
NODE_ENV production
password_equals_envfile true
password_equals_host_dotenv false
Enter fullscreen mode Exit fullscreen mode

password_len 17 matches EnvFileOnlySecret. The host-interpolated value never appeared.

The ${} gotcha inside command: strings

I put a JS template literal in the Compose command once. Compose warned and emptied it:

level=warning msg="The \"t\" variable is not set. Defaulting to a blank string."
Enter fullscreen mode Exit fullscreen mode

Resolved command became console.log('template', \token=); with no t. The container never saw ${t}. Fix: move the script to a file (as above), or escape as $$t in the YAML so Compose emits a literal $t for the shell or Node source.

Failure checklist

  1. .gitignore after the first commit. Tracked paths ignore the ignore file. Run git ls-files .env and git check-ignore -v .env.
  2. git rm --cached without rotating. Future commits are clean. Clones of the old history still have the password.
  3. .env.example missing or filled with real values. People copy the wrong file. Keep fake placeholders only.
  4. Mode 644 on the VPS secrets file. Any local account can read it. Use 600.
  5. ${POSTGRES_PASSWORD} in Compose YAML. docker compose config prints the value. Prefer env_file and build URLs inside the app from separate env vars.
  6. JS ${...} inside Compose command:. Compose eats it. Use a file or $$.
  7. Assuming a private GitHub repo is enough. Private today can be public tomorrow, or the laptop backup can leak. History still carries the blob.
  8. Committing secrets.env "just for the team". That is the same bug with a different filename. Use a password manager, sealed secrets, or a one-time scp onto the VPS.

Where this shows up for me

The stack I'm building, Epure, is a two-container Compose file (Rust binary + Postgres 16) for exception-only error monitoring. Same rule as above: example env in git, real DSN and DB password only on the host. The product is not APM, replay, or logs. If you self-host it, the docs assume you already keep secrets out of the repo.

After you stop tracking .env, do you rotate the leaked secret, or only fix the ignore file?

Top comments (1)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

One edge case is docker inspect: values supplied through env_file still appear in container metadata to anyone with Docker access. Does the deployment also restrict access to the Docker socket?