SOPS + Age and Sealed Secrets

Lobsters Hottest Tools

Summary

A blog post explaining how to combine SOPS with Age for encrypting secrets outside the cluster and Bitnami Sealed Secrets for in-cluster decryption, enabling a GitOps workflow for Kubernetes.

<p><a href="https://lobste.rs/s/72alqa/sops_age_sealed_secrets">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 05/31/26, 04:21 PM

# SOPS + Age and Sealed Secrets Source: [https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/) ## [Modern Kubernetes homelab](https://www.jonashietala.se/series/kube-homelab) When I’ve read other series about Kubernetes and reach the secrets section my eyes glaze over\. I can’t help myself; I want to read about the fun stuff\. Secrets are necessary to be sure, but it’s a little boring… But if I want to do proper GitOps I need to manage secrets \(and to document the process\)\. The sooner I set it up the better\. ## [In and outside cluster](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#In-and-outside-cluster) Kubernetes has different solutions for secrets management\. Of particular note is[Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets)which creates files that are safe to commit to git and Kubernetes decrypts them in\-cluster\. This is pretty great but has one big drawback: it can only manage secrets inside Kubernetes\. It cannot be used to encrypt things like the`talosconfig`or the Proxmox password Terraform uses\. That’s why I’ll also use[SOPS \+ Age](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#SOPS-Age), which allows us to encrypt whatever file we want\. The idea is to use SOPS \+ Age to manage the bootstrapping secrets and let Sealed Secrets take over when[ArgoCD](https://argo-cd.readthedocs.io/en/stable/)is up\. This way there’s only one private key I need to manage and the rest is available from the git repo\. ## [SOPS \+ Age](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#SOPS-Age) First, we need to install`sops`and`age`locally \(I found them in my package manager\)\. Then we can generate our private key: ``` mkdir -p ~/.config/sops/age age-keygen -o ~/.config/sops/age/keys.txt ``` You don’t want to lose this key, store it somewhere safe\. I stored it in[Bitwarden](https://bitwarden.com/)\(although I’m migrating to[Vaultwarden](https://github.com/dani-garcia/vaultwarden)hosted in\-cluster, which is a bit weird as keeping the key there risks a lock\-out\)\. You then need a`\.sops\.yaml`that describes the files to encrypt/decrypt\. For example, this is an entry for`talosconfig\.yaml`: ``` creation_rules: - path_regex: infrastructure/talosconfig(\.encrypted)?.yaml$ age: age1rrkgd5yza053qk9m8lp0ww39apdarz7w0rjyq85493g8l9gufgnq9cehzx encrypted_regex: '^(ca|crt|key)$' ``` \(`age`contains the public key, safe to share\.\) With`encrypted\_regex`you can limit encryption to certain fields; if you leave it out you’ll encrypt the entire file\. Then to encrypt and decrypt`talosconfig\.yaml`[we generated in a previous post](https://www.jonashietala.se/blog/2026/05/22/talos_linux_on_proxmox_with_terraform/#Generating-config-files): ``` sops --encrypt talosconfig.yaml > talosconfig.encrypted.yaml sops --decrypt talosconfig.encrypted.yaml > talosconfig.yaml ``` `talosconfig\.encrypted\.yaml`is safe to commit to git but the cleartext file`talosconfig\.yaml`should be added to`\.gitignore`\. ### [Just commands](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Just-commands) It won’t take long for me to forget these commands so I’ll add them to[Just](https://just.systems/man/en/introduction.html)\. These recipes will take care of the[secrets we’ve handled so far](https://www.jonashietala.se/blog/2026/05/22/talos_linux_on_proxmox_with_terraform/#Generating-config-files)and the cleartext files \(`talosconfig`,`kubeconfig`,`secrets\.auto\.tfvars`,`terraform\.tfstate`\) should allow us to regain cluster control, or to bootstrap the cluster anew from the git repo and the sops key\. ``` [doc("Decrypt required files committed to git")] decrypt_required: just secrets decrypt_cluster_config just secrets decrypt_terraform_secrets just secrets decrypt_terraform_state [doc("Encrypt talosconfig and kubeconfig")] [working-directory('../infrastructure')] encrypt_cluster_config: sops --encrypt kubeconfig.yaml > kubeconfig.encrypted.yaml sops --encrypt talosconfig.yaml > talosconfig.encrypted.yaml [doc("Decrypt talosconfig and kubeconfig")] [working-directory('../infrastructure')] decrypt_cluster_config: sops --decrypt talosconfig.encrypted.yaml > talosconfig.yaml sops --decrypt kubeconfig.encrypted.yaml > kubeconfig.yaml chmod 600 talosconfig.yaml kubeconfig.yaml [doc("Encrypt secrets.auto.tfvars")] [working-directory('../infrastructure')] encrypt_terraform_secrets: sops --encrypt secrets.auto.tfvars > secrets.auto.encrypted.tfvars [doc("Decrypt secrets.auto.tfvars")] [working-directory('../infrastructure')] decrypt_terraform_secrets: sops --decrypt secrets.auto.encrypted.tfvars > secrets.auto.tfvars chmod 600 secrets.auto.tfvars [doc("Encrypt terraform.tfstate")] [working-directory('../infrastructure')] encrypt_terraform_state: sops --encrypt --input-type json --output-type json terraform.tfstate > terraform.encrypted.tfstate [doc("Decrypt terraform.tfstate")] [working-directory('../infrastructure')] decrypt_terraform_state: # If the encrypted state doesn't exist yet (fresh repo), skip silently. test -f terraform.encrypted.tfstate || exit 0 sops --decrypt --input-type json --output-type json terraform.encrypted.tfstate > terraform.tfstate chmod 600 terraform.tfstate ``` I also added encryption to`create\_cluster\_config`bootstrap command, to make it harder for me to forget to add them to the repo: ``` [doc("Create talosconfig and kubeconfig")] [working-directory('../infrastructure')] create_cluster_config: terraform output -raw talosconfig > talosconfig.yaml terraform output -raw kubeconfig > kubeconfig.yaml just secrets::encrypt_cluster_config ``` And for the terraform state too \(run with`just tf::apply`instead of a plain`terraform apply`\): ``` [doc("terraform apply, then re-encrypt state (encrypts even on failure)")] [working-directory('../infrastructure')] apply *args: #!/usr/bin/env bash set -e trap 'just secrets::encrypt_terraform_state' EXIT terraform apply {{args}} [doc("terraform destroy, then re-encrypt state (encrypts even on failure)")] [working-directory('../infrastructure')] destroy *args: #!/usr/bin/env bash set -e trap 'just secrets::encrypt_terraform_state' EXIT terraform destroy {{args}} ``` ## [Sealed Secrets](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Sealed-Secrets) Let’s move on to sealed secrets\. There are more setup steps than with SOPS \+ Age but it’s not*that*bad\. ### [Installation](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Installation) I’ll install the sealed secrets controller using helm: ``` helm install sealed-secrets \ --repo https://bitnami-labs.github.io/sealed-secrets \ sealed-secrets \ --version 2.16.2 \ --namespace sealed-secrets \ --create-namespace \ --set fullnameOverride=sealed-secrets-controller # Wait for it to deploy kubectl rollout status deployment/sealed-secrets-controller -n sealed-secrets ``` To create secrets on the client we also need the`kubeseal`command\. It wasn’t available on the Void Linux package manager, so let’s do the hard way: ``` set -x KUBESEAL_VERSION '0.36.1' curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v$KUBESEAL_VERSION/kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz" tar -xvzf kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz kubeseal sudo install -m 755 kubeseal /usr/local/bin/kubeseal rm kubeseal-$KUBESEAL_VERSION-linux-amd64.tar.gz ``` ### [Create a secret](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Create-a-secret) To create a secret we can either use the cluster \(needs an active connection\) or offline via a certificate\. I prefer the certificate simply because you need to pass fewer arguments \(`\-\-cert`vs`\-\-controller\-name`and`\-\-controller\-namespace`\)\. Here’s how to fetch the certificate: ``` kubeseal --fetch-cert \ --controller-name=sealed-secrets-controller \ --controller-namespace=sealed-secrets \ > infrastructure/sealed-secrets-cert.pem ``` \(It’s a public key, safe to commit to git\.\) And this is how we can use it to generate a secret`my\-secret`with the two fields`username`and`password`: ``` kubectl create secret generic my-secret \ --namespace some-namespace \ --from-literal=username="user" \ --from-literal=password="password1" \ --dry-run=client -o yaml \ | kubeseal --cert infrastructure/sealed-secrets-cert.pem \ --format yaml \ > gitops/apps/myapp/my-secret.yaml ``` \(There might be other ways to do this\. You can generate json files for example, but this works and I don’t care to do research\.\) It will be stored in`gitops/apps/myapp/my\-secret\.yaml`that we can apply: ``` kubectl apply -f gitops/apps/myapp/my-secret.yaml ``` In the future when we get our GitOps setup up the process is the same except we don’t`apply`the secret; just create, commit, and push and it’ll get applied automatically\. It may feel like a lot of effort, but it’s quite nice to work with in day\-to\-day operations\. The process to update a secret is exactly the same; update the file with new contents and reapply\. ### [View a secret](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#View-a-secret) Check that the secret has been applied: ``` kubectl get secret my-secret -n some-namespace -o yaml ``` The data fields`username`and`password`shown above will be base64 encoded\. Here’s how to print out the password in cleartext: ``` kubectl get secret my-secret -n some-namespace -o jsonpath='{.data.password}' | base64 -d ``` Or you can view the secrets in a dashboard such as[Headlamp](https://headlamp.dev/), which is arguably easier\. ### [Surviving a cluster reset](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Surviving-a-cluster-reset) There’s one gotcha to sealed secrets: when the controller is installed it will generate a new public/private key pair so all existing sealed secrets are invalidated\. We’d have to reseal all secrets after we reset the cluster, which is highly annoying\. We can circumvent this by exporting the private key, encrypt it with SOPS \+ Age, and store it in git\. Then during the bootstrap process we can import the private key to the controller, allowing it to reuse all existing sealed secrets\. First export the key and encrypt it so we can keep it in git \(gitignore`sealed\-secrets\-key\.yaml`\): ``` kubectl get secret -n sealed-secrets \ -l sealedsecrets.bitnami.com/sealed-secrets-key=active -o yaml \ > sealed-secrets-key.yaml sops --encrypt sealed-secrets-key.yaml > sealed-secrets-key.encrypted.yaml ``` This needs a`\.sops\.yaml`rule: ``` creation_rules: - path_regex: infrastructure/sealed-secrets-key(\.encrypted)?.yaml$ age: age1rrkgd5yza053qk9m8lp0ww39apdarz7w0rjyq85493g8l9gufgnq9cehzx ``` Then to import it we simply`apply`it: ``` kubectl apply -f sealed-secrets-key.yaml ``` ### [Just commands](https://www.jonashietala.se/blog/2026/05/31/sops_age_and_sealed_secrets/#Just-commands-1) We’ve added a few steps to the bootstrap process to setup the sealed secrets controller: ``` [doc("Bootstrap everything from zero")] full: just bootstrap::cluster just bootstrap::cilium just secrets::restore_sealed_secrets_private_key just bootstrap::sealed_secrets ``` ``` [doc("Restore sealed-secrets-key.yaml")] [working-directory('../infrastructure')] restore_sealed_secrets_private_key: # If the encrypted private key doesn't exist, skip the whole recipe. test -f sealed-secrets-key.encrypted.yaml || exit 0 just secrets::decrypt_sealed_secrets_private_key # Don't exit if namespace already exists. kubectl create namespace sealed-secrets || true # Restore the private key. kubectl apply -f sealed-secrets-key.yaml rm sealed-secrets-key.yaml # We may need to restart the controller, but it may not exist, which is fine. kubectl rollout restart deployment/sealed-secrets-controller -n sealed-secrets || true ``` I’ve tried to safeguard the recipe to not crash if we haven’t created a key or bootstrapped the controller yet\. ``` [doc("Install sealed secrets controller")] sealed_secrets: helm install sealed-secrets \ --repo https://bitnami-labs.github.io/sealed-secrets \ sealed-secrets \ --version 2.16.2 \ --namespace sealed-secrets \ --create-namespace \ --set fullnameOverride=sealed-secrets-controller # Wait for it to deploy kubectl rollout status deployment/sealed-secrets-controller -n sealed-secrets ``` And some extra management recipes: ``` [doc("Fetch sealed-secrets-cert.pem, necessary to encrypt secrets offline")] [working-directory('../infrastructure')] fetch_sealed_secrets_cert: kubeseal --fetch-cert \ --controller-name=sealed-secrets-controller \ --controller-namespace=sealed-secrets \ > sealed-secrets-cert.pem [doc("Fetch sealed-secrets-key.yaml")] [working-directory('../infrastructure')] fetch_sealed_secrets_private_key: kubectl get secret -n sealed-secrets -l sealedsecrets.bitnami.com/sealed-secrets-key=active -o yaml > sealed-secrets-key.yaml just secrets::encrypt_sealed_secrets_private_key rm sealed-secrets-key.yaml [doc("Encrypt sealed-secrets-key.yaml")] [working-directory('../infrastructure')] encrypt_sealed_secrets_private_key: sops --encrypt sealed-secrets-key.yaml > sealed-secrets-key.encrypted.yaml [doc("Decrypt sealed-secrets-key.yaml")] [working-directory('../infrastructure')] decrypt_sealed_secrets_private_key: sops --decrypt sealed-secrets-key.encrypted.yaml > sealed-secrets-key.yaml ``` With this we’re prepared to setup GitOps with[ArgoCD](https://argo-cd.readthedocs.io/en/stable/)in the next post\.

Similar Articles

NixOS and Secrets

Lobsters Hottest

A tutorial explaining secrets management options for NixOS, comparing tools like sops-nix, agenix, and ragenix, with practical examples of using sops-nix for encrypted secrets management.

Fooling around with encrypted reasoning blobs

Hacker News Top

The author explores encrypted reasoning blobs in LLM APIs from OpenAI and Anthropic, discussing how chain-of-thought data is encrypted and signed, and the security implications of tampering with those blocks.