A Kubernetes operator that syncs SOPS-encrypted secrets from Git into Kubernetes Secret resources — without depending on Flux, Argo, or any other GitOps stack.
Contributing? See CONTRIBUTING.md for dev-setup (envtest + Kind e2e), CI mapping, and the conventional-commits rule.
isindir/sops-secrets-operator is the reference implementation in this space, but it deliberately does not pull from Git — it relies on Flux or Argo (or kubectl apply) to deliver encrypted CRs into the cluster. That works if you already run one of those stacks.
This operator bundles the Git-sync into the operator itself, so the workflow is:
- Encrypt a file with vanilla
sopsand push it to a git repo (or upload it to an HTTPS / S3 endpoint). - Apply a
GitRepositoryorObjectSource+ aSopsSecret/SopsSecretManifestCR. - The operator decrypts and produces a target
Secret.
No delivery tool required.
Five CRDs across two API versions in the sops.stuttgart-things.com group. SopsSecret and SopsSecretManifest store as v1alpha2; GitRepository, InlineSopsSecret still store as v1alpha1; ObjectSource is v1alpha2-only.
┌──────────────────┐
│ GitRepository │◄────┐
│ (source + auth) │ │ ┌──────────────────────┐ ┌──────────────┐
└──────────────────┘ ├──────│ SopsSecret │─────►│ Secret │
│ │ (flat-map → Secret) │ │ │
┌──────────────────┐ │ └──────────────────────┘ └──────────────┘
│ ObjectSource │◄────┤
│ (HTTPS / S3) │ │ ┌──────────────────────┐ ┌──────────────┐
└──────────────────┘ └──────│ SopsSecretManifest │─────►│ Secret │
│ (passthrough) │ │ │
└──────────────────────┘ └──────────────┘
┌──────────────────────┐ ┌──────────────┐
│ InlineSopsSecret │─────►│ Secret │
│ (inline payload) │ │ │
└──────────────────────┘ └──────────────┘
Consumers reference a source via spec.source.sourceRef:
source:
sourceRef:
kind: GitRepository # or ObjectSource
name: platform-secrets
path: prod/app/creds.enc.yamlGitRepository— connection to a Git repo: URL, branch or pinned revision, poll interval, and either HTTP basic or SSH auth.ObjectSource— connection to an HTTPS URL or S3-compatible bucket (MinIO / Ceph / R2 / AWS S3). HTTPS mode caches the object viaETag/If-None-Match; bucket mode validates reachability + auth and fetches per-key on read. Auth:bearer,basic(HTTPS);s3(access keys, bucket);nonefor public.SopsSecret— mapping mode: source file is a SOPS-encrypted flat key/value YAML.spec.data[]explicitly picks source keys and renames them into target Secretdatakeys. Unknown keys in the file are dropped; missing declared keys fail-closed.SopsSecretManifest— pass-through mode: source file is a SOPS-encryptedkind: Secretmanifest. The decrypted manifest is applied directly, but namespace is overridden authoritatively by the CR.InlineSopsSecret— no source: the SOPS-encrypted payload lives inside the CR (spec.encryptedYAML). Same Mapping / Manifest semantics viaspec.mode. Access control is RBAC on the CR itself — anyone who cancreate inlinesopssecretsin a namespace can decrypt anything the operator has keys for.
Both source-backed CRDs share their source's cache entry, so one source fed to many secrets is one clone (git) or one cached body (HTTPS) on disk.
# CRDs + RBAC + deployment
kubectl apply -k https://github.com/stuttgart-things/sops-secrets-operator/config/default?ref=mainThe deployment runs in namespace sops-secrets-operator-system with a service account scoped to the CRDs plus Secret read/write.
age-keygen -o age.agekey
# public key is printed; also stored as a comment inside age.agekeyCreate prod/app/creds.enc.yaml with plaintext:
database_url: postgres://app@db:5432/app
database_password: s3cret
api_token: xyzEncrypt in place:
sops --age age1yourPublicKeyHere --encrypt --in-place prod/app/creds.enc.yamlCommit and push to your git repo.
kubectl create namespace apps
# Git auth token (PAT for HTTPS, or SSH key — see samples)
kubectl -n apps create secret generic git-auth \
--from-literal=username=git \
--from-literal=password=ghp_yourToken
# Age decryption key
kubectl -n apps create secret generic sops-age-key \
--from-file=age.agekey=age.agekey
# GitRepository
cat <<EOF | kubectl apply -f -
apiVersion: sops.stuttgart-things.com/v1alpha1
kind: GitRepository
metadata:
name: platform-secrets
namespace: apps
spec:
url: https://github.com/your-org/secrets.git
branch: main # or pin a commit/tag with `revision: <sha>` (overrides branch)
interval: 5m
auth:
type: basic
secretRef:
name: git-auth
EOF
# SopsSecret — maps three keys out of the decrypted file
cat <<EOF | kubectl apply -f -
apiVersion: sops.stuttgart-things.com/v1alpha2
kind: SopsSecret
metadata:
name: app-creds
namespace: apps
spec:
source:
sourceRef:
kind: GitRepository
name: platform-secrets
path: prod/app/creds.enc.yaml
decryption:
keyRef:
name: sops-age-key
key: age.agekey
data:
- key: DATABASE_URL
from: database_url
- key: DATABASE_PASSWORD
from: database_password
- key: API_TOKEN
from: api_token
EOFTo back the same SopsSecret with an HTTPS ObjectSource instead of a git repo, swap kind: GitRepository for kind: ObjectSource and point name at an ObjectSource CR — see config/samples/sops_v1alpha2_objectsource.yaml.
kubectl -n apps get gitrepository,sopssecret
kubectl -n apps get secret app-creds -o yamlBoth CRs should show Applied=True in their status conditions, and the target Secret should carry the three declared keys plus operator-owned labels and annotations (managed-by, owner, content-hash, source-commit).
Use when your SOPS file is a flat key/value YAML and you want explicit control over what ends up in the target Secret (renaming, filtering).
- Source must be flat (no nested maps or lists).
spec.data[]declares every target key; unknown source keys are dropped.- Missing declared source keys fail the reconcile (fail-closed).
- Changing a
data[]entry between reconciles removes the old key and adds the new one.
Use when your SOPS file is an encrypted kind: Secret manifest (typically with --encrypted-regex '^(data|stringData)$').
- Strict whitelist on top-level and metadata fields (see SECURITY.md).
- Namespace override is authoritative — the CR decides where the Secret lands, not the decrypted file.
target.nameOverrideoptionally replacesmetadata.namefrom the manifest.
Use when you want to apply encrypted secrets without a git repo: ad-hoc/one-off secrets, air-gapped or GitOps-free clusters, or when RBAC on the CR is your delivery control.
spec.mode: Mapping— decrypted YAML is flat key/value, same contract asSopsSecret;spec.data[]required.spec.mode: Manifest— decrypted YAML is akind: Secret, same whitelist and namespace-authoritative rules asSopsSecretManifest.spec.encryptedYAMLholds the literal output ofsops --encrypt. The SOPS MAC is validated, so preserve the string byte-for-byte.- Caveats: encrypted blob lives in etcd (CR size ~1 MB); anyone with
create inlinesopssecretsin a namespace can decrypt anything the operator has keys for. See SECURITY.md.
The manager exposes Prometheus metrics at /metrics:
| Metric | Type | Labels |
|---|---|---|
sops_reconcile_total |
counter | kind, result |
sops_reconcile_errors_total |
counter | kind, stage (auth / fetch / decrypt / apply) |
sops_reconcile_duration_seconds |
histogram | kind, result |
sops_git_fetch_duration_seconds |
histogram | result |
sops_object_fetch_duration_seconds |
histogram | result |
sops_decrypt_duration_seconds |
histogram | result |
Each CR has status conditions you can watch with kubectl get -o jsonpath='{.status.conditions}':
GitRepository/ObjectSource:AuthResolved,SourceReadySopsSecret/SopsSecretManifest:SourceReady,Decrypted,AppliedInlineSopsSecret:Decrypted,Applied
Set the standard OTLP environment variables on the manager Pod and reconciles will emit one trace per CR with auth / fetch / decrypt / apply child spans:
env:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability:4317
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: grpc
- name: OTEL_SERVICE_NAME
value: sops-secrets-operatorWithout these vars set, a no-op tracer provider is installed and there is no overhead. Spans carry CR identifiers, sourceRef, the observed commit/ETag, and a content-hash fingerprint — never plaintext data or key material. See SECURITY.md for the full attribute list.
Runnable examples in config/samples/:
sops_v1alpha1_gitrepository.yaml— HTTP basic + SSH auth variantssops_v1alpha2_sopssecret.yaml— mapping mode againstGitRepositoryand againstObjectSourcesops_v1alpha1_sopssecret.yaml— mapping mode (legacy v1alpha1 shape)sops_v1alpha1_sopssecretmanifest.yaml— pass-through mode (legacy v1alpha1 shape)sops_v1alpha1_inlinesopssecret.yaml— inline payload, both Mapping and Manifest modessops_v1alpha2_objectsource.yaml—ObjectSourceHTTPS-bearer and S3 variants
Both source CRs and consumer CRs honor an annotation that bypasses the cached state on the next reconcile:
kubectl annotate gitrepository platform-secrets \
sops.stuttgart-things.com/reconcile-requested="$(date +%s)" --overwriteThe reconciler compares the annotation value to status.lastProcessedReconcileToken and runs the full pipeline when they differ. On a source CR (GitRepository / ObjectSource), this drops the local cache and re-fetches upstream regardless of commit/ETag — useful when a git push has just landed and you don't want to wait for the next poll. On a consumer CR (SopsSecret / SopsSecretManifest / InlineSopsSecret), it just records the honored token: the consumer pipeline is already idempotent and re-reads the cached source content on every reconcile, so to force a fresh upstream fetch annotate the source.
By default the operator refuses to overwrite a Secret it doesn't already own. A reconcile against an un-owned Secret fails with:
target Secret apps/app-creds exists but is not managed by this operator; set target.adopt=true to take over
To migrate an existing Secret under operator management, set target.adopt: true on the consumer CR (works on SopsSecret, SopsSecretManifest, and InlineSopsSecret):
spec:
target:
name: app-creds # optional; defaults to the CR name
adopt: true
# ...On the next reconcile the operator stamps its labels/annotations onto the existing Secret and replaces its data with the source-derived data. Adoption is opt-in per CR — there is no global override.
Every reconcile compares the source-derived content hash against sops.stuttgart-things.com/content-hash on the live Secret. If they differ — e.g. someone ran kubectl edit secret app-creds — the operator re-applies. Out-of-band edits do not survive the next reconcile; the only way to "freeze" data is to delete the owning CR (the finalizer cleans up the Secret too).
Every Secret produced by the operator carries:
| Key | Kind | Meaning |
|---|---|---|
sops.stuttgart-things.com/managed-by |
label | always sops-secrets-operator — used by adoption checks and kubectl get -l |
sops.stuttgart-things.com/owner |
annotation | <kind>/<namespace>/<name> of the owning CR |
sops.stuttgart-things.com/owner-uid |
annotation | UID of the owning CR — detects ownership transfer across CR kinds |
sops.stuttgart-things.com/content-hash |
annotation | SHA-256 of the applied data; powers drift detection |
sops.stuttgart-things.com/source-commit |
annotation | git commit (or ETag, for ObjectSource) the data was derived from |
List everything the operator currently manages in the cluster:
kubectl get secret -A -l sops.stuttgart-things.com/managed-by=sops-secrets-operatorSopsSecret and SopsSecretManifest switched their stored shape between v1alpha1 (source.repositoryRef.name) and v1alpha2 (source.sourceRef.{kind,name}). The CRDs declare spec.conversion.strategy: Webhook so v1alpha1 manifests still apply through the operator's auto-registered /convert handler.
The default install (kubectl apply -k config/default) wires this end-to-end via cert-manager: an Issuer + Certificate in config/certmanager/ issues the webhook serving cert into webhook-server-cert, and kustomize replacements stamp the cert-manager.io/inject-ca-from annotation onto both CRDs so the apiserver picks up the CA bundle. cert-manager must be installed in the cluster before applying the bundle (kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml).
If you cannot run cert-manager, either provision the webhook cert externally and patch the CRDs' spec.conversion.webhook.clientConfig.caBundle yourself, or stick to v1alpha2 manifests (which match the storage version and never go through /convert).
See SECURITY.md for the full threat model. Highlights:
- Namespace override on
SopsSecretManifestis authoritative (no git-controlled namespace escape). - SSH auth requires
knownHosts— no insecure-skip option. - Adoption of pre-existing un-owned Secrets is opt-in (
target.adopt: true). - SOPS MAC verification is preserved (integrity check on encrypted files is not disabled).
Requires Go 1.26+, kubebuilder, and kubectl.
make generate manifests # regenerate CRD YAML + zz_generated
make build # build the manager binary
make test # run unit + envtest (fetches envtest binaries)
make run # run the manager against the current kubecontextFor testing against a remote cluster (e.g. a shared k3s management cluster) instead of kubectl apply -k …, deploy a pinned release image with kustomize:
# Point at the target cluster
export KUBECONFIG=/path/to/your/kubeconfig
# Apply CRDs, RBAC, controller Deployment, webhook Service, and cert-manager Issuer/Certificate
make deploy IMG=ghcr.io/stuttgart-things/sops-secrets-operator:v0.7.2
# Verify rollout
kubectl -n sops-secrets-operator-system rollout status deploy/sops-secrets-operator-controller-manager
kubectl -n sops-secrets-operator-system get podsTear down with make undeploy (controller + webhook) and make uninstall (CRDs).
- cert-manager must be installed cluster-wide. The default kustomize overlay (
config/default) wires a self-signedIssuer+Certificateto serve the conversion webhook forSopsSecret/SopsSecretManifest(v1alpha1 ↔ v1beta1). - The node(s) must be able to pull from
ghcr.io. The published image is public. - Ensure no other controller is already managing the
sops.stuttgart-things.comCRDs in the cluster.
The controller is deployed without any age decryption key — apply yours after the rollout. Generate or reuse an age key, then create a Secret in the same namespace as the CRs that will consume it (the operator looks up decryption.keyRef in the CR's own namespace):
kubectl -n <crs-namespace> create secret generic sops-age \
--from-file=age.agekey=/path/to/age.agekeyReference it from each SopsSecret / SopsSecretManifest / InlineSopsSecret via:
spec:
decryption:
keyRef:
name: sops-age
key: age.agekeySee Quick start for the full end-to-end flow (key, encryption, GitRepository, SopsSecret).
make deploy runs kustomize build config/default | kubectl apply -f -. To override values without forking:
- Image / tag:
make deploy IMG=ghcr.io/<org>/sops-secrets-operator:<tag> - Namespace, replicas, resources, env: create an overlay that patches
config/manager/manager.yaml - Metrics / Prometheus / NetworkPolicy: uncomment the corresponding entries in
config/default/kustomization.yaml
To render the manifest without applying (useful for GitOps pipelines):
make build-installer IMG=ghcr.io/stuttgart-things/sops-secrets-operator:v0.7.2
# writes dist/install.yamlThe controllers are scaffolded with kubebuilder v4. Key packages:
internal/git/— go-git wrapper with revision pinning + safe cache directoryinternal/decrypt/— age-only SOPS decrypt with in-process serializationinternal/source/— cache registry shared across reconcilers (git + object sources)internal/object/— HTTPS (If-None-Match/ETag) and S3-compatible (minio-go) fetchers forObjectSourceinternal/transform/— pure helpers (flat-YAML parsing, manifest validation, content hashing)internal/keyresolve/— age key lookup from Secret refsinternal/controller/— five reconcilers (GitRepository,ObjectSource,SopsSecret,SopsSecretManifest,InlineSopsSecret)internal/metrics/— Prometheus counters/histograms
Apache-2.0 — see LICENSE.