Never Commit Your Keys - A Practical Guide to Secrets in Code
There's a specific kind of stomach drop that comes from realizing your database password is sitting in a public Git repository. Bots scan public repos constantly looking for exactly that, and API keys for cloud providers and payment services are a favorite. This is one of those problems where a few habits up front save you from a really bad day.
What counts as a secret
Anything that grants access. Database passwords, API keys, OAuth client secrets, private keys, webhook signing secrets, SMTP passwords, encryption keys, the app's session secret. If someone with that string could do something they shouldn't, it's a secret.
Things that aren't secret: your app name, which port it listens on, whether debug mode is on. Those are configuration. They also tend to change between your laptop and your server, so it's handy to keep them in the same place as secrets, just without the same paranoia.
The basic idea
Code goes in the repo. Secrets don't. Your code reads secrets from its environment at runtime, and each place the app runs (your laptop, a staging server, production) provides its own values.
Environment variables are the classic way to do that. They're just named values the operating system hands to a process when it starts. In PHP you'd read one like this:
<?php
$dbPass = getenv('DB_PASS');
if ($dbPass === false || $dbPass === '') {
throw new RuntimeException('DB_PASS is not set');
}
In Node it's process.env.DB_PASS, in Python os.environ["DB_PASS"]. Failing loudly when a value is missing is worth the two extra lines. It's much better than the app quietly connecting with an empty password and producing a confusing error three layers away.
The .env file
Typing a dozen variables into a shell every time gets old, so most projects use a .env file:
APP_ENV=local
DB_HOST=127.0.0.1
DB_NAME=myapp
DB_USER=myapp
DB_PASS=change-me-locally
STRIPE_SECRET_KEY=sk_test_xxxxxxxx
A small library loads that into the environment when the app starts. In PHP that's commonly vlucas/phpdotenv, in Node it's dotenv, and Python has python-dotenv. Docker Compose can read a file like this with env_file: too.
The one rule that matters: .env goes in .gitignore before you ever create it.
# .gitignore
.env
.env.*
!.env.example
Then commit a .env.example with every variable name and fake placeholder values. That way anyone setting up the project knows what's needed, and nobody has to guess.
A few gotchas
Keep it out of the web root. If your .env sits in the same folder your web server serves files from, someone may be able to just request yoursite.com/.env and read it. Put your public files in a public/ subfolder and point the web server there, so the .env lives one level up where it can't be served. Blocking dotfiles in your web server config is a good second layer.
PHP-FPM clears the environment by default. The clear_env setting is on by default, which means variables you set in your shell or systemd unit may not reach your PHP scripts. You can pass them explicitly with env[DB_PASS] = ... lines in the pool config, or just use a .env loader in the app.
Don't print them. Debug pages, phpinfo(), verbose error output and log lines can all expose environment variables. Turn off detailed errors in production and be careful what you log. "Connecting with config: ..." followed by the whole config array is a classic.
Environment variables aren't a vault. Any process running as the same user can generally read them, and they get inherited by child processes. For a small app on a server you control, that's usually an acceptable trade-off. For bigger setups, look at a proper secrets manager like HashiCorp Vault, your cloud provider's secret store, or Docker and Kubernetes secrets, which mount values as files instead.
Catching mistakes before they happen
Tools like gitleaks or trufflehog can scan your repo, and you can run them as a pre-commit hook so a key gets caught before it ever leaves your machine. GitHub also has secret scanning and push protection that can block pushes containing known key formats. Turn those on. They're cheap insurance.
If you already committed a secret
Assume it's compromised, and rotate it first. Generate a new key or password, update your app, and revoke the old one. Do that before anything else.
Deleting the file in a new commit doesn't help, because the old value is still in the history. Rewriting history with git filter-repo or BFG can remove it, but if the repo was ever public or cloned by anyone, copies may already exist. That's why rotation comes first and cleanup second.
To stop tracking a file you accidentally committed without deleting your local copy:
git rm --cached .env
Then make sure it's in .gitignore and commit that change.
Honestly, the whole thing comes down to one habit: create the .gitignore before the .env, every single project. Once that's muscle memory, most of this never comes up.

Comments