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.
I'd kept the credential out of the public repository, but a rebuild would still require me to repeat that manual step.
I used SOPS with age to encrypt the manifests before committing them. Flux would decrypt and apply them. Linkding and cloudflared would still receive readable credentials at runtime, while the repository stored ciphertext.
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.
I keep this Secret outside the encrypted Git workflow. Otherwise, Flux would need the private key to decrypt the very manifest containing that key. The sops-age Secret remains a bootstrap requirement, with a separate backup of its private identity.
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 in 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
Decryption happens during reconciliation, before the application receives the Secret.
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 tunnel credential could now be recovered from Git. The age private identity was still required for bootstrap.
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.
Where the credentials are readable
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 protects the repository and handles decryption for GitOps delivery. Kubernetes datastore encryption and RBAC still need their own configuration. A dedicated external secret store would be another separate addition.
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
appsKustomization is configured to decrypt SOPS manifests during reconciliation. - Deleting
tunnel-credentialsproved that Flux can recreate it from encrypted desired state. - A replacement
cloudflaredpod 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.
For a rebuild, I still need the separately backed-up age identity. Once that's available to Flux, the repository contains the encrypted application credentials and the configuration needed to deliver them.