Guide

The Problem With Plaintext Secrets

Part 1 of 4By Amith Kumar8 min read
Part 1 of 4Secrets Management on a VPS: A Hands-On Guide
  1. 1The Problem With Plaintext Secrets
  2. 2File-Based Secrets Done Right
  3. 3Encrypt Secrets at Rest with age and sops
  4. 4Secret Rotation, Auditing and Git-History Scrubbing
Verified 22 Jul 2026 · Ubuntu 24.04 LTS

What you will have at the end

This series is for the developer with plaintext secrets scattered across a slice and a laptop, the API keys and passwords sitting in .env files, one careless git push away from leaking into a public commit. That is the exact person this guide is written for.

By the end you stop that from happening: those secrets become encrypted-at-rest files that are safe to commit, your secret files have owner-only permissions, and when a key does slip out you can rotate it and prove the old one is gone from history. The single promise running through it is that you stop leaking and can show the old ones no longer work.

This first chapter shows you that end state on a live Ubuntu 24.04 slice, then reproduces the exact problem it solves: a plaintext secret leaking four different ways on the same box.

Key takeaways
  • The series destination: a secrets file committed as ENC[AES256_GCM,...] ciphertext that opens with your key and stays shut without it.
  • A secret in plaintext leaks through four channels at once: file permissions, the process environment, logs and git history.
  • A .env at mode 644 is readable by every local account, including a compromised web server user.
  • Deleting a committed secret does not remove it, because git log -p still replays every past version.
Before you start
  • A slice you deploy real apps to, the kind that already has a .env or two on disk, since the whole point is to lock down secrets you actually hold rather than invented ones.
  • A git repository you can experiment in. Later chapters plant a throwaway secret and scrub it, so use a scratch repo, never a repository you care about the history of.
  • Nothing to install for this chapter. The leaks are shown with tools already on the box, stat, chmod, git and /proc. The encryption tools arrive in chapter three.
  • Every value in this series is a throwaway demo secret written as sk_demo_..., created for the walkthrough and deleted after, so nothing shown grants access to anything.

See the destination first

Before any theory, here is where the series lands. This is a secrets file after chapter three has encrypted it: the file you would commit to your repository. It is ciphertext, it opens for the key you hold, and it refuses everyone else with no plaintext at all.

cat secrets.enc.yaml
sops --decrypt secrets.enc.yaml
SOPS_AGE_KEY_FILE=/dev/null sops --decrypt secrets.enc.yaml
The series destination shown before any theory: a committed secrets.enc.yaml as ENC[AES256_GCM,...] ciphertext, decrypting to its values only when the age key is present, and failing with no plaintext when the key file is /dev/null

The committed file is a set of ENC[AES256_GCM,...] blobs, so a push exposes nothing usable. With your age key present, sops --decrypt returns the real values at deploy time. Point it at a missing key and the same file is inert: it fails to find a data key and prints no secret. That is the destination in one line, a file safe to store in git that only your servers can open.

Right now you have none of this, because your secrets are plaintext on disk and probably in a commit already. The rest of this chapter shows exactly how much that leaks, so the encryption later feels earned rather than ceremonial.

Why plaintext secrets leak

Plaintext secrets are the API keys, database passwords and tokens you store as readable text, most often in a .env file next to the app. The trouble is that a plaintext secret has no single home. It copies into git history, into process memory, into log files and into every backup you take.

Locking one copy does nothing for the others, which is why the fix is a habit rather than a single command: own the secret with one account, lock it to that account, and keep it out of anything that gets committed or shipped.

Look first at how one plaintext file betrays you on disk.

A world-readable file is readable by everyone

The default umask on many setups writes a new file as mode 644: the owner can write it and every other account on the box can read it. For source code that is fine. For a secret it is a quiet disaster, because any process that gets a foothold as another user reads it straight off disk.

chmod 644 .env
stat -c '%A %a %U:%G  %n' .env
sudo -u www-data cat .env
chmod 600 .env
sudo -u www-data cat .env
A world-readable .env at mode 644 that the www-data account reads in full, then the same file at mode 600 refusing the read with permission denied

At 644 the web server account www-data reads the file with no resistance. Change the mode to 600 and the same read is refused with Permission denied, because only the owner can open it. That single bit of hygiene, owner-only read and write, is the floor every other technique in this series builds on.

Checkpoint

Run stat -c '%a' .env and confirm it prints 600, then sudo -u www-data cat .env and confirm it is refused. If www-data still reads the file, the mode did not take: re-run chmod 600 .env and check the owner is your deploy account, not root.

Secrets escape the file at runtime

Locking the file is not enough, because a running process leaks the same secret two more ways. The classic pattern is passing a secret as an environment variable. Anyone who can read the process, or run as root, dumps its environment from /proc.

sudo tr '\0' '\n' < /proc/$PID/environ | grep API_KEY
grep -o 'api_key=[^ "&]*' access.log
The environment of a running process dumped from proc showing the API key in plain text, and a token leaking from a URL query string into an access log

The first command reads the environment of a running process and prints the key in the clear. The second shows the other runtime leak: a token passed in a URL query string. Reverse proxies and app frameworks log the full request line, so a secret in a URL is copied verbatim into an access log that ships to your log host and your backups. Keep secrets out of URLs, and prefer files or a credential store over raw environment variables for anything sensitive.

Git history remembers what you deleted

The leak that catches the most teams is git. You commit a .env early, realise the mistake, add it to .gitignore and delete it. The working tree looks clean, so it feels solved. It is not. Every earlier commit still contains the file, and anyone who clones the repository walks the history to find it.

git rm --cached .env
git log -p -- .env
git log -p replaying the diff that first added a committed .env, so the secret is still in history after the file was removed from tracking

After git rm --cached the file stops being tracked going forward, yet git log -p still replays the diff that first added it, secret and all. A private repository does not save you here, because access lists change, forks happen and laptops get stolen. The rule is blunt: a secret that ever touched a commit must be treated as exposed and rotated. Chapter four scrubs it from history properly, and proves the old value is gone.

Backups keep a copy you forgot

The fourth channel is the quietest. Every backup that includes your application directory also copies the plaintext .env inside it. A backup is built to be durable and kept for months, so a secret that reached one snapshot now lives in every retained copy, often on a different host or in object storage you rarely think about.

That is why rotation matters even after you lock the file. Once a secret has been backed up in plaintext, tightening the live file does not reach the copies. Keep secrets out of the paths your backup job walks, or encrypt them at rest so the backup only ever holds the ciphertext you saw in the destination shot.

Going further: what actually belongs in a secret

Not everything in a config file is a secret. A secret is any value that grants access or proves identity: API keys, database and message-queue passwords, private TLS keys, SSH private keys, OAuth client secrets, webhook signing keys and session-encryption keys. These need owner-only permissions and, ideally, encryption at rest.

Ordinary configuration is different. A port number, a feature flag, a public hostname or a log level carries no access and can live in plain config, in the repository, without worry. Splitting the two cleanly is the first design decision: keep boring config in version control, and route every access-granting value through the file, credential and encryption patterns in the chapters that follow.

Frequently asked questions

Is an environment variable safer than a .env file?

Not on its own. An environment variable is visible in /proc/<pid>/environ to root and to the process owner, and child processes inherit it. A file at mode 600 owned by a dedicated account is easier to lock down and audit than a value scattered across a process tree.

My repository is private, so is a committed secret really a problem?

Yes. Private is an access setting, not a guarantee. Collaborators change, repositories get forked or mirrored, and a laptop with a clone can be lost. Treat any secret that reached a commit as exposed and rotate it, whatever the repository visibility.

What mode should a secret file have?

Use 600 for owner read and write with no group or other access, owned by the account that runs the service. When a small group needs read access, 640 with a dedicated group works. Never leave a secret at 644 or looser.

Are the keys in this guide real?

No. Every value is a throwaway demo secret generated for the walkthrough, shown as sk_demo_... and masked where a real workflow would mask it. They were created on a test server and removed afterwards, and none grant access to anything.

What you can do now

You have seen the destination, an encrypted file that is safe to commit and opens only for your key, and you have seen the problem it solves: a plaintext secret leaking through file mode, the process environment, a logged URL and git history. The fix you can apply today is chmod 600 and a dedicated owner on every secret file, plus a .gitignore entry before the first commit.

That closes the easy holes but not all of them. The next chapter builds file-based secrets properly, with a service account, systemd credentials that never enter the environment, and a tmpfs so runtime secrets never touch the disk: file-based secrets done right.


Secure secrets on a slice

Keep secrets safe on a slice

Secrets belong encrypted on a server you control, not in git. Launch a slice, apply this guide, and stop leaking keys for good.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot