Skip to content
DevOps devops security 6 min read

Managing Secrets & Credentials

Almost every application needs a few pieces of private information to run: a database password, an API key, a token to talk to a payment provider. These are called “secrets” (sensitive values that must never be seen by anyone except the app itself). The hard part of DevOps security is not picking strong secrets, it is delivering them to your servers without accidentally leaking them into Git, logs, or backups. This page shows the safe ways to store and hand out secrets on an Ubuntu 22.04/24.04 LTS server, from simple environment files all the way up to a dedicated secret manager.

The number one rule: secrets never go in your code. If a password is written into a source file and pushed to Git, treat it as compromised forever, even after you delete it. Git keeps full history, so the old value is still readable in past commits. The only real fix is to rotate (change) that secret.

Why secrets in code and Git are dangerous

When you hardcode a secret (write the value directly in a source file), several bad things can happen. Anyone with read access to the repository can see it. If the repo ever becomes public by mistake, bots scan GitHub for keys within seconds. And because Git stores history, deleting the line does not delete the value, it just hides it from the latest version.

The defense is simple to state: keep secrets out of the files you commit, and load them at runtime instead. Add a .gitignore entry so secret files can never be staged by accident.

echo ".env" >> .gitignore
echo "*.pem" >> .gitignore
git rm --cached .env 2>/dev/null || true
git status

Output:

On branch main
Changes not staged for commit:
  modified:   .gitignore
Untracked files:
  (use "git add <file>..." to include them)

The .env file no longer appears as something Git wants to commit. When to use this: always, in every project, on day one.

Environment variables and EnvironmentFile

An environment variable (a named value the operating system hands to a process when it starts) is the standard way to pass a secret to an app. The app reads DATABASE_URL from its environment instead of from a file in the repo.

For a service managed by systemd (Ubuntu’s service manager, the program that starts and supervises background apps), the clean approach is an EnvironmentFile. You put the secrets in a file outside the repo, lock down its permissions, and point the unit at it.

Create the file in /etc/myapp/ and restrict it so only root can read it:

sudo mkdir -p /etc/myapp
sudo tee /etc/myapp/env > /dev/null <<'EOF'
DATABASE_URL=postgres://app:s3cr3t@localhost/appdb
STRIPE_KEY=sk_live_51Hxxxxxxxxxxxxxxxxxxxxxx
EOF
sudo chmod 600 /etc/myapp/env
sudo chown root:root /etc/myapp/env

The chmod 600 means “read and write for the owner, nothing for anyone else”. Now reference it from the service unit:

# /etc/systemd/system/myapp.service
[Service]
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /srv/myapp/server.js
User=myapp

Reload and restart so the change takes effect:

sudo systemctl daemon-reload
sudo systemctl restart myapp

When to use this: for a single server or a small fleet where you deploy by hand or with a simple script. It is a big step up from a .env sitting in the project folder.

.env file hygiene

Many frameworks (Node, Laravel, Django) read a .env file in the project directory during development. That is fine locally, but follow these rules so it never leaks:

  • Never commit it. Always .gitignore it (shown above).
  • Commit a .env.example instead, listing the keys with blank or dummy values, so teammates know what is needed.
  • On servers, prefer an EnvironmentFile in /etc/ over a .env inside the deploy folder, and set chmod 600.
# .env.example  (safe to commit)
DATABASE_URL=
STRIPE_KEY=
JWT_SECRET=

Watch your logs. A common leak is printing the whole environment for debugging, e.g. console.log(process.env), which then writes your live keys into /var/log or your log aggregator. Never log secrets, and scrub them from error reports.

Secret managers: Vault, cloud managers, and SOPS

Files on disk work, but they do not rotate themselves, audit who read them, or scale across many machines. A “secret manager” (a dedicated service whose only job is to store secrets and hand them out under tight access control) solves this. Here is how the main options compare.

ToolWhat it isBest when
HashiCorp VaultSelf-hosted server with dynamic secrets, leases, and audit logsYou run your own infra and want central control + auditing
AWS / GCP / Azure Secret ManagerManaged cloud service, secrets fetched via IAM identityYou already run in that cloud and want zero servers to maintain
SOPSEncrypts secret files so they can live safely in GitSmall teams, GitOps, no extra service to run

With Vault, an app authenticates and reads a secret at runtime, so nothing is stored on disk:

export VAULT_ADDR=https://vault.internal:8200
vault kv get -field=password secret/myapp/db

Output:

s3cr3t-rotated-2026-06-15

With SOPS (a tool that encrypts only the values in a YAML/JSON file using a key from age or KMS), you can commit an encrypted file safely:

sudo apt install age -y
age-keygen -o key.txt
sops --encrypt --age $(grep public key.txt | awk '{print $4}') secrets.yaml > secrets.enc.yaml

The resulting secrets.enc.yaml has readable keys but ciphertext values, so it is safe in Git. When to use SOPS: GitOps setups where you want one source of truth and no running secret server. When NOT to: if you need automatic rotation or per-request access auditing, use Vault or a cloud manager instead.

Rotation

Rotation means changing a secret on a schedule, or immediately if you suspect it leaked. Even perfectly stored secrets should rotate, because old copies linger in backups, laptops, and chat history. The pattern is: create the new value in your manager, deploy it, confirm the app works, then revoke the old one.

# Example: rotate a database password, then restart the app to pick it up
sudo -u postgres psql -c "ALTER USER app WITH PASSWORD 'new-strong-value';"
sudo sed -i 's#^DATABASE_URL=.*#DATABASE_URL=postgres://app:new-strong-value@localhost/appdb#' /etc/myapp/env
sudo systemctl restart myapp

A secret manager automates most of this. Vault can issue short-lived database credentials that expire on their own, so a leaked value is useless within minutes.

The secrets you load here are exactly the environment values your deployment reads, so this ties directly into how you configure each deployment environment, see Server hardening for locking down the host they run on.

Best Practices

  • Never commit secrets; .gitignore every .env, .pem, and key file, and commit a .env.example instead.
  • Deliver server secrets through a chmod 600 EnvironmentFile owned by root, not a file inside the deploy folder.
  • Never log process.env or print secrets in error reports, stack traces, or CI output.
  • Use a secret manager (Vault, a cloud manager, or SOPS) once you have more than one server or need auditing.
  • Rotate secrets on a schedule and immediately after any suspected leak, then revoke the old value.
  • Give each app the smallest set of secrets it needs, following least privilege.
Last updated June 15, 2026
Was this helpful?