Ciphertext In, Credentials Out

September 1, 2026

The previous phase left me with one deliberate gap in an otherwise GitOps-managed application.

Flux could recreate Linkding, its storage, its internal Service, the Cloudflare connector Deployment and the tunnel configuration. It could not recreate the tunnel credential. That Secret had been created directly with kubectl, which meant the running cluster knew something that Git did not.

Keeping the credential out of a public repository was correct. Depending on an undocumented, one-time command to rebuild the application was not.

This phase closes that gap with SOPS, age and Flux. The result is not “no Kubernetes Secrets.” Linkding and cloudflared still need readable credentials at runtime. The improvement is an encrypted, versioned source of desired state that Flux can decrypt and reconcile without exposing the values in Git.

Two keys with different jobs

I generated an age key pair for the cluster:

  • The public recipient encrypts data and is safe to keep in the repository.
  • The private identity decrypts data and must remain outside Git.

The repository-root .sops.yaml tells SOPS which files and fields to encrypt:

creation_rules:
  - path_regex: '(^|/).*-secret\.yaml$'
    encrypted_regex: '^(data|stringData)$'
    age: age1<PUBLIC-RECIPIENT>

The filename rule is intentionally narrower than “every YAML file.” It matches manifests deliberately named *-secret.yaml, whether they live at the repository root or under an application overlay. A file such as deployment.yaml is outside the rule, as is app-secret.yaml.backup.

The second expression limits encryption to data and stringData. Kubernetes still needs to identify the object by its readable apiVersion, kind and metadata; the sensitive values are the part that becomes ciphertext.

With that configuration in place, this shorter command is enough:

sops --encrypt --in-place path/to/application-secret.yaml

The resulting file remains recognisably a Kubernetes Secret, but its values now look like ENC[AES256_GCM,...] rather than reversible base64.

Bootstrapping the root of trust

Flux needs access to the age private identity before it can decrypt anything from Git. I created one bootstrap Secret in the flux-system namespace:

kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-file=age.agekey="<PATH-TO-PRIVATE-AGE-IDENTITY>"

The key name must end in .agekey so that Flux recognises the entry as an age identity. The private key is excluded from the repository and must be backed up separately; losing both the cluster copy and that backup would make the encrypted manifests unrecoverable.

This Secret is intentionally not encrypted by the Git repository it unlocks. Doing so would create a circular dependency: Flux would need the private key in order to decrypt the private key. In this design, sops-age is the cluster's root of trust and remains an explicit bootstrap requirement.

I then enabled Flux's SOPS decryption on the apps Kustomization in clusters/staging/apps.yaml:

spec:
  interval: 1m0s
  path: ./apps/staging
  prune: true
  decryption:
    provider: sops
    secretRef:
      name: sops-age

This belongs to the Flux custom resource—kustomize.toolkit.fluxcd.io—rather than an ordinary kustomize.config.k8s.io file. It tells the kustomize-controller to use SOPS and the referenced private key while reconciling the application manifests.

The delivery path is now:

Encrypted Secret manifest in Git
  -> Flux source-controller pulls the revision
  -> kustomize-controller builds the staging overlay
  -> SOPS decrypts the sensitive fields in memory
  -> Flux applies the Kubernetes Secret
  -> kubelet provides the value to the consuming container

Git stores the locked package. Flux has the key. The application receives the contents rather than the lock.

Moving the Cloudflare credential under GitOps

The existing tunnel-credentials Secret was working, but it had been created imperatively from the tunnel's JSON credential. I generated a manifest for the same Kubernetes object without applying it:

kubectl create secret generic tunnel-credentials \
  --namespace=linkding \
  --from-file=credentials.json="$HOME/.cloudflared/<TUNNEL-UUID>.json" \
  --dry-run=client \
  -o yaml > apps/staging/linkding/cloudflare-secret.yaml

--dry-run=client makes kubectl render YAML locally. The generated data.credentials.json value is base64, which Kubernetes explicitly treats as encoding rather than encryption, so I encrypted the file before it went anywhere near Git:

sops --encrypt --in-place \
  apps/staging/linkding/cloudflare-secret.yaml

The staging Kustomization now includes both the Cloudflare workload and its encrypted credential declaration:

resources:
  - ../../base/linkding/
  - cloudflare.yaml
  - cloudflare-secret.yaml

There were not two live Secrets competing for the same pods. Kubernetes identifies the object by its kind, namespace and name:

Secret / linkding / tunnel-credentials

The old and new workflows described the same object. Initially, kubectl created it directly. After reconciliation, the encrypted Git manifest became its desired source and Flux managed the live Secret.

That distinction matters more than the YAML filename. cloudflare-secret.yaml is how I organise the repository; metadata.name: tunnel-credentials is the identity Kubernetes understands.

Deleting it on purpose

A successful reconciliation showed that Flux accepted the encrypted manifest, but I wanted stronger evidence than a green status.

I recorded the live Secret's UID, deleted the Secret from the cluster and requested an immediate source refresh and reconciliation:

kubectl delete secret tunnel-credentials -n linkding

flux reconcile kustomization apps --with-source

Flux detected that the declared object was missing, decrypted cloudflare-secret.yaml and recreated Secret/linkding/tunnel-credentials. The replacement had a new UID, proving that I was looking at a new Kubernetes object rather than the original one surviving unnoticed.

I then replaced a cloudflared pod. The new pod reached Running and its description showed the recovered Secret and existing ConfigMap mounted independently:

Volumes:
  creds:
    Type:        Secret
    SecretName:  tunnel-credentials
    Optional:    false
  config:
    Type:        ConfigMap
    Name:        cloudflared
    Optional:    false

Finally, https://linkding.joshlabs.me remained available. That completed the useful version of the test:

Git ciphertext
  -> Flux decryption
  -> recreated Kubernetes Secret
  -> newly scheduled connector
  -> authenticated Cloudflare Tunnel
  -> working public application

The manual dependency was gone.

Automating Linkding's initial administrator

There was one more imperative step in the application build. Linkding's first superuser had originally been created by executing Django's management command inside a running pod. It worked, but it required an operator to wait for the container, open a shell and complete an interactive workflow.

Linkding supports two startup variables for initial provisioning:

LD_SUPERUSER_NAME
LD_SUPERUSER_PASSWORD

I created a second SOPS-managed Secret containing those exact keys. Rather than typing the values into export NAME=value commands, I read them interactively into unexported zsh variables:

read -r "LINKDING_ADMIN_NAME?Linkding admin username: "
read -rs "LINKDING_ADMIN_PASSWORD?Linkding admin password: "
printf '\n'

read keeps the entered values out of shell history, while -s prevents the password from being echoed. This reduces exposure rather than eliminating it: shell expansion still passes the values to kubectl, and the generated manifest remains base64-readable until SOPS encrypts it.

I generated the Secret manifest locally:

kubectl create secret generic linkding-secret \
  --namespace=linkding \
  --from-literal=LD_SUPERUSER_NAME="$LINKDING_ADMIN_NAME" \
  --from-literal=LD_SUPERUSER_PASSWORD="$LINKDING_ADMIN_PASSWORD" \
  --dry-run=client \
  -o yaml > apps/staging/linkding/linkding-secret.yaml

Once kubectl had rendered the file, the temporary shell variables were no longer needed:

unset LINKDING_ADMIN_NAME LINKDING_ADMIN_PASSWORD

sops --encrypt --in-place \
  apps/staging/linkding/linkding-secret.yaml

I added linkding-secret.yaml to the staging resources and updated the base Deployment to import every key from that Secret:

containers:
  - name: linkding
    image: sissbruecker/linkding:1.46.2
    env:
      - name: LD_DISABLE_BACKGROUND_TASKS
        value: "True"
    envFrom:
      - secretRef:
          name: linkding-secret

Because the Secret keys already use Linkding's expected variable names, envFrom avoids duplicating each mapping in the Deployment. The base declares a dependency on a stable Secret name, while the staging overlay supplies the environment-specific encrypted values.

The change was committed as:

feat: automate Linkding superuser provisioning

Flux reconciled the new revision, and the Deployment change produced a replacement Linkding pod. Its description confirmed the source without displaying either credential:

Environment Variables from:
  linkding-secret  Secret  Optional: false

Logging into the public Linkding instance with the provisioned account completed the test.

This is initial provisioning rather than continuous password management. Linkding creates the superuser only when the configured username does not already exist, and Kubernetes does not inject changed Secret environment values into an already-running container. Password rotation therefore remains a separate operational workflow.

What SOPS does—and what it does not

SOPS protects the repository copy of each Secret. It does not make the live application consume ciphertext.

At different points in the flow, the same logical credential has different protection:

MacBook input                 readable
temporary Secret manifest    base64; readable and not encrypted
GitHub manifest              SOPS ciphertext
Flux reconciliation          decrypted in controller memory
Kubernetes Secret            live cluster object
container environment/file   readable by the intended process

That final readable form is unavoidable: cloudflared needs the tunnel JSON, and Linkding needs the username and password. Security comes from controlling where decryption happens, which identities may access the live Secret and which container receives it.

SOPS is also separate from Kubernetes datastore encryption, RBAC and external secret stores. It solves repository confidentiality and GitOps delivery. Encrypting live Secrets at rest inside K3s, restricting API access and eventually integrating a dedicated secret manager are additional controls rather than features implied by SOPS.

At this checkpoint:

  • The repository contains only SOPS-encrypted Cloudflare and Linkding credential values.
  • The age public recipient is available to contributors through .sops.yaml; the private identity remains outside Git.
  • Flux's apps Kustomization is configured to decrypt SOPS manifests during reconciliation.
  • Deleting tunnel-credentials proved that Flux can recreate it from encrypted desired state.
  • A replacement cloudflared pod mounted the recovered credential and maintained the public tunnel.
  • Linkding receives its initial superuser variables from an encrypted, Flux-managed Secret through envFrom.
  • The automatically provisioned login works at the public application endpoint.

The cluster is now materially more reproducible. Git knows that the credentials must exist, Flux knows how to deliver them, and neither GitHub nor the public repository has the key required to read them.