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
.envin.gitignorebefore the first commit. Commit.env.examplewith fake values. If.envalready landed in git,git rm --cachedonly 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 examplesecrets.env) and point Compose at it withenv_file. Do not put${POSTGRES_PASSWORD}in the YAML if that value comes from a project.envyou might commit. - Compose interpolates
${...}in the Compose file itself, including insidecommand: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 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 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 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 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.examplein git: keys and fake values, so a teammate knows what to create. -
secrets.envon the host only: real values, mode600, listed in.gitignore.
$ chmod 600 secrets.env $ stat -c '%a %n' secrets.env .env.worldreadable 600 secrets.env 644 .env.worldreadable 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 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} $ 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 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"] // 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"); 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 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." 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
-
.gitignoreafter the first commit. Tracked paths ignore the ignore file. Rungit ls-files .envandgit check-ignore -v .env. -
git rm --cachedwithout rotating. Future commits are clean. Clones of the old history still have the password. -
.env.examplemissing or filled with real values. People copy the wrong file. Keep fake placeholders only. - Mode
644on the VPS secrets file. Any local account can read it. Use600. -
${POSTGRES_PASSWORD}in Compose YAML.docker compose configprints the value. Preferenv_fileand build URLs inside the app from separate env vars. - JS
${...}inside Composecommand:. Compose eats it. Use a file or$$. - Assuming a private GitHub repo is enough. Private today can be public tomorrow, or the laptop backup can leak. History still carries the blob.
- 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?
