
Your VPS can be fully patched and still get compromised by one dumb slip: a stray .env in a backup, a config pasted into a support ticket, or a secret baked into a Docker layer forever. If you deploy from Git, you need a repeatable way to store and ship secrets that doesn’t rely on “just don’t commit it.” This guide covers Linux VPS secrets management with sops + age in 2026: keep encrypted files in your repo, decrypt only on the server, rotate predictably, and roll back without guesswork.
Scenario: you run a small internal API on a Debian 12 VPS. It listens on port 8087, uses Postgres, and needs a few secrets: a database URL, an app signing key, and an SMTP password. You’ll commit only ciphertext to Git and decrypt on the VPS during deploy.
Why sops + age works well on a VPS
Most VPS “secret handling” ends up as one of two habits: a long-lived .env left on disk, or secrets copy/pasted into a CI variable store until they drift and nobody trusts what’s live. Both fail the same way—quietly—until you need an audit trail or a rollback.
- sops (Mozilla SOPS, actively maintained) encrypts YAML/JSON/INI/dotenv-style files while keeping them diff-friendly. You can review changes in Git without exposing plaintext.
- age is a small, modern encryption tool. You encrypt to a public key (recipient) and only the server holding the private key can decrypt.
The result is simple: encrypted secrets stay in the repo, plaintext appears only where it’s used, and rotation becomes a routine change—not a scramble.
Prerequisites
- A Linux VPS (this walkthrough uses Debian 12 on systemd).
- SSH access with sudo.
- A Git repo for your app.
- A service you deploy with systemd (or can adapt to it).
If you’re still tightening the basics, do that first. These help:
Step 1: Pick a clean secrets layout (and stop using ad-hoc .env)
Start with a predictable layout on your workstation. It keeps secrets from turning into “that one file someone created last year.”
repo/
app/
deploy/
secrets/
api.env.sops
systemd/
acme-api.service
acme-api.envfile.path
One rule: api.env.sops is safe to commit because it’s encrypted. The decrypted file never lands in Git and only exists on the VPS.
Step 2: Install sops and age (workstation + VPS)
Install both tools on your workstation (for editing) and on the VPS (for deploy-time decryption).
Debian 12 / Ubuntu 24.04+ on the VPS:
sudo apt update
sudo apt install -y age
SOPS packaging varies by distro and repo policy. In many setups you’ll install SOPS from the upstream release artifact and pin the version. As of 2026, the v3.x line is a common stable choice. Download the correct binary for your CPU (example shows x86_64):
# Verify the download source and checksum in your environment.
cd /tmp
curl -fsSLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
sudo install -m 0755 sops-v3.9.4.linux.amd64 /usr/local/bin/sops
sops --version
Expected output:
sops 3.9.4
On your workstation, install with your preferred package manager (Homebrew, apt, dnf, etc.) and confirm both tools work:
age --version
sops --version
Step 3: Generate an age keypair for the VPS and lock down permissions
Generate a dedicated keypair for this server. Don’t reuse your personal workstation key. Treat server keys like SSH host keys: stable, protected, and not passed around casually.
On the VPS:
sudo mkdir -p /etc/sops/age
sudo chmod 700 /etc/sops/age
sudo age-keygen -o /etc/sops/age/keys.txt
sudo chmod 600 /etc/sops/age/keys.txt
sudo head -n 2 /etc/sops/age/keys.txt
Expected output:
# created: 2026-04-14T...
# public key: age1k8m...9f3q
Copy the public key line. You’ll use it on your workstation to encrypt secrets to this VPS.
Hard rule: never commit /etc/sops/age/keys.txt to Git, never put it in CI variables, and don’t email it. If you back it up, back it up encrypted.
Step 4: Tell sops which key to use (create a .sops.yaml)
In your repo root on your workstation, add .sops.yaml so SOPS always uses the VPS recipient for files under deploy/secrets/.
cat > .sops.yaml <<'YAML'
creation_rules:
- path_regex: ^deploy/secrets/.*\.sops$
age: "age1k8m...9f3q" # replace with your VPS public key
YAML
This removes “which key did we use?” from your team’s memory. If you add more VPS nodes later, add more recipients so each server can decrypt the same file.
Step 5: Create an encrypted env file (dotenv format) with sops
Create the secrets file in dotenv format. It plays nicely with systemd EnvironmentFile= and most app frameworks.
mkdir -p deploy/secrets
cat > deploy/secrets/api.env.sops <<'ENV'
DATABASE_URL=postgres://acme_api:REPLACE_ME@127.0.0.1:5432/acme
APP_SIGNING_KEY=REPLACE_ME
SMTP_PASSWORD=REPLACE_ME
ENV
Encrypt it in place:
sops -e -i deploy/secrets/api.env.sops
Verify it’s encrypted:
head -n 12 deploy/secrets/api.env.sops
Expected output (example):
DATABASE_URL: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
APP_SIGNING_KEY: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
SMTP_PASSWORD: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops:
kms: []
age:
- recipient: age1k8m...9f3q
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
Swap placeholders for real values by editing through sops. It decrypts to a temp file, opens your editor, then re-encrypts on save:
export EDITOR=nano
sops deploy/secrets/api.env.sops
Commit .sops.yaml and the encrypted secrets file:
git add .sops.yaml deploy/secrets/api.env.sops
git commit -m "Add encrypted API secrets with sops+age"
Step 6: Add a deploy-time decrypt step on the VPS (with predictable file paths)
You want decrypted secrets to land somewhere that’s:
- not in your repo working tree
- owned by root (or the service user) with tight permissions
- easy to atomically swap for rollback
Here we’ll write to /etc/acme-api/ and keep timestamped backups.
On the VPS:
sudo useradd --system --home /srv/acme-api --shell /usr/sbin/nologin acme-api || true
sudo mkdir -p /etc/acme-api /var/lib/acme-api/releases
sudo chown root:root /etc/acme-api
sudo chmod 700 /etc/acme-api
Create a deploy script that decrypts into a temp file, runs quick checks, then installs it into place.
sudo tee /usr/local/sbin/acme-api-secrets-deploy > /dev/null <<'SH'
#!/bin/sh
set -eu
REPO_DIR="/srv/acme-api/repo"
SRC="$REPO_DIR/deploy/secrets/api.env.sops"
DEST_DIR="/etc/acme-api"
DEST="$DEST_DIR/api.env"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
BACKUP_DIR="/var/lib/acme-api/releases"
umask 077
if [ ! -f "$SRC" ]; then
echo "Missing encrypted secrets: $SRC" 1>&2
exit 1
fi
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
# Decrypt using the server key
SOPS_AGE_KEY_FILE=/etc/sops/age/keys.txt sops -d "$SRC" > "$TMP"
# Minimal sanity checks (fail fast on empty or placeholder values)
if ! grep -q '^DATABASE_URL=' "$TMP"; then
echo "DATABASE_URL missing" 1>&2
exit 1
fi
if grep -q 'REPLACE_ME' "$TMP"; then
echo "Secrets file still contains REPLACE_ME placeholders" 1>&2
exit 1
fi
# Backup old secrets for rollback
if [ -f "$DEST" ]; then
install -m 600 "$DEST" "$BACKUP_DIR/api.env.$TS"
fi
install -m 600 "$TMP" "$DEST"
echo "Deployed secrets to $DEST (backup in $BACKUP_DIR if previous existed)"
SH
sudo chmod 0750 /usr/local/sbin/acme-api-secrets-deploy
Quick verification:
sudo -n /usr/local/sbin/acme-api-secrets-deploy
Expected output:
Deployed secrets to /etc/acme-api/api.env (backup in /var/lib/acme-api/releases if previous existed)
Two details matter here: nothing prints secrets to your terminal, and umask 077 keeps temp files from becoming world-readable.
Step 7: Wire secrets into systemd without exposing them in unit files
Don’t paste secrets into .service units. Unit files are easy to read, easy to copy, and often end up in config repos or ticket threads.
Create a unit that references an env file:
sudo tee /etc/systemd/system/acme-api.service > /dev/null <<'UNIT'
[Unit]
Description=Acme Internal API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=acme-api
Group=acme-api
WorkingDirectory=/srv/acme-api/current
# Load decrypted secrets
EnvironmentFile=/etc/acme-api/api.env
# Example app command (replace with yours)
ExecStart=/srv/acme-api/current/bin/acme-api --listen 127.0.0.1:8087
Restart=on-failure
RestartSec=2
# Basic sandboxing that usually doesn't break typical apps
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/acme-api
[Install]
WantedBy=multi-user.target
UNIT
Reload and start:
sudo systemctl daemon-reload
sudo systemctl enable --now acme-api
sudo systemctl status acme-api --no-pager
Expected output (example):
● acme-api.service - Acme Internal API
Loaded: loaded (/etc/systemd/system/acme-api.service; enabled; preset: enabled)
Active: active (running) since ...
If you want your service to recover cleanly from bad config, add watchdog-based health checks and treat failures as fast, visible signals. See systemd watchdog with health checks and safe rollbacks.
Step 8: Verification (prove secrets decrypt correctly and the service actually uses them)
Run two checks: permissions, then behavior.
1) Permissions on the decrypted file
sudo ls -l /etc/acme-api/api.env
sudo stat -c '%a %U %G %n' /etc/acme-api/api.env
Expected output:
-rw------- 1 root root ... /etc/acme-api/api.env
600 root root /etc/acme-api/api.env
If your app runs as acme-api and can’t read the file, you can instead:
- set group ownership to
acme-apiand mode0640, or - use systemd
LoadCredential=(advanced), or - decrypt into a root-owned location and pass only the needed values via a root-only wrapper (less ideal).
2) Behavior check
If your API exposes a health endpoint, query it locally (the service binds to 127.0.0.1:8087):
curl -fsS http://127.0.0.1:8087/health | head
Then confirm it connected to Postgres by checking logs—without dumping config values:
sudo journalctl -u acme-api -n 50 --no-pager
Step 9: Rotate secrets without downtime surprises
Rotation is where teams get nervous and start inventing “temporary” plaintext workflows. Don’t. Keep it repetitive.
- Edit encrypted secrets on your workstation:
sops deploy/secrets/api.env.sops. - Commit the change.
- Pull on the VPS and run the decrypt script.
- Restart the service (or send a reload signal if your app supports config reload).
# On the VPS
cd /srv/acme-api/repo
sudo -u root git pull --ff-only
sudo /usr/local/sbin/acme-api-secrets-deploy
sudo systemctl restart acme-api
sudo systemctl is-active acme-api
Expected output:
active
Step 10: Common pitfalls (and how to avoid them)
- Accidentally committing plaintext. Add deny rules in
.gitignorefor decrypted paths. Also consider a pre-commit hook that rejectsDATABASE_URL=patterns outside.sopsfiles. - Temp files leaking secrets. Use
umask 077andmktempas shown. Avoid editors that write swap files into world-readable directories. - Wrong recipient key. If you encrypt to the wrong age public key, the VPS cannot decrypt. Keep recipients in
.sops.yaml, not in team memory. - Secrets exposed via logs. Apps sometimes log config on startup. Grep your logs for “password”, “token”, “DATABASE_URL”. Fix the app, don’t accept it.
- Secrets readable by too many users. On a multi-admin box, treat root as “too many.” Use a bastion host and tighter sudo policies if you need separation. Your firewall logs can help with auditing; see VPS firewall logging with nftables.
Rollback plan (two levels: secrets-only and full release)
Rollbacks should be mechanical. You shouldn’t need to remember what the previous value was.
A) Roll back just the decrypted secrets file
List backups and restore one:
sudo ls -1 /var/lib/acme-api/releases/api.env.* | tail -n 5
# pick one, then:
sudo install -m 600 /var/lib/acme-api/releases/api.env.20260414T120501Z /etc/acme-api/api.env
sudo systemctl restart acme-api
sudo systemctl status acme-api --no-pager
B) Roll back to a previous Git commit (and redeploy secrets from that commit)
cd /srv/acme-api/repo
git log --oneline -n 10
sudo -n git checkout <good_commit_sha>
sudo /usr/local/sbin/acme-api-secrets-deploy
sudo systemctl restart acme-api
This is a big reason to keep encrypted secrets in Git: you can roll back config and code together, without digging through old notes.
Next steps: tighten and automate without making it fragile
- Add CI checks that fail builds if plaintext secrets appear in diffs. Keep it strict, but avoid regex false positives that train the team to ignore failures.
- Pair with monitored deployments. If you already run OpenTelemetry, alert on restart loops and failed DB connects. This complements your rotation flow. Related: VPS monitoring with OpenTelemetry Collector.
- Reduce exposed admin surface. Consider a private admin network or VPN so your management ports aren’t public. A simple option is Tailscale; see Tailscale VPS VPN setup.
- Move to per-service keys. If you host multiple apps on one VPS, give each one its own age key and its own decrypt script path.
If you’re standardizing deployments across a few services, consistent disk and network performance makes this workflow calmer to operate. Start with a HostMyCode VPS and keep the stack under your control, or offload routine upkeep with managed VPS hosting.
FAQ: Linux VPS secrets management with sops + age
Should I store the age private key in my CI system?
Usually no. If CI can decrypt prod secrets, then a CI breach is effectively a prod breach. Prefer decrypting on the VPS using a key that lives only on that server.
Can multiple servers decrypt the same secrets file?
Yes. Add multiple age recipients in .sops.yaml. sops encrypts the data key to each recipient, and any server holding its private key can decrypt.
What if I need developers to decrypt locally for debugging?
Create a separate “dev” secrets file encrypted to developer keys, or add developer recipients only to a non-production file. Don’t add personal recipients to production secrets unless you’re comfortable with that access.
Is dotenv the best format for secrets?
It’s convenient for systemd and many apps. YAML or JSON can be a better fit for structured config. sops supports all three; keep the decrypted footprint small and consistent.
How do I prevent secrets from appearing in process listings?
Don’t pass secrets as command-line flags. Use EnvironmentFile= or credentials mechanisms so ps output doesn’t expose them.
Summary
You don’t need a full secret vault to stop accidental leaks on a VPS. With sops + age, you keep encrypted secrets in Git, decrypt only on the server, validate before swapping, and roll back quickly when rotation goes sideways. For a repeatable ops baseline in 2026, this is one of the highest-impact changes you can make.
For teams that want consistent environments for multiple services, pair this approach with a stable HostMyCode VPS and write the workflow down once, then reuse it across repos.