Plaintext secrets on your server or in your repo? A 3-step self-check

Author: OpsMate team
Updated
Run commands only on servers and repositories you are authorized to access; replace paths and service names for your environment.

Short answer: On the server, list only the locations and variable names of secrets, never their values: .env and config files, shell history, environment variables in docker inspect, compose files, crontab and systemd units. Then scan the Git repository and its full history with gitleaks or trufflehog. For every secret you find, rotate it first and remove it second. Once a secret has been exposed, deleting the commit or rewriting history does not make it stop working; only rotation does.

A database password in .env, a deploy token left in shell history, a cloud access key that was committed and later deleted: these are the plaintext secrets you most often find on a small team's server. This guide is a 3-step self-check: find them, check Git history, and contain them in the right order. The last section covers redacting logs and terminal output before you send them to any AI tool.

How to use this guide:

1. On the server: where plaintext secrets hide

Read-only: .env files and their variable names:

sudo find /root /home /srv /opt /var/www /etc -xdev \( -name '.env' -o -name '.env.*' -o -name '*.env' \) -type f -not -path '*/node_modules/*' 2>/dev/null
sudo grep -oE '^(export +)?[A-Za-z_][A-Za-z0-9_]*=' /path/to/app/.env

Read-only: passwords and tokens hard-coded in config files (file names only):

sudo rg -l -i --hidden --no-ignore -g '!node_modules' -g '!*.log' '(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]' /etc /srv /opt /var/www /home /root 2>/dev/null
sudo grep -rIlEi --exclude-dir=node_modules '(password|passwd|secret|token|api[_-]?key|access[_-]?key)[[:space:]]*[:=]' /etc /srv /opt /var/www /home /root 2>/dev/null

Both do the same job. Use the first if you have rg (ripgrep), which is faster, and the second otherwise. -l prints only the names of matching files, not their contents. Plenty of matches are normal, such as config templates and comments; focus on your own application directories. To see which variable names matched in one file, without the values:

sudo grep -noiE '[A-Za-z0-9_]*(password|passwd|secret|token|api_?key)[A-Za-z0-9_]*' /path/to/app/config.yml

Read-only: private keys and tool credential files:

sudo grep -rlE -- '-----BEGIN ([A-Z]+ )?PRIVATE KEY-----' /root /home /srv /opt /var/www /etc 2>/dev/null
sudo ls -la /root/.git-credentials /root/.netrc /root/.aws/credentials /root/.docker/config.json /home/*/.git-credentials /home/*/.netrc /home/*/.aws/credentials /home/*/.docker/config.json 2>/dev/null

Read-only: passwords typed into shell history (file and line number only):

sudo grep -nEi '(password|passwd|secret|token|api[_-]?key)=|mysql .*-p[^[:space:]]|curl .*(-u |authorization:)|export [A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)' /root/.bash_history /home/*/.bash_history /root/.zsh_history /home/*/.zsh_history 2>/dev/null | cut -d: -f1,2

cut -d: -f1,2 keeps only "file:line" and drops the command itself. Typical culprits: mysql -pXXX, curl -u user:XXX, export API_TOKEN=XXX.

Read-only: Docker container environment variables (names only):

for c in $(docker ps -aq); do
  echo "== $(docker inspect --format '{{.Name}}' "$c")"
  docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$c" | cut -d= -f1 | grep -Ei 'pass|secret|token|key|dsn|url'
done

Anyone who can run docker inspect sees these values in plain text, and access to the docker command is close to root access anyway. Likewise, a secret baked into an image with ENV or ARG stays in the image layers and shows up in docker history --no-trunc; don't share that output.

Read-only: values written directly into compose files:

sudo find /root /home /srv /opt -xdev \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) -not -path '*/node_modules/*' 2>/dev/null
sudo grep -Ei '(password|secret|token|api_?key)[A-Za-z0-9_]*[[:space:]]*[:=]' /path/to/app/docker-compose.yml | grep -vc '\${'

The second command counts lines that have a password-like name but don't use a ${VARIABLE} reference. 0 means every value is pulled from .env or elsewhere. Anything above 0 means a value sits in the compose file itself, and compose files usually end up in Git.

Read-only: crontab and systemd units (file and line number only):

sudo grep -nEi '(password|passwd|secret|token|api_?key|authorization)' /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/* /var/spool/cron/* 2>/dev/null | cut -d: -f1,2
sudo grep -rnEi '^(Environment=.*(pass|secret|token|key)|EnvironmentFile=)' /etc/systemd/system /lib/systemd/system 2>/dev/null | cut -d: -f1,2

Read-only: who can read these files:

sudo find /root /home /srv /opt /var/www -xdev \( -name '.env' -o -name '.env.*' -o -name '*.pem' -o -name '*.key' \) -type f \( -perm -o=r -o -perm -g=r \) -exec ls -l {} + 2>/dev/null

This lists secret-type files readable by the group or by everyone. A .env shown as -rw-r--r-- can be read by every account on the server and by any compromised process. Also make sure no .env sits inside the web root (for example /var/www/html/.env), where a browser might be able to download it.

What this does not tell you: whether anyone has already seen or copied a secret. It only shows where secrets are now and who can read them.

2. In the repository: current files and full history

Do this in the repository checkout on the server and in the copy on your own computer.

Read-only: which sensitive files Git is tracking:

cd /path/to/app
git ls-files | grep -Ei '(^|/)\.env($|\.)|\.pem$|\.key$|id_rsa|id_ed25519|credentials|\.p12$'
git check-ignore -v .env || echo ".env is not ignored by .gitignore"

Read-only: scan current files and the full history with a scanner (recommended):

gitleaks git --redact -v .
gitleaks dir --redact -v .
trufflehog git file://. --no-verification

Read-only: a rough check with git log if you can't install a tool:

git log --all -i -G '(password|secret|token|api_?key)[[:space:]]*[:=]' --format='%h %ad %an %s' --date=short
git log --all --format='%h %ad %s' --date=short -- .env '*.pem' '*.key'
git show --stat COMMIT_ID

If the repository lives on GitHub or a similar platform, also check the platform's secret scanning alerts, and whether your CI variables or CI logs have ever printed a secret.

3. Contain: rotate first, remove second

Handle every secret from steps 1 and 2 in this order. Commands in this step are CHANGE unless labeled otherwise.

First: rotate the secret. Create a new password or token at the provider or in the database, put it wherever the app uses it, restart the app, check that it works, and only then disable the old one. Until the old one is disabled, you can always switch back.

Second: tighten permissions. Record the current permissions first so you can undo:

stat -c '%a %U:%G %n' /path/to/app/.env
sudo chown APP_USER:APP_USER /path/to/app/.env
sudo chmod 600 /path/to/app/.env

Third: move secrets out of places that get committed or seen.

Last (optional): clean up Git history. After every secret has been rotated, you can use git filter-repo to remove sensitive files from history. This rewrites every commit hash, needs a force push, and every collaborator has to re-clone; back up the whole repository first. It is cleanup, not a fix: rotation has to happen either way. GitHub's guide in the references walks through it.

Redact logs and terminal output before sending them to AI

Whatever AI tool you use, logs and command output you send leave your server. The usual ways secrets slip into troubleshooting output: database connection strings in error logs, request headers printed in debug mode, the output of env, printenv or docker inspect, and scripts logged with set -x.

What to remove:

Read-only (writes a new file and leaves the original untouched): a sed example that swaps those patterns for placeholders:

sed -E \
  -e '/-----BEGIN [A-Z ]*PRIVATE KEY-----/,/-----END [A-Z ]*PRIVATE KEY-----/c [REDACTED_PRIVATE_KEY]' \
  -e 's#(://[^:/@[:space:]]+:)[^@[:space:]]+@#\1[REDACTED]@#g' \
  -e 's/(Authorization:[[:space:]]*(Bearer|Basic|Token)[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \
  -e 's/((password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*"?[[:space:]]*[:=][[:space:]]*"?)[^",}[:space:]]+/\1[REDACTED]/Ig' \
  -e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \
  -e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[EMAIL]/g' \
  -e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/[IP]/g' \
  app-error.log > app-error.redacted.log

For example, DB_PASSWORD=CHANGE_ME_EXAMPLE becomes DB_PASSWORD=[REDACTED], and postgres://app:EXAMPLE_PASSWORD@db.example.com/app becomes postgres://app:[REDACTED]@db.example.com/app. This is GNU sed syntax, which ships with common Linux distributions. You can also pipe into it instead of reading a file, for example docker logs --since 30m YOUR_CONTAINER 2>&1 | sed -E ….

Then check the result. It is clean only if this prints nothing:

grep -nEi 'password|secret|token|api_?key|BEGIN .*PRIVATE KEY|eyJ' app-error.redacted.log | grep -v REDACTED

Regexes are a safety net, not a guarantee: they won't recognize a custom key format or customer data stored somewhere unexpected. The most reliable approach is to use names-only commands like the ones in step 1, so values never reach the screen in the first place.

If you use OpsMate: each time you ask the AI for help, recent terminal output is sent to the cloud AI along with your message, so remove sensitive information first.

Note: clear does not remove what gets sent to the AI; to clear it, disconnect and reconnect the terminal. Keys in common formats are replaced automatically before sending, but don't rely on that alone. The best approach is to never print secrets in the terminal in the first place.

Doing this in OpsMate

OpsMate (itops.sh) puts an SSH terminal and an AI assistant on the same server page, and you can type every command in this guide straight into the terminal. Commands that fail the safety check are blocked and never run. Low-risk fixes run automatically by default, and every step is logged. Each time you ask the AI for help, recent terminal output is sent to the cloud AI along with your message, so remove sensitive information first. For what OpsMate does and doesn't do on its own, see the FAQ.

Illustrative example

Illustrative example (not a real customer case): a three-person team runs a Node app on a VPS. Step 1 finds /srv/app/.env with mode 644, two mysql -p… lines in shell history, and one literal POSTGRES_PASSWORD in the compose file. In step 2, gitleaks git flags a commit from a year ago that included .env, deleted since. The team first sets a new password for the app's database account, updates .env and the compose reference, restarts, and places a test order. Next they create a new API key at their email provider, swap it in, and disable the old key. Then they set .env to 600 owned by the app account and delete the two history lines. The secrets in the old committed .env no longer work, so they decide not to rewrite Git history for now. What this does not prove: that nobody used the old secrets before they were rotated. To check for misuse, look at the access records on the database and provider side.

AI diagnostic prompt

I'm checking this server and the repository at /path/to/app for plaintext secrets. Check first without changing anything: list places in .env and config files, shell history, Docker container environment variables, compose files, crontab and systemd units that may contain passwords, tokens or API keys, along with those files' permissions and owners. Then check whether Git tracks files such as .env or private keys, and whether they ever appeared in history. Output only file paths, line numbers and variable names, never any values. Don't modify, move or delete files, don't change permissions, and don't run anything that rewrites Git history. If something needs fixing, list the suggested steps, their impact and how to undo them, and I'll run them myself.

FAQ

I accidentally pushed a secret to GitHub. Is deleting the commit enough?

No. If the repository was ever public, was cloned, or was viewed after the push, treat the secret as exposed: rotate it with the provider first, then think about cleaning history. Deleting the commit or rewriting history only stops people from finding it in the repository later; it does not invalidate a copy someone already has.

Is it safe to keep secrets in a .env file on the server?

It's a common, reasonable choice for small teams, provided the file is mode 600, owned by the account that runs the app, outside the web root, and not in Git. As the team and the number of services grow, consider a cloud provider's secret manager. Steps 1 and 3 show how to check and fix the permissions.

What should I remove from logs before asking an AI to help troubleshoot?

At minimum: passwords, tokens, API keys, passwords inside connection strings, Authorization headers, private keys, and customer emails, phone numbers and internal addresses. Run the sed command in this guide first, then read through the result yourself.

Try OpsMate

500 free AI calls per month and unlimited servers. The desktop app keeps your SSH credentials on your own machine by default.

References

Related guides

Need help interpreting the evidence?

OpsMate (itops.sh) helps developers and operators investigate with AI, and you review the evidence yourself. Each time you ask the AI for help, recent terminal output is sent to the cloud AI along with your message, so remove sensitive information first.

Start free Desktop with local credentials

Troubleshooting guides