Guide

File-Based Secrets Done Right

Part 2 of 4By Amith Kumar6 min read
Part 2 of 4Secrets Management on a VPS: A Hands-On Guide
  1. 1The Problem With Plaintext Secrets
  2. 2File-Based Secrets Done Right
  3. 3Encrypt Secrets at Rest with age and sops
  4. 4Secret Rotation, Auditing and Git-History Scrubbing
Verified 22 Jul 2026 · Ubuntu 24.04 LTS

File-based secrets done right

Chapter one ended on a single fix: chmod 600 and a dedicated owner. File-based secrets take that floor and build the real pattern on top of it, which is the practical default for a single slice: a locked file on disk that exactly one account reads, with no external service to run. This chapter does file-based secrets properly on a live Ubuntu 24.04 server, using a dedicated owner, systemd credentials that never reach the process environment, an EnvironmentFile at mode 600, and a tmpfs so runtime secrets never touch the disk.

Key takeaways
  • Give each service its own system account and a secret file owned by that account at mode 600.
  • systemd LoadCredential= exposes a secret under $CREDENTIALS_DIRECTORY, not in the environment.
  • An EnvironmentFile= is loaded with whatever permissions you set, so lock it to 600 yourself.
  • A tmpfs keeps runtime secrets in RAM, so they never get written to disk or a backup.

The goal across all four methods is the same: exactly one account can read the value, and the value lives in as few places as possible. Start with the account and the file mode, then layer systemd on top.

A dedicated owner and mode 600

Running services as your login user, or worse as root, means every secret they hold is readable by that broad account. A system account fixes this. It has no login shell and no home, exists only to run one service, and owns that service's secret file. No other account, including the web server user, can read it.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin appsvc
sudo stat -c '%A %a %U:%G  %n' /etc/appsvc/secrets.env
sudo -u www-data cat /etc/appsvc/secrets.env
sudo -u appsvc cat /etc/appsvc/secrets.env
A secret file owned by the appsvc account at mode 600 that the www-data account cannot read, while the service account itself reads it

The file is owned by appsvc:appsvc at mode 600. When www-data tries to read it the kernel returns Permission denied, while the service account itself reads it fine. This is the tightest a plain file gets: one owner, no group or other access. It is also the pattern systemd expects when you hand a service a credential.

Checkpoint

Run sudo -u www-data cat /etc/appsvc/secrets.env and confirm it is refused, then sudo -u appsvc cat /etc/appsvc/secrets.env and confirm the owner reads it. If the wrong account can read the file, check ls -l shows appsvc appsvc and the mode is -rw-------.

systemd credentials that never enter the environment

Passing a secret through the environment leaks it into /proc, as chapter one showed. systemd's LoadCredential= avoids that. It reads a file at start, places a copy in a private per-service directory, and points $CREDENTIALS_DIRECTORY at it. The secret is readable by the service but never exported as an environment variable.

[Service]
Type=oneshot
DynamicUser=yes
LoadCredential=apikey:/etc/secretsdemo/apikey
ExecStart=/usr/local/bin/secretsdemo-check.sh

The demo service reads its credential and reports on it without printing the value, then checks its own environment.

sudo journalctl -u secretsdemo.service -n 8 -o cat
The journal of a systemd unit using LoadCredential, showing the credential under the credentials directory and a check confirming it is not in the environment

The journal shows $CREDENTIALS_DIRECTORY set to a path under /run, the credential file present and readable, and the final line confirming the key is not in the environment. The credential directory lives on a tmpfs and is cleaned up when the service stops, so the secret never persists. For a fixed value you can also inline it with SetCredential=, though a file keeps the secret out of the unit text.

EnvironmentFile and tmpfs

Many apps still expect their config as environment variables. EnvironmentFile= loads KEY=value lines from a file into the service environment. systemd does not enforce the file's permissions for you, so lock it down yourself: owner-only 600, owned by the service account, exactly like the file above. A world-readable EnvironmentFile reintroduces the chapter-one leak.

For secrets that only exist while the service runs, a tmpfs keeps them in memory. A tmpfs is a filesystem backed by RAM, so anything written there is gone on reboot and never reaches the disk or a disk backup.

findmnt /run/appsvc
sudo stat -c '%A %a  %n' /run/appsvc/token
findmnt showing a tmpfs mount backed by RAM and a token file inside it at mode 600, so the runtime secret never touches the disk

The mount shows type tmpfs, and the token file sits inside it at mode 600. Because /run is already a tmpfs on Ubuntu, runtime secrets placed there stay in RAM by default. Combine the pieces and each secret has one owner, one location, and no path onto the disk or into the environment that you did not choose.

Going further: DynamicUser and short-lived jobs

The demo unit pairs LoadCredential= with DynamicUser=yes, which runs the service under a temporary account created at start and removed at stop. systemd scopes the credential directory to that account, so the file under $CREDENTIALS_DIRECTORY is readable by the running service and by nothing else. That combination gives you an owned, isolated secret without even creating a permanent account, which suits short-lived or one-shot jobs.

For a long-running service you usually want a stable system account instead, so logs, file ownership and audits point at a name that persists. Reach for DynamicUser=yes when the job is transient and leaves nothing behind, and for a named account like appsvc when the service and its files need to outlive a single run.

Frequently asked questions

When should I use LoadCredential instead of an EnvironmentFile?

Prefer LoadCredential= when the app can read a file path, because the secret never enters the environment and cannot leak through /proc. Use EnvironmentFile= only when the app insists on environment variables, and then lock the file to mode 600 owned by the service account.

Does systemd protect an EnvironmentFile automatically?

No. systemd reads whatever file you point it at, with whatever permissions it has. Setting the file to 600 and the right owner is your job. systemd does harden the LoadCredential= directory, which is one reason to prefer it.

Why put runtime secrets on a tmpfs?

A tmpfs lives in RAM, so a secret written there is never flushed to disk and cannot appear in a filesystem backup or on a recovered drive. It also disappears on reboot, which limits how long a leaked value stays around. On Ubuntu, /run is already a tmpfs.

Is a system account really necessary for one app?

It is worth the two commands. A dedicated account limits the blast radius: if the app is compromised, the attacker gets that account's secrets and nothing else. Sharing your login user or root across services means one breach exposes every secret on the box.

What you can do now

Give each service a system account, own its secret file as that account at mode 600, and choose how the app receives the value: LoadCredential= to keep it out of the environment, an EnvironmentFile= locked to 600 when the app needs variables, and a tmpfs for anything that should stay in RAM.

These methods keep a secret safe on the server, but they do not make it safe to commit, because the value is still plaintext on disk. The next chapter installs age and sops on the bare box and encrypts secrets at rest, so the ciphertext is the version you store in the repository: encrypt secrets at rest with age and sops.


Secure secrets on a slice

Keep secrets safe on a slice

Secrets belong encrypted on a server you control, not in git. Launch a slice, apply this guide, and stop leaking keys for good.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot