Secrets in Production: Managing Env Variables
Stop hardcoding credentials — learn to manage environment variables in production with practical secrets management strategies.
Managing environment variables and secrets properly is one of those things that separates a hobby project from a production system. Hardcoding credentials in your source code is a fast track to a security incident, and even exporting them manually in a shell session is fragile. This post covers practical, battle-tested approaches for keeping your secrets safe and your deployments reproducible.
The Problem with .env Files in Production
.env files are great for local development, but they become a liability in production. They get committed to Git by accident, they sit on the filesystem in plaintext, and they encourage a false sense of security. If your server is compromised, an attacker with read access to the filesystem gets every database password and API key instantly.
A better pattern: treat secrets as runtime inputs, not as files. Your application should read them from the environment, and the environment itself should be populated by a dedicated secrets manager. This way, secrets never touch disk in plaintext, and you can rotate them without redeploying.
Using Systemd EnvironmentFile with Locked-Down Permissions
If you’re running a simple Node.js or Python service on a single VPS with systemd, you don’t need a full cloud vault. Use systemd’s EnvironmentFile directive, but secure it properly.
First, create the file with strict ownership:
sudo mkdir -p /etc/myapp
sudo touch /etc/myapp/secrets.env
sudo chown root:myapp /etc/myapp/secrets.env
sudo chmod 640 /etc/myapp/secrets.env
Then in your unit file (/etc/systemd/system/myapp.service):
[Service]
EnvironmentFile=/etc/myapp/secrets.env
ExecStart=/usr/bin/node /opt/myapp/index.js
The key is chmod 640 — only root and the service group can read it. Never use 644 or worse, 777. And make sure your .gitignore includes *.env so you never commit these files.
Real Production: Docker Secrets and Vault
For Docker Swarm or Kubernetes, use built-in secret mechanisms. With Docker Swarm, you get secrets as in-memory files mounted at /run/secrets/:
echo "supersecret" | docker secret create db_password -
Then in your docker-compose.yml:
services:
app:
image: myapp:latest
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
Your app reads the file, not the environment variable directly. This prevents secrets from leaking into docker inspect output or container logs.
For more complex environments, HashiCorp Vault is the industry standard. You can inject secrets dynamically into your app using the Vault Agent:
vault agent -config=/etc/vault-agent.hcl
The agent writes secrets to a temp file, your app reads them, and the agent cleans up. No plaintext on disk, no secrets in process lists.
Rotation and Auditing: Don’t Skip This
Storing secrets is only half the battle. You need rotation policies and audit trails. Set up a cron job or CI pipeline that rotates database passwords every 30 days. With Vault, this is trivial:
vault write database/rotate-root/my-db
For systemd-based setups, create a simple script that generates a new password and reloads the service:
#!/bin/bash
NEW_PASS=$(openssl rand -base64 32)
sudo sed -i "s/^DB_PASS=.*/DB_PASS=$NEW_PASS/" /etc/myapp/secrets.env
sudo systemctl restart myapp
Finally, audit who has access. If you’re using a cloud provider, enable CloudTrail or equivalent. If you’re self-hosting, check journalctl for systemd service restarts and use auditd to monitor reads on your secrets file.
Conclusion
Stop treating secrets as an afterthought. Move them out of your codebase, into systemd EnvironmentFile with locked permissions, Docker secrets, or Vault. Automate rotation, and log access. Your future self — and your security auditor — will thank you.