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:
- Every command is labeled. Read-only commands only look. CHANGE commands modify files, permissions or the repository; each comes with an Undo line. Run changes one at a time. Steps 1 and 2 are entirely read-only.
- The checks are built to show file paths, line numbers and variable names, never values. Don't
cata whole.envfile "just to check", especially on a shared screen, a recording, or a terminal with an AI conversation open. - Replace
/path/to/appwith your application's directory andYOUR_CONTAINERwith the container name. - Values such as
EXAMPLE_PASSWORDandCHANGE_ME_EXAMPLEare placeholders, not real secrets.
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
- The first command lists
.env-style files. Files ending in.exampleor.sampleare usually templates, but check that they only hold placeholders. - The second prints only the variable names in one file, such as
DB_PASSWORD=, without the values.
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
- The first lists files that contain a private key. TLS keys under
/etc/ssl/privateand host keys at/etc/ssh/ssh_host_*_keyare expected; just check their permissions. A private key inside a web root or application directory needs an explanation. - The second shows whether common credential files exist and who can read them.
.git-credentialsstores Git passwords or tokens in plain text,.netrcis plain text too, and.docker/config.jsonmay hold registry logins.
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
- Scheduled jobs often contain
curl -H 'Authorization: ...'or a database backup command with a password. - Any user on the system can read the values of
Environment=lines in a unit withsystemctl show.EnvironmentFile=itself is fine; what matters is the permissions on the file it points to.
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"
- The first lists sensitive files that are committed. Templates such as
.env.examplecan stay, as long as they hold placeholders. - The second checks whether
.gitignorecovers.env. Note that.gitignoreonly applies to files that are not yet tracked. A file that was already committed stays in the repository and its history even after you add it to.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
gitleaks gitscans the whole commit history;gitleaks dirscans the files in the directory, including uncommitted ones.--redactkeeps the secrets themselves out of the output and shows the file, commit and rule instead.- By default trufflehog tests each secret it finds against the provider's API to see whether it still works, which sends requests over the network. Add
--no-verificationto keep the scan local; drop it when you want to know which secrets are still live and need rotating now. - Install both tools first; links are in the references. Both miss things and both raise false positives, so treat results as leads, not verdicts.
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
- The first lists commits whose changes added or removed a password-like assignment. It shows only the hash, date, author and message, not the content.
- The second lists commits that touched
.env,.pemor.keyfiles, including files deleted later. git show --statshows which files a commit changed.git log -p --all -- FILEshows the actual content, but that output contains the plaintext values. Read it in your own terminal only; don't screenshot it, paste it or send it to an AI.
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.
- Once a secret has been exposed, rotation is the only fix. Exposed means it was committed (even if later deleted), shown on a shared screen, posted in a chat, or seen by a former colleague or contractor. Deleting the commit or rewriting Git history cannot invalidate a secret someone already copied, and clones, forks and CI caches may still hold it.
- Rotate one secret at a time, at a quiet hour. Rotating a session or signing key usually logs every user out; getting a payment or SMS key wrong breaks callbacks. For what else to update after each rotation, see the table in step 5 of Contractor or ex-employee left? How to revoke their server access.
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
- The first line is Read-only: it records the current mode and owner, for example
644 root:root. - The next two are CHANGE: hand the file to the account that runs the app (
APP_USER) and make it readable and writable only by that account. - Restart the app and check it can still read its config. Undo: run
sudo chmod 644 …andsudo chown root:root …with the values you recorded. - Set
.git-credentials,.netrcand private key files to600too.
Third: move secrets out of places that get committed or seen.
- CHANGE: for a committed
.env, add.envto.gitignore, rungit rm --cached .envso Git stops tracking it (the local file stays), then commit. Undo:git add -f .env. This only affects future commits; the history still has it, which is why its secrets must already be rotated. - In compose files, replace literal values with references such as
${DB_PASSWORD}from.env, or use composesecrets. - In systemd units, replace
Environment=withEnvironmentFile=pointing at a file with mode600, or useLoadCredential=. Then runsudo systemctl daemon-reloadand restart the service. Undo: put the backed-up unit file back and rundaemon-reloadagain. - As the team grows, a cloud provider's secret manager or a tool like Vault lets the app fetch secrets at startup instead.
- Passwords in shell history: rotate the password first, then delete those lines with an editor. A shell that is still open writes its in-memory history back to the file when it exits, so close other sessions before editing. In future, type temporary passwords with
read -s VARinstead of putting them on the command line.
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:
- Passwords inside connection strings:
postgres://app:EXAMPLE_PASSWORD@db.example.com/app - Values of variables named
PASSWORD,SECRET,TOKEN,API_KEY,ACCESS_KEYand similar Authorization: Bearer …/Basic …headers- Private key blocks (from
-----BEGIN … PRIVATE KEY-----toEND) - JWTs (long strings starting with
eyJ, split into three parts by two dots) and provider-specific token formats - Customer emails and phone numbers, internal addresses, hostnames and business table names
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
- Gitleaks — scan Git repositories and directories for secrets
- TruffleHog — find and optionally verify leaked credentials
- GitHub: Removing sensitive data from a repository — using git filter-repo and its caveats
- Docker Compose: use secrets — keep passwords out of compose files
- systemd.exec(5) — EnvironmentFile= and LoadCredential=
Related guides
- Contractor or ex-employee left? How to revoke their server access
- Was my Linux server hacked? Read-only checks for suspicious logins and processes
- Linux disk usage spike? Find what filled the disk in 3 steps
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