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?
Top comments (1)
One edge case is
docker inspect: values supplied throughenv_filestill appear in container metadata to anyone with Docker access. Does the deployment also restrict access to the Docker socket?