Posted on ::

In my practical guide to age, I covered the commands for encrypting and decrypting files. This post is about what happens when those commands become part of a real deployment.

Headcode has a small collection of secrets that the application needs to run. They include API keys, a database password, a Cloudflare Tunnel token, and two Google Cloud service-account keys. Rather than keep those files out of Git and add a separate secrets manager, I encrypt them with age and commit the ciphertext alongside the rest of the project.

That sounds like a small distinction, but it changes the deployment workflow. Git contains the files, their history, and their changes. It just cannot show the contents without one of the private keys.

What is in Git?

The encrypted files live in deploy/secrets/. There are four of them:

  • secrets.env.age, containing ordinary KEY=value pairs;
  • tunnel-token.age, containing the Cloudflare Tunnel token;
  • two encrypted Google Cloud service-account JSON files.

There is also a .age-recipients file. This contains the public keys that are allowed to decrypt the secrets. It is safe to commit because a public key does not give someone the ability to decrypt anything.

The private keys are kept by the operators who deploy Headcode. Mine stays on my machine and does not go into Git.

That private key is the important part. Anyone who gets a copy of it may be able to decrypt the files encrypted for its corresponding public key, so it needs to be protected like any other credential.

This is the basic model behind age: encrypt to one or more recipients, which are public keys, and decrypt with the matching identity, which is the private key. Each operator generates their own key pair with age-keygen, then adds their public key to .age-recipients.

Why keep encrypted secrets in the repository?

There are two common ways to handle deployment secrets.

The first is to keep them somewhere outside the repository and retrieve them during deployment. That might mean a hosted secrets manager, a password manager, or a secret store provided by the hosting platform. Those systems can be the right choice, particularly once a team has more people, environments, and access policies to manage. They also introduce another system to operate, back up, secure, and keep available when deploying.

For Headcode, I wanted to avoid that operational overhead. This is a small project with one or two operators, and I wanted the secret-handling setup to be straightforward and low-maintenance. I did not want to spend time maintaining a secrets service or password manager when the actual requirement was to encrypt a handful of files and deploy them to one machine.

The second approach is what I use for Headcode: encrypt the files before committing them. The encrypted files can then be reviewed, versioned, and deployed like any other part of the project. The deployment process only needs age, SSH, and the private identity already held by the operator.

The benefit is not that Git has somehow become a secrets manager. It has not. The benefit is that the deployment configuration has one source of history. If a secret changes, that change appears alongside the code or infrastructure change that required it. A new operator can see which encrypted files exist and how they fit into the deployment without needing access to a separate system first.

There is a trade-off. The repository now contains encrypted copies of the secrets, and old Git history may contain previous versions. Anyone with an authorised private key can decrypt those historical versions too. If a secret is compromised, rotating the current file is not enough on its own; the old value needs to be treated as compromised as well.

This approach suits a project with one or two operators. It is not a universal replacement for a secrets manager.

Deploying without writing plaintext locally

During deployment, the identity file on the operator's machine is used to decrypt each secret with age:

age -d -i <identity-file> deploy/secrets/secrets.env.age

The important part is what happens to that output. The plaintext is piped directly into an SSH connection to the target VM. It is not written to a temporary file on the operator's machine.

The overall shape of the process is:

age identity on the operator's machine
        |
        v
age decrypts ciphertext
        |
        v
SSH stream
        |
        v
permission-locked file on the VM

The deployment process handles the remote destination, ownership, permissions, and SELinux labels. The key detail is that the decrypted bytes go from age to SSH and then to the destination on the server. There is no intermediate plaintext file on the laptop.

On the VM, the resulting files are written to their final locations. For example, the environment file ends up at:

/etc/headcode/secrets.env

The file is then locked down with permissions such as 640, owned by root:headcode, and its SELinux labels are restored. The service can read it through its group membership, but it is not an ordinary world-readable configuration file.

This protects one part of the workflow: plaintext does not need to sit on the operator's disk. It does not mean the secret never exists in plaintext. The server needs the plaintext to run the application, so the file exists there after deployment.

From encrypted file to Go configuration

The environment file is not the application's final configuration. The Headcode service uses a YAML configuration file with placeholders for values that come from the environment.

For example, the configuration can contain values like these:

server:
  port: ${PORT:8080}

database:
  url: ${DATABASE_URL:postgres://localhost/headcode}

resend_api_key: ${RESEND_API_KEY}
login_throttle_pepper: ${LOGIN_THROTTLE_PEPPER}

The path from the encrypted file to the typed Go configuration is:

secrets.env.age
  -> age decrypts it during deployment
  -> /etc/headcode/secrets.env
  -> systemd reads EnvironmentFile
  -> process environment
  -> yamlcfg expands ${VAR} placeholders
  -> typed Go configuration struct

The Go application does not read each secret with a collection of direct os.Getenv() calls. Instead, the systemd unit contains:

EnvironmentFile=-/etc/headcode/secrets.env

When systemd starts the service, it reads the KEY=value pairs and adds them to the process environment. The yamlcfg package then expands placeholders such as ${RESEND_API_KEY} before the YAML is parsed into the application's typed configuration.

The :default form is useful for values that can sensibly have a local default:

${DATABASE_URL:postgres://localhost/headcode}

Secret-bearing values do not have defaults. If RESEND_API_KEY is missing, the configuration should fail rather than quietly start with an empty or fake credential.

This gives the application one configuration mechanism without putting secret values directly in config.yaml. The YAML describes which values it needs; the deployment environment supplies them.

Not everything belongs in the environment

The two Google Cloud service-account keys take a slightly different route. They are structured JSON documents, so I deploy them as whole files:

/etc/headcode/gcs-*-sa-key.json

The YAML configuration contains the path to each file rather than an environment-variable placeholder. This avoids squeezing a multi-line JSON document into an environment variable and makes the boundary clearer: simple scalar secrets use secrets.env, while credential documents remain files.

The files still go through the same encrypted-at-rest deployment process. The difference is only what happens after they reach the server.

Adding an operator or rotating a secret

Each operator has their own key pair. To add someone, I add their public key to .age-recipients and re-encrypt all four secret files for the expanded recipient list.

That means the new operator can decrypt the files without sharing an existing private key. It also means private keys do not need to move between people, which is the important part.

Rotating a secret is a separate operation. I replace the value in the local plaintext source, encrypt the file again for the current recipient list, commit the new ciphertext, and deploy it. The running service only sees the new value after it has been restarted and systemd has loaded the updated environment file.

If the private identity for an operator is lost, that operator can no longer decrypt the files. Other authorised operators can still deploy, and a replacement key can be added to the recipients file. This is one reason not to make a single private key the only route into the system.

Why not SOPS?

I did consider SOPS. SOPS can use age as its encryption backend and adds features that are useful for secret files, including encrypting individual values within a structured document and making some multi-key rotation workflows easier.

For Headcode, plain age is enough for now. The secrets are already separated into a small number of files, and the team is small. Encrypting the whole file keeps the process easy to inspect: the input is a normal file, the output is an age ciphertext, and the deployment script decrypts it immediately before sending it to the server.

That may change as the project grows. If more people need access, or if several services start sharing structured configuration, SOPS may become the better fit. I do not need to add that layer before the problem exists.

What this setup does and does not protect

This arrangement protects the repository from containing readable secret values and avoids leaving deployment plaintext on the operator's machine. It also gives me a straightforward way to version and review changes to the encrypted files.

It does not protect a running server from someone who can read /etc/headcode/secrets.env. It does not prevent a privileged user from inspecting the process environment. It does not securely erase the original plaintext files used to create the ciphertext. And it does not tell me whether a public key belongs to the person I think it belongs to; that still needs to be handled when keys are exchanged.

There is no magic involved. age protects the files between those points in the workflow. The server still needs access to the decrypted values, and the operator still needs to protect their identity file.

For Headcode, that is a reasonable boundary. The project has a small number of operators, a single deployment target, and a handful of files that need protecting. Encrypted files in Git give us a simple audit trail without introducing a separate service before we need one.