Skip to content

Secret Management

Overview

The Nextcloud operator supports referencing existing Kubernetes secrets for sensitive credentials instead of embedding them in the Nextcloud CR. This follows security best practices and integrates seamlessly with secret management tools like:

  • External Secrets Operator (ESO)
  • Sealed Secrets
  • HashiCorp Vault
  • AWS Secrets Manager / Azure Key Vault / Google Secret Manager
  • Manual secret creation

How It Works

Priority Order

For each credential type, the operator follows this priority:

  1. credentialsSecret — Reference to existing Kubernetes secret (highest priority)
  2. Inline values — Credentials defined directly in the CR
  3. Defaults — Built-in defaults (admin credentials only)

One exception, admin only (0.21.5)

A credentialsSecret naming the operator's own admin Secret (<instance>-nextcloud-admin) does not outrank an inline spec.admin.password — the inline password wins. See Changing a declared admin password below for why, and what the operator does with it. A credentialsSecret you supply keeps the priority above, for admin and every other section.

Benefits

  • Security: Credentials never stored in CRDs or Git
  • Separation of Concerns: Dev team manages apps, ops team manages secrets
  • GitOps Friendly: Nextcloud CRs can be in Git without exposing secrets
  • Integration: Works with any secret management tool
  • Flexibility: Mix and match — use secrets for some, inline for others

Supported Credential Types

1. Database Credentials

spec:
  database:
    type: postgresql
    credentialsSecret: my-db-credentials

Required secret keys: host, name, user, password

Optional secret keys: port (default: 5432), type (default: postgresql)

2. Redis Credentials

spec:
  redis:
    enabled: true
    credentialsSecret: my-redis-credentials

Required secret keys: host

Optional secret keys: port (default: 6379), password

3. S3 Credentials

spec:
  s3:
    enabled: true
    credentialsSecret: my-s3-credentials

Required secret keys: bucket, accessKey, secretKey

Optional secret keys: endpoint (default: s3.amazonaws.com), region (default: us-east-1)

4. Admin Credentials

spec:
  admin:
    credentialsSecret: my-admin-credentials

Required secret keys: username, password

Optional secret keys: email

Generated Admin Credentials (0.21.0+)

If you create an instance without declaring spec.admin.password or spec.admin.credentialsSecret, the operator generates one for you. As of 0.21.0, the generated password is never written back into the CR spec as plaintext:

  1. The operator generates a 32-byte URL-safe random password (or reuses the value already stored in the admin Secret, if one exists — a retry or re-create never rotates it).
  2. It writes that password into the <instance>-nextcloud-admin Secret — the same Secret the operator has always created, unchanged in name and keys. This Secret is the source of truth for a generated credential.
  3. Only after the Secret is confirmed created does it patch the CR: the whole spec.admin section is replaced with {credentialsSecret: <instance>-nextcloud-admin} (any other declared admin keys, e.g. email, are preserved; password is never present). It also stamps metadata.annotations["k8s.bnerd.com/admin-credential"] = "generated" so the CR is self-documenting about where the credential came from.
# What you apply:
spec:
  admin: {}   # or omitted entirely

# What the operator writes back:
spec:
  admin:
    credentialsSecret: my-instance-nextcloud-admin
metadata:
  annotations:
    k8s.bnerd.com/admin-credential: generated

Retrieve the generated password with:

kubectl get secret <instance>-nextcloud-admin -n <namespace> \
  -o jsonpath='{.data.nextcloud-password}' | base64 -d

Two sets of key names in the operator's own admin Secret

This Secret carries the credential under both nextcloud-username/nextcloud-password (the names the Helm chart reads, via nextcloud.existingSecret) and username/password (the names the credentialsSecret contract uses). Secrets written by operator versions before 0.21.0 have only the chart names, so the operator accepts those as a fallback for its own admin Secret — the one named <instance>-nextcloud-admin. A credentialsSecret you supply is unaffected and must use username/password as documented below; the chart key names are an operator-internal detail, not part of the external contract.

GitOps CRs: naming-convention fallback, zero spec writes

A CR managed by Flux (Kustomize or HelmRelease labels — the same _is_gitops_managed check used elsewhere in the operator) never gets a patch.spec write for admin credentials, for the same reason the operator never scrubs OIDC/mail/S3/database secrets on a GitOps CR: Flux would revert the operator's patch on its next sync, and if the reverted state has no password/credentialsSecret, the operator would generate a new password and try to apply it live — a rotation loop that resets the admin account on every Flux sync (this was rated a High-severity finding in the 0.20.0 security review, closed by this change).

Instead, credential resolution falls back to a naming convention: if spec.admin declares neither password nor credentialsSecret, the operator reads (and creates if absent) <instance>-nextcloud-admin directly — a read-only lookup, not a spec write. The GitOps CR's admin: {} (or no admin section at all) is valid and stable; nothing about it changes across reconciles.

admin: {} on 0.21.0–0.21.4

With the CRD default this release removes, the API server turned that exact shape into admin: {password: changeme} — and the operator then treated it as a declared password. Instances created that way have changeme in their <instance>-nextcloud-admin Secret and live. 0.21.5 fixes it in code as well as in the schema, but it does not rotate an already-provisioned password: check the Secret and rotate it yourself if you find the literal there.

Pre-existing behavior, documented: the admin Secret is always operator-owned

Even when you declare your own spec.admin.credentialsSecret pointing at a Secret you manage, the operator still mirrors the resolved credentials into its own <instance>-nextcloud-admin Secret (this predates 0.21.0 and is unchanged by it). Practically: your declared Secret is the one the operator reads from, but a second, operator-owned Secret with the same username/ password is also materialized and kept in sync — expect both to exist, and prefer reading from the one you declared if you need a single source of truth for automation.

Changing a declared admin password

Owned Secret vs. user-supplied Secret — the precedence rule (0.21.5). The operator's own <instance>-nextcloud-admin Secret is a materialization of the desired admin credential; it is not a source of truth. A Secret you supply is the opposite — the operator never writes it, so it is the source of truth. Hence:

spec.admin.credentialsSecret Non-empty spec.admin.password What is applied
<instance>-nextcloud-admin (operator-owned) yes the inline password — it is the newer intent
<instance>-nextcloud-admin (operator-owned) no / "" the Secret's value (the steady post-0.21.0 state)
a Secret you supply yes the Secret's value — unchanged contract, your ref keeps priority
a Secret you supply no the Secret's value
none yes the inline password
none no generated / naming-convention fallback (see above)

<instance>-nextcloud-admin is a reserved, operator-owned Secret name: the operator creates, rewrites and deletes it as it sees fit, and treats a spec that points at it as pointing at its own materialization rather than at an external source of truth. Never hand-manage a Secret under that name — put your own credentials in a Secret with any other name and reference that instead.

Why this matters in practice: after 0.21.0 a pool-provisioned instance's spec reads admin: {credentialsSecret: <instance>-nextcloud-admin, password: ""}. Declaring spec.admin.password — typically on the parent Nextcloud CR, which passes it straight through — then leaves both fields set on the instance. That happens through two routes, and both were affected:

  • at pool assignment, when you create a Nextcloud CR that already declares spec.admin.password: the assignment writes the parent's spec onto the spare as a merge patch, so the spare's own ref survives beside your password; and
  • later, when you add or change spec.admin.password on an already-assigned instance. Under plain ref-priority the operator would read its own, older generated password back out of its own Secret and conclude nothing had changed — so your declared password would never be applied. That was the #7866 regression, fixed in 0.21.5.

What the operator does with a declared password in that shape, in order:

  1. Resolves the credential to the inline value.
  2. Rewrites the <instance>-nextcloud-admin Secret with it. That Secret is replaced wholesale, so its SMTP keys are re-derived from spec.mail on every rewrite (the chart reads them from this same object via nextcloud.existingSecret) — they are not carried over from the old copy, and they are dropped if spec.mail.enabled is false.
  3. Applies it to the live instance (occ user:resetpassword, or occ user:add --group admin if the declared username doesn't exist yet) and transitions the AdminCredentialApplied condition. A declared spec.admin.username flows through the same path.
  4. Only then blanks the inline password back out of the spec, leaving the ref form {credentialsSecret: <instance>-nextcloud-admin, password: ""}. The order matters: if the live apply fails, the spec keeps your declared password so the next reconcile can retry it. The blank is conditional — it re-reads the live spec and only clears the exact value it just applied, so a password you change again while the first one is still being applied is never swallowed; it simply wins the next reconcile.

The change is picked up as soon as the spec lands — the instance's spec.admin field watcher fires on that very patch — with the periodic instance-status timer as the safety net; you do not need to wait for a long timer or trigger a manual reconcile.

Verifying it worked

Watch status.appliedAdminCredential (appliedAt advances, passwordHash changes), not the AdminCredentialApplied condition's lastTransitionTime: on an instance that already had a credential applied — every pool-provisioned one — the condition goes True → True and its transition time is preserved by design. See the operations runbook.

The literal changeme is never treated as a declared password

Up to 0.21.4 the NextcloudInstance CRD defaulted spec.admin.password to changeme. Kubernetes fills a schema default into every admin object that omits the key, so with that CRD installed the API server reports password: changeme for a spec that declares only a credentialsSecret — or for admin: {}. On 0.21.0–0.21.4 that literal became the instance's real admin password (see the security note in CHANGES.md for 0.21.5, and audit your <instance>-nextcloud-admin Secrets for it).

0.21.5 removes the default and, because the CRDs are vendored separately from the operator image, refuses to treat that exact literal as a declared password anywhere in the spec: not as a value that shadows the operator's own Secret, not as a reason to skip generating one, and never as a spec value handed to the live instance.

That does not un-do an instance already provisioned with it. If a previous version stored changeme in <instance>-nextcloud-admin, that Secret is the resolved credential, so it keeps being applied — rotate it yourself (see the warning under GitOps CRs below, and the security entry in CHANGES.md).

If you genuinely want changeme as your admin password (please don't), set it through a credentialsSecret you own.

GitOps-managed CRs

Step 4 is skipped for a Flux-managed CR (same _is_gitops_managed rule as everywhere else — Flux would revert the write). Steps 1-3 still happen, so the declared password is applied live; the plaintext simply stays in the CR, where Git already has it anyway.

The declared password comes back if the parent still declares it

Blanking the instance's inline password does not edit the parent Nextcloud CR. If the parent still declares spec.admin.password, the next spec passthrough pushes it back down. That is harmless: the credential is already live, so the apply step is a hash-match no-op — no occ exec, no Secret rewrite, no further spec write. To stop keeping the plaintext in the parent, remove spec.admin.password there once the condition reads AdminCredentialApplied=True; resolution then continues through the Secret.

In-place migration of pool-origin generated passwords (0.21.0)

Before 0.21.0, generated admin passwords were written into spec.admin.password as plaintext (in etcd, kubectl get -o yaml, backups, and GitOps diffs). On first reconcile after upgrading to 0.21.0, the operator migrates instances it can positively identify as operator-generated — pool-origin instances only (identified by the k8s.bnerd.com/pool label the pool handler stamps on every instance it creates):

  • Pool-origin instance, Secret value matches the spec's inline password (the expected case for every pool-created instance): the operator rewrites spec.admin to the ref form described above and emits one MigratedAdminCredential event. No live credential changes — the Secret already held this exact password, so the AdminCredentialApplied hash-gate sees no delta: no occ exec runs, no admin reset happens. This is a spec-hygiene migration only.
  • Pool-origin instance, Secret value does not match (drift — something edited the spec or Secret independently after creation): the operator does not migrate. Guessing which value is authoritative risks either discarding an intentional change or migrating the wrong password. A warning is logged (never the password value itself); resolve the drift manually, then a future reconcile will pick it up.
  • Non-pool instance with an inline password (a hand-authored or externally-provisioned CR — the ambiguous case, since the operator cannot tell a user-declared password from a generated one just by comparing it to the Secret, which mirrors every inline password regardless of origin): the spec is left untouched. A one-time InlineAdminPasswordDeprecated advisory event points at the credentialsSecret pattern; it fires once, gated by the k8s.bnerd.com/inline-admin-advisory: sent annotation, not on every reconcile. Scrubbing non-pool inline passwords automatically is explicitly out of scope here — see #7830 for the broader secretRef migration this defers to.
  • Already migrated / no admin password declared: no-op, no Secret read.
  • Pool-origin instance whose spec already declares a credentialsSecret (e.g. someone pointed the instance at an externally managed Secret after pool assignment but never cleared the now-stale inline password): the operator never migrates, regardless of whether the stale inline password happens to match the admin Secret's value. A declared credentialsSecret short-circuits migration in the same early gate as the "already migrated" case above — checked before any Secret read — so a stale inline password is never silently redirected to the operator's own <instance>-nextcloud-admin Secret name behind the user's declared ref.

Why the migrated spec shows password: "" rather than omitting the key entirely: a kopf patch.spec write is a Kubernetes JSON Merge Patch, where an omitted key leaves the live value unchanged — it does not delete it. Omitting password from the patch would silently leave the old plaintext password live in the spec forever while claiming to have migrated it away. The migration (and the 0.21.0 generation path generally) explicitly sets password: "" in the same patch that adds credentialsSecret, so the plaintext is actually cleared, not just hidden behind an unwritten key.

5. Mail/SMTP Credentials

spec:
  mail:
    enabled: true
    credentialsSecret: my-mail-credentials

Secret keys: fromAddress, domain, smtpHost, smtpPort, smtpSecure, smtpAuthType, smtpName, smtpPassword

Legacy key names accepted for the operator's own mail Secret (0.21.1+)

Mirrors the admin Secret's legacy-key fallback (see the "Two sets of key names" note under Generated Admin Credentials above) exactly: an operator-owned <instance>-nextcloud-mail Secret written before the 0.16.1 dual-key writer only carries the Helm chart's hyphenated key names (smtp-host, smtp-port, smtp-secure, smtp-authtype, smtp-name, smtp-password) rather than the plain contract keys above — the operator now accepts those as a fallback, but only for its own mail Secret (matched by naming convention). A credentialsSecret you supply must use the plain key names as documented above; the chart key names are an operator-internal detail, not part of the external contract.

6. OIDC Credentials

OIDC uses a different pattern — clientSecretRef references a single key in a secret. The recommended form is to use clientSecretRef directly:

spec:
  oidc:
    enabled: true
    providerName: keycloak
    clientId: nextcloud
    clientSecretRef:
      name: oidc-secret
      key: client-secret  # optional, defaults to "client-secret"
    discoveryUri: https://iam.example.com/realms/office/.well-known/openid-configuration

OIDC client secret auto-materialization

As a convenience for non-GitOps flows, the operator also accepts an inline clientSecret and automatically promotes it to an operator-owned Kubernetes Secret:

  1. On the first reconcile, the operator reads spec.oidc.clientSecret.
  2. It creates (or updates) an operator-owned Secret named <instance>-nextcloud-oidc in the instance namespace, with key client-secret.
  3. It scrubs the inline value from the CR spec (clientSecret → "") and writes back clientSecretRef pointing at the new Secret — so the plaintext value no longer lives in etcd or audit logs.
  4. All subsequent reconciles resolve the secret from clientSecretRef, never from the CR spec.

The materialized Secret name and key:

Field Value
Secret name <instance-name>-nextcloud-oidc
Secret key client-secret

The Secret is registered in status.secrets.oidc so it is garbage-collected when the instance is deleted.

The OIDCReady status condition reflects whether the provider was successfully configured:

status:
  secrets:
    oidc: my-instance-nextcloud-oidc
  conditions:
    - type: OIDCReady
      status: "True"
      reason: Configured
      message: "OIDC provider 'keycloak' configured successfully."

Possible reason values:

Reason Meaning
Configured Provider configured via occ user_oidc:provider
IncompleteConfig clientId or discoveryUri missing
SecretResolutionFailed Neither clientSecretRef nor inline clientSecret resolved
ConfigureFailed occ command returned non-zero

GitOps / Flux exception

If the NextcloudInstance CR carries Flux Kustomize labels (kustomize.toolkit.fluxcd.io/* or helm.toolkit.fluxcd.io/*), the spec scrub is skipped. Flux would immediately revert any operator-applied spec change from its git source, causing an endless patch/revert loop. The operator-owned Secret is still created and used to configure the provider, but clientSecret is left in the spec. Manage the scrub on the Flux/git side instead.

Rotating the OIDC client secret

See Rotating the OIDC client secret in the runbook section below.

Automatic sanitization of inline mail / S3 / database credentials

(since v0.16.0) The same scrub-on-reconcile behaviour now applies to the other plaintext credential fields. When you supply a credential inline, the operator promotes it into the section's operator-owned Secret (which it already creates) and scrubs the spec to a credentialsSecret reference — so the plaintext no longer persists in the CR (kubectl get nextcloud -o yaml), etcd, or later audit reads.

Inline spec field Scrubbed into Secret Secret key(s) status.secrets
spec.mail.smtpPassword <instance>-nextcloud-mail smtp-password mail
spec.s3.accessKey / spec.s3.secretKey <instance>-nextcloud-s3 s3-access-key / s3-secret-key s3
spec.database.password <instance>-nextcloud-db db-password database

After the scrub, spec.<section>.credentialsSecret is set and the inline field is emptied; subsequent reconciles resolve from the Secret, which is garbage-collected with the instance.

Notes:

  • Accepted window: the scrub runs on the operator's first reconcile, so plaintext is briefly present between kubectl apply and that reconcile (and in the create-event audit log). To avoid the window entirely, provide a credentialsSecret yourself at creation time — the operator then never sees plaintext.
  • Managed PostgreSQL is never scrubbed. With spec.database.managed: true the password is operator-generated (the Percona Secret); there is no user plaintext to scrub.
  • GitOps / Flux exception applies identically — see above. On Flux-managed CRs the Secret is still used but the spec scrub is skipped (it would cause a patch/revert loop); sanitize on the git side instead.
  • Rotation: edit the operator-owned Secret directly (e.g. kubectl edit secret <instance>-nextcloud-mail); the operator reconciles it on the next pass.

Examples

All Credentials from Secrets

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: secure-nextcloud
spec:
  profile: production

  database:
    type: postgresql
    credentialsSecret: nextcloud-db-creds

  redis:
    enabled: true
    credentialsSecret: nextcloud-redis-creds

  s3:
    enabled: true
    credentialsSecret: nextcloud-s3-creds

  admin:
    credentialsSecret: nextcloud-admin-creds

Mixed Approach

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: mixed-nextcloud
spec:
  # Database from secret (production, managed by ops)
  database:
    type: postgresql
    credentialsSecret: prod-db-credentials

  # Redis inline (development, not sensitive)
  redis:
    enabled: true
    host: redis.default.svc
    port: 6379

  # Admin from secret
  admin:
    credentialsSecret: admin-credentials

With External Secrets Operator

# External Secret that syncs from Vault
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: nextcloud-db-credentials
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: nextcloud-db-credentials
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: secret/data/nextcloud/database

---
# Nextcloud references the synced secret
apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: my-nextcloud
spec:
  database:
    type: postgresql
    credentialsSecret: nextcloud-db-credentials

With Sealed Secrets

# Sealed Secret (safe to commit to Git)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: nextcloud-admin-credentials
spec:
  encryptedData:
    username: AgBvN3RI...
    password: AgCd8hP2...
  template:
    metadata:
      name: nextcloud-admin-credentials

---
# Nextcloud references the sealed secret
apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: my-nextcloud
spec:
  admin:
    credentialsSecret: nextcloud-admin-credentials

Creating Secrets

With kubectl

# Database
kubectl create secret generic nextcloud-db-creds \
  --from-literal=host=postgres.db.svc \
  --from-literal=port=5432 \
  --from-literal=name=nextcloud \
  --from-literal=user=nextcloud \
  --from-literal=password='db-password'

# Redis
kubectl create secret generic nextcloud-redis-creds \
  --from-literal=host=redis.cache.svc \
  --from-literal=port=6379 \
  --from-literal=password='redis-password'

# S3
kubectl create secret generic nextcloud-s3-creds \
  --from-literal=bucket=my-bucket \
  --from-literal=endpoint=s3.amazonaws.com \
  --from-literal=region=us-east-1 \
  --from-literal=accessKey='AKIA...' \
  --from-literal=secretKey='secret...'

# Admin
kubectl create secret generic nextcloud-admin-creds \
  --from-literal=username=admin \
  --from-literal=password='admin-password' \
  --from-literal=email=admin@example.com

With YAML Manifest

apiVersion: v1
kind: Secret
metadata:
  name: nextcloud-db-credentials
type: Opaque
stringData:
  host: postgres.database.svc.cluster.local
  port: "5432"
  name: nextcloud
  user: nextcloud
  password: "super-secret-password"

Warning

Do not commit unencrypted Secret manifests to Git. Use Sealed Secrets, External Secrets Operator, or SOPS instead.

Error Handling

Secret Not Found

If you specify a credentialsSecret that doesn't exist, the instance will fail:

status:
  phase: Failed
  conditions:
    - type: SecretsReady
      status: "False"
      reason: "Failed"
      message: "Database credentialsSecret 'nonexistent-secret' specified but secret not found"

Missing Required Keys

If a secret exists but is missing required keys:

Secret default/my-db-creds is missing required keys: password

Security Best Practices

  1. Always use credentialsSecret for production — never commit inline passwords
  2. Integrate with secret management tools — External Secrets Operator + Vault/AWS/Azure
  3. Restrict RBAC for secrets — limit who can read secrets
  4. Rotate credentials regularly — update secrets and trigger reconciliation
  5. Use separate secrets per environment — production and staging should use different credentials

Runbook: rotating the OIDC client secret

When you rotate the OIDC client secret at your identity provider, update the operator-owned Secret and trigger a reconcile:

  1. Update the operator-owned Secret with the new value:

    kubectl patch secret <instance>-nextcloud-oidc \
      -n <instance-namespace> \
      --type=json \
      -p '[{"op":"replace","path":"/data/client-secret","value":"'"$(echo -n '<new-secret>' | base64 -w0)"'"}]'
    

  2. Force-reconcile by applying the force-reconcile annotation:

    kubectl annotate nextcloudinstance <instance> \
      -n <instance-namespace> \
      k8s.bnerd.com/force-reconcile="$(date +%s)"
    

  3. Verify the OIDCReady condition is True:

    kubectl get nextcloudinstance <instance> -n <instance-namespace> \
      -o jsonpath='{.status.conditions[?(@.type=="OIDCReady")]}'
    

If the condition shows SecretResolutionFailed, check that the Secret exists in the correct namespace and contains the client-secret key.

Troubleshooting

# Check secret exists
kubectl get secret nextcloud-db-credentials

# List keys in secret
kubectl get secret nextcloud-db-credentials -o json | jq -r '.data | keys | .[]'

# Check operator logs for credential messages
kubectl logs -n nextcloud-operator-system -l app.kubernetes.io/name=nextcloud-operator | grep -i credential

# Inspect the OIDC-owned secret for an instance
kubectl get secret <instance>-nextcloud-oidc -n <instance-namespace>

# Check OIDC condition
kubectl get nextcloudinstance <instance> -n <instance-namespace> \
  -o jsonpath='{.status.conditions[?(@.type=="OIDCReady")]}'