Trust / Chapter 9

By the end of this chapter you should be able to

  • Name four places a secret in an environment variable can be read from
  • Move an application onto file-backed secrets and prove the value stopped leaking
  • Encrypt a configuration file so it is safe to commit, and decrypt it again
  • Say where the key that decrypts committed configuration lives, and what happens if you lose it

Chapter 7 left a database password in a plain file. This chapter fixes it, starting with the part nearly every tutorial gets wrong.

Where a secret in an environment variable can be read #

Run two disposable containers with the same dummy password, one passed as an environment variable and one as a file:

$ printf '%s' 'super-secret-value' | sudo tee /var/tmp/leaklab-password >/dev/null
$ sudo chmod 0600 /var/tmp/leaklab-password
$ sudo docker run -d --name leaklab-viaenv \
    -e DB_PASSWORD=super-secret-value alpine:3.22 sleep 300
$ sudo docker run -d --name leaklab-viafile \
    -e DB_PASSWORD_FILE=/run/secrets/db_password \
    -v /var/tmp/leaklab-password:/run/secrets/db_password:ro \
    alpine:3.22 sleep 300

Now inspect both forms.

$ sudo docker inspect leaklab-viaenv --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass

Require your output to contain the complete dummy DB_PASSWORD value. That is documented container metadata, readable by anything that can talk to Docker.

The same question of the file-backed one:

$ sudo docker inspect leaklab-viafile --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass

Require only DB_PASSWORD_FILE and its path, not the value.

That is one leak. There are more, and they are worth knowing individually because each has a different fix:

docker inspect shows it, as above. Anything with broad read access to the Docker container API can read environment variables and can also reach logs, process arguments and container file-export endpoints. Chapter 8 deliberately avoided Docker discovery for this reason.

docker compose config renders your file with variables substituted, which is a genuinely useful debugging command that prints your passwords to the terminal:

$ sudo docker compose config | grep -i password

Run this only while the known dummy value is configured, and confirm that Compose renders the value rather than merely its source expression.

/proc/<pid>/environ holds the environment of a running process, on the host, with no Docker command involved. Root can read any of them.

Everything downstream. A crash handler that dumps the environment into a log, a subprocess that inherits it, a support tool that prints configuration. Each turns one leak into a permanent one.

So why does every guide use environment variables?Show me why

Because they are the only mechanism that works identically everywhere, and because for a great deal of software the configuration genuinely is not sensitive.

The problem is not the mechanism, it is using one mechanism for both kinds of value. TZ=Europe/Paris is fine in an environment variable for ever. DB_PASSWORD is not, and the two look identical in a compose file, so people stop distinguishing them.

The rule: if writing it on a whiteboard would be a problem, it does not go in an environment variable.

Two ways to give a container a password An environment variable is visible through docker inspect, docker compose config, the host process environment, and anything that logs its own configuration. A file-backed secret puts only a path in the container's metadata, and the value in a root-only file on the host. Environment variable DB_PASSWORD=super-secret-value stored in the container's own metadata docker inspect docker compose config /proc/<pid>/environ, on the host any crash dump or log of the environment every one of these reads it, unmodified File-backed secret DB_PASSWORD_FILE=/run/secrets/db_password the metadata holds a path, not a value the value sits in one file on the host /etc/homeserver/secrets/ is 0700, root mounted into the one container that needs it readable inside that container, by design docker inspect sees the mount, not the value Neither hides the secret from the application. That is not the goal, and could not be. The goal is to narrow which host and container processes can read the value.
The same password, two ways in. Everything on the left can read an environment variable without doing anything unusual.

File-backed secrets #

Compose has a mechanism for this. Declare a secret pointing at a file, and it appears inside the container under /run/secrets/:

services:
  database:
    image: ghcr.io/immich-app/postgres:...
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/immich_db_password
    secrets: [immich_db_password]

  immich-server:
    image: ghcr.io/immich-app/immich-server:...
    environment:
      DB_PASSWORD_FILE: /run/secrets/immich_db_password
    secrets: [immich_db_password]

secrets:
  immich_db_password:
    file: /etc/homeserver/secrets/immich_db_password

Both services need it, and each uses its own variable name: the Postgres image knows POSTGRES_PASSWORD_FILE and the application knows DB_PASSWORD_FILE. Converting one and not the other is the most common way this goes wrong, and it fails as "the application cannot connect to the database".

Inside the container it is an ordinary file:

$ sudo docker exec leaklab-viafile sh -c 'cat /run/secrets/db_password; ls -l /run/secrets/'

The first line should be the dummy value; the listing shows the ownership and mode inherited from the host file. Record what your machine reports instead of assuming a particular container uid.

Remove every lab artifact now:

$ sudo docker rm -f leaklab-viaenv leaklab-viafile
$ sudo rm -f /var/tmp/leaklab-password

The _FILE suffix is a convention, not a standard, and it is not universally supported. The current PostgreSQL and Immich images support the variables shown here. For another image, check its documentation rather than inferring support from the suffix. An ignored variable can leave an application broken or, worse, running without the protection you expected.

Look at that file mode

Compose bind-mounts the host file, so it arrives with the host's numeric ownership and permissions. Confirm that the application user can read it and unrelated processes on the host cannot.

That is fine, because everything in that container is the application anyway. What matters is the host side: the directory holding these files should be 0700 and owned by root, so nothing on the machine except root and the containers you granted can get at them. The value of this mechanism is that the secret is not in the container's metadata, not that it is hidden from the application that needs it.

Now the configuration itself #

File-backed secrets solve the runtime leak. They do not solve storage. The files have to exist, and plaintext files on disk keep your configuration out of git.

The trick is to encrypt the values, commit the ciphertext, and decrypt at deploy time.

SOPS encrypts the values in a structured file and leaves the keys readable, which is the property that makes it usable: a diff still shows you which setting changed. age is the encryption underneath, chosen here because it is one keypair and no ceremony.

Install both on the laptop, where you edit the canonical repository, and on the server, where deployment renders runtime files. Pin their release versions and checksums in the playbook or in a documented laptop setup file.

A keypair #

$ install -d -m 0700 ~/.config/sops/age
$ age-keygen -o ~/.config/sops/age/keys.txt
$ chmod 0600 ~/.config/sops/age/keys.txt
$ age-keygen -y ~/.config/sops/age/keys.txt

Record the age1... line printed by -y. It is the public key, or recipient: it encrypts, is not secret, and goes in your repository. Do not print or copy the AGE-SECRET-KEY-... line into a terminal transcript; it decrypts every file for that recipient.

Back up the age private key outside the server

Lose this key and the encrypted configuration cannot be read. Your photographs and restic backup still have their own recovery path, but every encrypted application credential must be recovered or rotated.

Keep the laptop copy at mode 0600, copy it to /etc/homeserver/age/keys.txt on the server with root ownership and mode 0600, then create an independent recovery copy in a password manager, on an encrypted removable device or on paper. The restic password introduced in chapter 11 is a different secret: it decrypts backup contents.

One explicit transfer is easier to audit than an untracked second copy:

$ scp ~/.config/sops/age/keys.txt admin@192.168.1.20:/var/tmp/homeserver-age-key
$ ssh -t admin@192.168.1.20 \
    'sudo install -d -o root -g root -m 0700 /etc/homeserver/age && sudo install -o root -g root -m 0600 /var/tmp/homeserver-age-key /etc/homeserver/age/keys.txt && rm -f /var/tmp/homeserver-age-key'

Tell SOPS which key to use #

A .sops.yaml at the top of your repository means you never type the recipient again, and that anyone adding a file gets it encrypted the same way:

.sops.yaml
creation_rules:
  - path_regex: secrets/.*\.env$
    age: <your-age1-recipient>

This file is committed. It contains only the public half.

Encrypt something #

$ sops encrypt --input-type dotenv --output-type dotenv --in-place secrets/immich.env
$ sops filestatus secrets/immich.env
$ git diff -- secrets/immich.env

Require filestatus to report an encrypted file. Inspect the real diff: variable names and recipients remain readable, while values and the integrity MAC are encrypted. Never edit those metadata blocks by hand.

That file is safe to commit.

Prove decryption succeeds without printing the password:

$ sops decrypt --input-type dotenv --output-type dotenv secrets/immich.env >/dev/null

`DB_USERNAME` did not need encrypting

And SOPS encrypted it anyway, because by default it encrypts every value. Harmless, but it makes diffs less useful: every change looks like a change to everything.

encrypted_regex in .sops.yaml restricts encryption to keys matching a pattern, so you can leave the boring settings readable. Worth doing once your files get long enough that you want to see what actually changed.

Change one value #

Edit on the laptop, where SOPS finds the protected key in its default age-key location:

$ sops set secrets/immich.env '["DB_PASSWORD"]' '"a-much-better-one"'
$ sops edit secrets/immich.env

sops edit opens your editor on the decrypted content, held in a temporary file it manages, and re-encrypts on save. sops set changes one value without an editor, which is what you want from a script.

sops edit uses a mode-0600 plaintext temporary file and removes it afterwards. It avoids a persistent plaintext copy, but /tmp may still be disk-backed. Use sops set for scripted single-value changes and keep the laptop encrypted if that temporary-file exposure matters to you.

SOPS says it cannot decrypt my file

Work out which key it is looking for and which one I have

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

sops decrypt is failing on a file I encrypted. I am not sure whether the problem is the key file location, the recipient the file was encrypted to, or my .sops.yaml. I do still have my age key file.

Getting it to the containers at deploy time #

The pieces now connect. Encrypted file in git, private key on the server, file-backed secrets in compose. What joins them is a small script that runs before docker compose up.

render-secrets.sh
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

out=/etc/homeserver/secrets
install -d -o root -g root -m 0700 "$out"

export SOPS_AGE_KEY_FILE=/etc/homeserver/age/keys.txt
tmp=
cleanup() { [[ -z ${tmp:-} ]] || rm -f "$tmp"; }
trap cleanup EXIT

render() {
  local source=$1 expression=$2 target=$3
  tmp=$(mktemp "$out/.${target}.XXXXXX")
  sops decrypt --input-type dotenv --extract "$expression" "$source" >"$tmp"
  test -s "$tmp"
  chmod 0444 "$tmp"
  mv -f "$tmp" "$out/$target"
  tmp=
}

# One explicit mapping per runtime secret.
render secrets/immich.env '["DB_PASSWORD"]' immich_db_password

Three deliberate choices in there.

umask 077 at the top, so anything created is private by default rather than private if you remembered.

Each destination is replaced atomically. Decryption goes to a private temporary file in the destination directory; mv replaces the old secret only after the new one is non-empty and has the intended mode. The trap removes a partial file after an error.

Mode 0444, not 0400. The containers that read these files do not all run as root, and a root-only file is unreadable to an application running as uid 1000. What protects the secret is the directory: /etc/homeserver/secrets is 0700 and owned by root, so nothing on the host can list or open it except root. Inside the container, the only thing there is the application it belongs to.

Install the script as root and run it before Compose:

$ sudo install -o root -g root -m 0755 render-secrets.sh /usr/local/sbin/render-secrets
$ sudo /usr/local/sbin/render-secrets
$ sudo docker compose -f /home/admin/homeserver/immich/compose.yaml up -d

Remove the chapter 7 env_file: .env reference from both Immich services, validate Compose, and delete the server's plaintext .env only after the file-backed deployment works. The mapping list is the secret ledger. When a later chapter adds restic, Authentik or monitoring credentials, it adds an encrypted source key and one render line, then reruns the same script. A later chapter must not merely assume a /run/secrets/... file appeared.

Why not just have Ansible write the secrets?Show me why

You can, and Ansible has its own encrypted-variable mechanism in Vault. It is a reasonable alternative and plenty of people are happy with it.

The reason this book uses SOPS is that the encrypted file is useful on its own, without Ansible, and readable by anything. You can decrypt it in a shell, in a script, on your laptop, in a future tool that does not exist yet. Ansible Vault encrypts the whole file, so you lose the readable-diff property, and it ties the secret to one tool.

The cost is one more binary to install. Take the trade or do not; what matters is that the plaintext is never at rest in the repository.

Rotation #

Secrets differ in how hard they are to change. Find out which is which before an emergency.

Cheap. An API token for an external service. Generate a new one there, put it in the encrypted file, redeploy. Nothing else knows or cares.

Moderate. An application's own signing or session key. Changing it usually logs everybody out, which is disruptive rather than dangerous.

Expensive. A database password. The secret exists in two places: your file and the database's own user record. Change one and the application stops being able to connect, with an error that looks like the database is down. You have to change both, in the right order, and take the application down in between:

Stop the application, then let psql prompt without putting the value in shell history:

$ sudo docker compose -f /home/admin/homeserver/immich/compose.yaml stop immich-server
$ sudo docker compose -f /home/admin/homeserver/immich/compose.yaml \
    exec database psql -U postgres -d postgres -c '\password postgres'

Update the encrypted file on the laptop, deploy it, run sudo /usr/local/sbin/render-secrets, and start the application again. Practise this once on test data so both copies remain in step.

Effectively impossible without a rebuild. A key used to encrypt data at rest, where the stored data can only be read with the original. Some applications have one. Find out whether yours does before you need to know.

You accidentally committed a plaintext password, then removed it in the next commit. Is it safe now?Show answer

No. Git keeps history, so the password is in the repository for anyone who clones it, and git log -p will show it. If the repository was pushed anywhere, assume the value is public.

Rewriting history is possible and does not help much, because copies may already exist. The only reliable answer is to rotate the secret: change the actual password so the exposed one is worthless. Cleaning history afterwards is tidying, not remediation.

This is why the rule is not to commit it in the first place, and why .gitignore goes in before the first commit rather than after the mistake.

The one you cannot encrypt #

Everything is encrypted except the key that decrypts it, which sits in plaintext at /etc/homeserver/age/keys.txt. That is the design. Every secret-management system ends in one secret that exists in the clear, because something has to start without a human present. Cloud providers move it into hardware; you have moved it into one root-owned file on a machine you control.

What it buys you: your entire configuration can go in git and be published tomorrow. What it does not: anyone with root on the server, or an unencrypted disk in their hands, has everything. That is the physical-security dependency the lockout annex in chapter 3 described.

Deciding what to do about my age key backup

Choose an off-machine recovery location for my age key

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

I have an age private key that decrypts all my server's secrets. I want to think through where a backup of it should live, given that it cannot only be inside the backups it protects, and that losing it means resetting every password.

Done when

  • docker inspect on your applications shows paths, not passwords
  • /etc/homeserver/secrets is 0700 and owned by root
  • Your compose files use secrets: and _FILE variables
  • secrets/*.env is encrypted, committed, and readable as a diff
  • .sops.yaml is committed and contains only the public recipient
  • The age private key is at /etc/homeserver/age/keys.txt, root-owned, 0600
  • A copy of that key exists somewhere off this machine
  • The temporary plaintext .env is deleted after its only value is moved to SOPS and Compose no longer references it
  • You have rotated one secret end to end and the application still works

What you picked up

  • An environment variable is readable through docker inspect, docker compose config, /proc, and anything that logs its own configuration.
  • File-backed secrets keep the value out of container metadata. They do not hide it from the application, and are not meant to.
  • SOPS encrypts values and leaves keys readable, so an encrypted file still produces a useful diff.
  • Commit the ciphertext and the public recipient. Never the private key.
  • Render to root-only files at deploy time, and never let plaintext rest in the repository.
  • Database passwords live in two places and rotating one without the other looks exactly like an outage.
  • A committed secret is compromised. Rotate it. Cleaning history does not undo it.
  • Every such system ends in one plaintext key. Yours is one root-owned file, and it needs a backup that is not inside your backups.

Settings

Your values

The book is written with placeholder names so it makes sense to everybody. Put your own in and every chapter, every command and every copy-paste prompt updates to match.

Nothing here is sent anywhere. It is saved in this browser, so it comes back next time. A different browser or a private window gets the placeholders again.

Live preview

$ ssh admin@192.168.1.20
$ sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp
$ sudo hostnamectl set-hostname homeserver
$ sudo timedatectl set-timezone Europe/Paris

Real commands from chapters 2, 3 and 4. They change as you type.

The account you log in as. Not root, and not necessarily the same name you use on your laptop.

Introduced in Chapter 2, Meet your server

The book's placeholder is admin

What the machine calls itself. You choose it, and it shows up in your shell prompt and your logs.

Introduced in Chapter 3, A safe front door

The book's placeholder is homeserver

The IP address your server has on your home network, from ip -brief addr.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.20

The address range and prefix shown by ip route or ip -brief addr, written in CIDR form. Copy the real prefix; do not guess /24.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.0/24

The address traffic goes to on its way out of your house, from ip route.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.1

In Region/City form, or Etc/UTC if you would rather read logs in UTC.

Introduced in Chapter 3, A safe front door

The book's placeholder is Europe/Paris

A registered name you control. Chapter 8 uses it for the LAN route; chapter 12 uses a separate private Tailscale name remotely.

Introduced in Chapter 8, One door, many rooms

The book's placeholder is example.com

The email identity allowed to administer the tagged server in your Tailscale policy.

Introduced in Chapter 10, Your own private network

The book's placeholder is you@example.com

The mailbox that should receive actionable home-server alerts.

Introduced in Chapter 14, Knowing it is alive

The book's placeholder is alerts@example.com

Once you save, the prose and the commands read with your names, the copy buttons copy your values, and the copy-paste prompts describe your machine accurately. That last one matters: an assistant told your network is 192.168.1.0/24 when it is not will send you chasing the wrong thing.

Anything you leave empty keeps the book's placeholder.