How to Keep Secrets Out of Your Git Repo and Deployed Site

How to Keep Secrets Out of Your Git Repo and Deployed Site

The only reliable way to manage environment variables and secrets in deployment is to keep them out of the repository entirely and inject them at runtime, so the code that reads a secret and the secret itself never travel together. Everything below follows from that one rule: version control holds the code and a template listing the variable names, and the actual values live in the environment that runs the process.

Why secrets end up in Git in the first place

Secrets rarely get committed on purpose. They get committed because the shortest path from "my code works" to "my code works on the server" runs through a local file. You write a config file, you fill in a database password to test something, you commit everything with git add ., and the value is now in history. Deleting the file in a later commit does not remove it. Git stores every object it has ever been told about, and anyone who clones the repository can run git log -p and read it.

The same pattern shows up in frontend builds. A bundler happily inlines anything you reference through its environment mechanism, so a key that starts with a public prefix ends up in the shipped JavaScript, visible to anyone who opens the developer tools. That is not a leak in the sense of a breach. It is the build system doing exactly what you asked, and it is worth understanding before you put anything sensitive near it.

Separate config from secrets

Split your settings into two groups. Configuration is non sensitive and can live in the repo: port numbers, feature flag names, log levels, the hostname of a service. Secrets are everything that grants access if leaked: API keys, database passwords, signing keys for sessions or tokens, private keys for TLS or for signing webhooks.

Commit a template that documents the shape without the values. A file like .env.example is the convention most teams settle on:

DATABASE_URL=postgres://user:password@host:5432/dbname
SESSION_SIGNING_KEY=replace-me
STRIPE_SECRET_KEY=replace-me

The template is safe to commit because it contains placeholders. Add the real file to .gitignore before you create it, not after:

printf '.env\n.env.*\n*.pem\n' >> .gitignore
git check-ignore -v .env

The second command prints the rule that matches the path, which confirms the ignore is actually in effect. That check matters more than it sounds, because a broader ignore rule higher in the tree, or a negation later in the file, can silently re-include the file you thought you had excluded.

How to manage environment variables and secrets in deployment

Once the values are out of the repo, the deployment platform has to supply them. There are three common shapes, and they differ mainly in how much you trust the surrounding system.

The first is a plain environment variable set by the process supervisor. With systemd you use EnvironmentFile= pointing at a file owned by the service user and readable only by it. With a container you pass values through the orchestrator's secret mechanism rather than baking them into the image. The process reads them with the ordinary environment lookup, for example os.environ["DATABASE_URL"] in Python or process.env.DATABASE_URL in Node.

Environment variables are convenient and they are also easy to overexpose. They are inherited by child processes, they can appear in crash dumps, and some frameworks print the whole environment on a startup error. Treat them as the default, not as a guarantee of secrecy.

The second shape is a file mounted at runtime, often under a path like /run/secrets/, readable only by the process that needs it. This is the model most container secret stores use. The application reads the file at startup, and the value never appears in the process environment at all. If your language or framework supports it, this is usually the better default for anything long lived.

The third shape is a secret manager that the application queries over the network, authenticated by the workload's own identity rather than by a static credential. The application fetches the secret at boot and optionally refreshes it on an interval. This adds a dependency at startup, so cache the value and handle the fetch failure explicitly rather than crashing in a loop.

Whichever you choose, the application code should not care. Read from a single configuration module that checks the environment first, then the mounted file, then falls back to a development default that is obviously fake. That keeps local development working without a secret manager and keeps production reading from the real source.

Stop leaks before they leave the machine

Prevention beats cleanup, so put a check in the commit path. A pre-commit hook that scans staged changes for high entropy strings and known key prefixes will catch most accidents before they become history. Tools in this space are easy to find by searching for a secret scanning pre-commit hook; the important part is that the hook runs on the developer's machine and fails the commit rather than reporting after the fact.

Add the same scan to your continuous integration pipeline as a second line of defense, and enable any secret scanning that your repository host offers. None of these are perfect. They reduce the window from "committed and forgotten" to "blocked at the keyboard", which is the difference that matters.

Rotating a secret that has already leaked

Assume anything that reached a remote is compromised, even a private repository, even if you force pushed. Rotation is the fix, and it is a sequence, not a single action.

First, issue a new credential at the provider and give it the same permissions as the old one. Second, update the value wherever it is injected, in your deployment configuration and in any local development environment that needs it. Third, deploy and confirm the new value is in use, which usually means watching the logs for a successful authenticated request. Fourth, and only then, revoke the old credential. Revoking first gives you an outage with no way back; revoking last gives you a window where both work, which is what you want.

If the secret was a signing key rather than an access credential, rotation is harder because existing sessions or tokens were signed with it. Support two keys during the transition: verify with the old key and the new key, sign only with the new one, then drop the old key once the longest lived token has expired.

After rotation, clean up what you can. Rewriting history with git filter-repo removes the value from future clones, but it does not remove it from clones that already exist, from forks, or from caches on the hosting side. That is why rotation comes first and history rewriting is optional housekeeping.

What to do next

Pick one repository and check whether it currently contains a real secret. Run a scanner over the full history rather than just the working tree, since the working tree is the part you have already looked at. If you find something live, rotate it before you do anything else, then move the value into your deployment platform's secret mechanism and commit a template in its place. Once that loop is boring, repeat it for the next repository, and add the pre-commit hook so the loop stops being necessary.

Related articles

Subscribe to our newsletter

Get the latest hosting tips, performance insights, and industry news.