Skip to content

Configuration Profiles

Overview

Profiles provide baseline configurations for different deployment scenarios, offering sane defaults for resources, probes, persistence, and other settings. This allows you to quickly deploy Nextcloud instances optimized for specific use cases without manually configuring every detail.

How Profiles Work

Profiles use a four-layer configuration system (last wins):

  1. Built-in profile defaults (lowest priority) — Baseline from production, testing, or development
  2. NextcloudProfile CRD — Custom profile that overrides or extends built-in profiles
  3. Spec fields (medium priority) — Your database, redis, s3, admin, mail configurations
  4. Helm values (highest priority) — helm.values overrides everything

This layering allows you to:

  • Start with sensible defaults from a profile
  • Customize via NextcloudProfile CRDs for organization standards
  • Add your infrastructure specifics (database credentials, etc.)
  • Fine-tune with custom Helm values when needed

Available Profiles

production

Purpose: Production-ready configuration with high availability, security hardening, and resource limits.

Key Features:

  • Replicas: 2 pods for high availability
  • Resources: 500m-2000m CPU, 512Mi-2Gi memory
  • Persistence: 20Gi storage
  • Probes: All probes enabled with production-safe timeouts
  • Database: External database required (internal disabled); managed PostgreSQL gets pgBouncer enabled (database.postgres.proxy.enabled: true)
  • Redis: HA replication topology (redis.architecture: replication, 3 replicas) when managed Redis is used
  • PHP: Optimized with opcache settings (512M memory limit)
  • Security: Pod security contexts and fsGroup set
  • Strategy: RollingUpdate with zero downtime

Example:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: nextcloud-prod
spec:
  profile: production
  database:
    type: postgresql
    credentialsSecret: db-creds

testing

Purpose: Testing and staging environments with moderate resources.

Key Features:

  • Replicas: 1 pod
  • Resources: 250m-1000m CPU, 256Mi-1Gi memory
  • Persistence: 10Gi storage
  • Database: External database by default (internal disabled)

Example:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: nextcloud-staging
spec:
  profile: testing
  database:
    type: postgresql
    credentialsSecret: staging-db-creds

development

Purpose: Local development with minimal resources and fast startup.

Key Features:

  • Replicas: 1 pod
  • Resources: 100m-500m CPU, 128Mi-512Mi memory
  • Persistence: 5Gi storage
  • Probes: Liveness disabled, minimal readiness (fast startup)
  • Database: Internal SQLite enabled (no external database needed)

Example:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: nextcloud-dev
spec:
  profile: development
  # That's it! Uses SQLite, minimal resources, default credentials

Profile Comparison

Setting production testing development
Replicas 2 1 1
CPU Request 500m 250m 100m
CPU Limit 2000m 1000m 500m
Memory Request 512Mi 256Mi 128Mi
Memory Limit 2Gi 1Gi 512Mi
Storage 20Gi 10Gi 5Gi
Liveness Probe 120s delay 60s delay Disabled
Readiness Probe 60s delay 30s delay 10s delay
Startup Probe 30s delay 10s delay Disabled
Internal DB No No Yes (SQLite)
Redis architecture replication (3 replicas) unset (standalone for new) unset (standalone for new)
pgBouncer (managed PG) enabled disabled disabled
PHP Memory 512M 256M 128M
Security Context Yes No No
Opcache Yes No No

Configuration Layering Examples

Profile + Database Override

spec:
  profile: production  # Get production defaults
  database:            # Override to use your database
    type: postgresql
    credentialsSecret: my-db-creds

Result: Production resource limits and probes + your PostgreSQL database

Profile + Helm Values Override

spec:
  profile: production
  database:
    credentialsSecret: my-db-creds
  helm:
    values:
      replicaCount: 5    # Override profile's 2 replicas
      resources:
        requests:
          cpu: "1000m"
          memory: "1Gi"
        limits:
          cpu: "4000m"
          memory: "4Gi"

Result: Custom replicas and resources, but keeps production probes and PHP configs

When Profiles Apply

Profile defaults reach an instance in two separate ways with two separate rules, and it's important not to conflate them: a one-time spec write-back (persists profile defaults into the instance's own spec, e.g. spec.database/spec.s3) and, since 0.21.1, an always-re-resolved Helm values render (what the operator actually renders into the HelmRelease).

The spec write-back is still once, at instance creation — not continuous, and unaffected by 0.21.1. The gate is status.appliedProfile: once the instance's very first reconcile has run and written that status field, every later reconcile — an ordinary spec-field update, a manual k8s.bnerd.com/reconcile annotation, or the periodic timer — skips re-merging the profile into the persisted spec, for as long as the instance exists. This is deliberate: profile configuration is meant to be sticky once an instance is running, so editing a shared NextcloudProfile (or an instance's spec.profile pointing at a different one) doesn't silently reconfigure every tenant using it the next time any of them happens to reconcile — the same "avoid surprise reconfiguration of running tenants" rationale that already governs assigned pool instances, which are likewise never recreated or reconfigured in place when the pool's profile changes (only unassigned spares are, and only if recreateOnProfileChange: true — see Pool Provisioning → Lifecycle Policies).

The Helm values render now always re-resolves the profile (0.21.1, review finding E-1). Before 0.21.1, rendering skipped re-resolving the profile entirely once status.appliedProfile was set, on the assumption that the spec write-back above had already copied every profile-derived value where the renderer would find it again. That assumption held for the common case but not for instances the write-back never ran for — see below. 0.21.1 removed the skip: the profile now resolves fresh on every render. In practice, because an instance's own declared values (spec.helm.values above all) are still applied last and still win:

  • A profile edit to a key the instance already has a value for — whether from the write-back or your own spec.helm.values/spec.<field> — still never propagates. The instance's existing value wins the merge exactly as before; re-resolving the profile underneath it changes nothing observable.
  • A key added to the profile after the instance was created — one the write-back never had a chance to capture — now renders, on the instance's first reconcile after upgrading to 0.21.1 and on every render after that. Previously it would have silently never appeared.
  • Instances whose spec write-back never ran at all — created before operator v0.10.2, or GitOps-managed, where the write-back is deliberately skipped by design (see Operations → Profile Propagation at Upgrade) — see the biggest change: all profile-derived Helm values that aren't otherwise overridden (replicaCount, securityContext, podSecurityContext, nextcloud.strategy, etc.) now render correctly, where before they silently never did.
  • Persisted spec fields (spec.database/spec.s3/etc., as opposed to the rendered Helm values derived from them) are untouched by any of the above — there is still no supported way to force a re-apply of those to an existing instance; the write-back remains create-time-only.
  • Only instances created after a profile edit get the edited value copied into their spec by the write-back; every existing instance's persisted spec fields stay exactly as materialized at creation, same as always.

Exception: managed-PostgreSQL pgBouncer tracks the live profile continuously

One field breaks the once-only rule: database.postgres.proxy (the managed-PostgreSQL pgBouncer configuration) is re-read from the live profile on every reconcile, not just at creation. This is deliberate — pgBouncer is HA-critical (a missing or wrong spec.proxy.pgBouncer block can crash the underlying Percona cluster; see Managed PostgreSQL) and it needs to self-heal for instances whose spec never correctly picked it up in the first place, including pre-0.20.0 instances and GitOps-managed instances (whose CR spec is deliberately never written back to by the operator — see Operations → Profile Propagation at Upgrade).

Practically: if a profile edit changes database.postgres.proxy.enabled and, say, an existing resources.requests.cpu value in the same update, an existing managed-PostgreSQL instance using that profile sees a genuinely split outcome — the live PerconaPGCluster's pgBouncer state changes on the next reconcile, but the rendered Helm resources value does not (the instance's own materialized value still wins that merge — see the Helm values render rule above). The split narrows for the two vintages 0.21.1 restores full rendering for (instances predating the spec write-back, and GitOps-managed ones): for those, a resources change does render alongside the pgBouncer change, since there's no materialized value overriding it.

One-time heal: spec.mail gap-fill for instances materialized before 0.20.0 (0.21.0+)

Mail configuration is Helm-values-only: spec.mail.fromAddress (and the rest of spec.mail) maps straight to the chart's nextcloud.mail.* values, which the image's smtp.config.php renders as pod env (MAIL_FROM_ADDRESS, MAIL_DOMAIN, SMTP_*) after config.php is loaded — so an occ config:system:set mail_from_address write is silently masked by the env value whenever SMTP env vars are present, which they always are once spec.mail is configured. There is no working occ-based path for this field; the only mechanism the chart actually honors is the rendered Helm value.

Before 0.20.0's merge-kernel fix (the "Behavior change in 0.20.0" section below), a partial spec.mail override — say, an instance or pool template that set domain/smtpHost but not fromAddress — caused the entire profile-provided mail block to be discarded, including fromAddress, at the instance's one-time creation-time materialization. 0.20.0 fixed the merge kernel for instances created from that point on, but it did not retroactively repair instances that had already materialized under the old, lossy merge: build_helm_values still skips profile re-resolution once status.appliedProfile is set (the same once-only rule as everywhere else in this section), so a pre-0.20.0 instance's spec.mail stayed permanently missing fromAddress with no convergence mechanism — the operator would never look at it again.

0.21.0 adds a narrow, one-time exception to close that gap, in addition to the pgBouncer exception above: on the periodic instance-status timer, for any instance with status.appliedProfile set and that is not GitOps-managed, the operator re-checks whether the profile's defaults.mail provides any key the instance's materialized spec.mail is still missing. If so, it writes the filled mail section back to spec.mail — instance-set keys always win, only genuine gaps get filled — and emits a MailConfigMigrated event recording which profile supplied the fill. This is a one-time convergence, not a continuous sync like pgBouncer: once spec.mail has been filled, the next tick's comparison finds nothing left to fill and makes no further write (idempotent). GitOps-managed instances are skipped entirely for the spec write — their Helm values are already recomputed live from the current profile + spec on every sync, so they converge without needing spec.mail touched at all.

Operational consequence: the write to spec.mail changes the rendered Helm values, which updates the instance's HelmRelease, which changes the chart's MAIL_FROM_ADDRESS/etc. env — so an affected instance's Nextcloud pod(s) restart once, the next time the operator's status timer runs after upgrading to 0.21.0. This is expected and is the entire point of the fix (the alternative, an occ-based write, would restart nothing but also fix nothing, per the masking behavior above) — see Operations → Profile Propagation at Upgrade for the fleet-wide convergence timeline.

fromAddress default-literal override (0.21.1)

The gap-fill above only fills a missing fromAddress key. Field verification of the 0.21.0 upgrade found the dominant affected-fleet shape wasn't a missing key at all: creation-era materialization for many instances had written the historic operator/chart default literal "nextcloud" into spec.mail.fromAddress as a present value — which "instance wins" correctly leaves alone, so the gap-fill never touches it.

0.21.1 adds a second, narrower heal that runs alongside the gap-fill on the same periodic instance-status timer: for a non-GitOps instance with an applied profile, when spec.mail.fromAddress is exactly the literal string "nextcloud" and the profile's defaults.mail.fromAddress declares a different value, the operator overwrites it.

Override rule: the literal "nextcloud" is treated as provably operator-authored — the only place the operator or chart ever writes it is the historical default fallback in build_helm_values, not something a real user would type unless they deliberately wanted it, the same justification as the 0.21.0 generated-password migration. Any other value in spec.mail.fromAddress, including a user who deliberately declared the literal "nextcloud" themselves, is never touched — that instance value always wins. This is an accepted, documented risk: the sentinel is indistinguishable from a genuine user choice of the same string, but the profile explicitly declaring something else is the same signal the gap-fill above already trusts.

Otherwise identical to the gap-fill's mechanism: one MailConfigMigrated event, idempotent (a healed value no longer matches the sentinel on the next tick), skipped for GitOps-managed instances, and the same pod restart once consequence — see Operational consequence above.

An instance with both a genuine gap and the sentinel takes two ticks, not one. The gap-fill and this heal run in the same if/elif on the instance-status timer — only one of them can fire per tick, in that order. If an instance is missing a key the gap-fill would fill (say spec.mail.domain was dropped by the same pre-0.20.0 lossy merge) and spec.mail.fromAddress still carries the sentinel literal, the first tick runs the gap-fill only (it finds something to fill, so the elif never evaluates) — domain gets filled, fromAddress stays "nextcloud" untouched, one MailConfigMigrated event. Only on the next tick, once the gap-fill finds nothing left to fill (domain is no longer missing), does the elif run the sentinel heal and fix fromAddress — a second MailConfigMigrated event, and a second pod restart. This is expected, not a bug: each write only ever touches spec.mail once per tick (single atomic patch), and the two mechanisms are deliberately kept as separate, independently-idempotent checks rather than merged into one, so a partial failure in one doesn't block the other from ever running.

Parent Nextcloud CR also heals now (0.21.2)

The 0.21.1 heal above runs on the NextcloudInstance — but Rails-created logical Nextcloud CRs can independently carry the same frozen literal default in their own spec.mail.fromAddress. Before 0.21.2, the Nextcloud handler's periodic status-sync timer (sync_status) copies the parent's spec to the assigned instance whenever it detects drift — so a stale parent literal got pushed straight back onto an instance the 0.21.1 heal had just converged, reverting it on the very next 30s tick. Observed on a production fleet: 35–37 MailConfigMigrated events per instance and continuous spec/HelmRelease churn on roughly 28 tenants.

0.21.2 mirrors the identical heal (same heal_historic_mail_from_address function, same override rule above — literal "nextcloud" and the profile actively declaring something else) on the parent Nextcloud CR itself, in the same sync_status tick and before the drift check runs, so a healed parent never pushes the stale value back onto an already-healed instance. Both layers now converge by construction:

  • Two-tick shape: on the first tick where drift is detected, the parent is healed (its own spec.mail.fromAddress is patched) and the drift push (if one still fires that same tick) carries the healed value, never the stale literal. Once both the parent and the instance carry the same fromAddress, no further fromAddress value churn occurs — a content-no-op drift PATCH can still fire on that same 30s cadence for other, unrelated materialized keys (a pre-existing, fleet-wide, out-of-scope-for-0.21.2 behavior — see the Helm Values Ownership internals below), so "the loop is closed" means no repeat heal and no repeat revert, not literally zero writes forever.
  • Two-event shape: a fleet instance affected by both bugs can see up to two MailConfigMigrated events total across its lifetime — one from the 0.21.1 NCI-side heal, one from the 0.21.2 parent-side heal — not per-tick repeats of either.
  • Why the refill's secret-mode guard doesn't apply here: refill_mail_defaults's mode-switch guard (documented above, under the gap-fill) exists because several mail keys are credential-governed once a credentialsSecret is set. fromAddress is never one of those keys — it's never resolved from a Secret, inline or otherwise — so the heal ignores that guard entirely and applies uniformly regardless of which credential mode the instance is in.
  • Profile resolution falls back to the assigned instance's own status.appliedProfile when the parent has no spec.profile of its own (0.21.2 review I-2) — pool mode and reference mode never set spec.profile on the parent; the profile is resolved via the pool template and recorded only on the instance. This fallback is what makes the heal fire at all — observed in a live fleet where no affected parent carried spec.profile.
  • A GitOps-managed parent can never be healed — its write is unconditionally skipped (Flux would just revert it) — so its spec.mail.fromAddress stays the stale literal forever if it was ever set that way. To stop that permanently-unheal-able parent from reverting an already-healed instance anyway, the drift push itself ignores the sentinel literal (0.21.2 review I-1): when pushing the parent's spec onto the instance, a fromAddress that is still exactly "nextcloud" is replaced with the instance's own current value before the push, treating the literal as "no opinion" rather than an instruction to revert. This only filters what gets pushed to the instance — it never writes anything back to the GitOps-managed parent itself.

How Defaults Merge

Profile defaults reach an instance through the four-layer precedence chain from How Profiles Work above (built-in profile → NextcloudProfile CRD → instance spec → helm.values), but how a given defaults.<key> combines with the instance's own spec.<key> depends on which bucket the key falls into. The operator classifies every top-level defaults key at profile-resolution time:

Bucket What it means Merge behavior
Spec-level One of s3, mail, database, redis, admin, ingress, oidc, version, image, apps, backups, maintenance, persistence, cronjob, livenessProbe, readinessProbe, startupProbe, resources, replicas, php, extraVolumes, extraVolumeMounts, hooks, placement, upgradePolicy — consumed as a NextcloudInstance spec field. Deep-merged, with one exception (hooks — see below). Where both the profile and the instance set the same key as an object, they merge field by field at every depth — the instance wins on conflicts, the profile fills in whatever the instance didn't set. If the instance's value is a scalar or a list instead, the instance's value wins outright (lists are never concatenated/merged).
Known Helm value Not spec-level, but a recognized top-level key in the upstream nextcloud Helm chart's values.yaml (e.g. hpa, nginx, metrics, securityContext, mariadb). Passed straight through as a raw Helm value — the operator doesn't interpret it.
Unknown Neither of the above — most likely a typo, or a key from a different chart version. Still passed through unchanged (nothing is ever dropped or rejected), but the operator emits a Kubernetes warning event on the consuming instance with reason UnknownProfileKey so you notice and can fix it.

A key present in both the spec-level and known-Helm-value lists (e.g. ingress, redis, resources, persistence, cronjob, livenessProbe, readinessProbe) always classifies as spec-level — its deep-merge semantics take precedence.

Exception: hooks replaces wholesale, never deep-merges. Every other spec-level key deep-merges field by field, but hooks follows the lifecycle-hooks cascade's own documented contract instead — "last source wins" (replace, not concatenate), see Commands → Lifecycle hooks → Cascade. If the instance sets spec.hooks at all, its value replaces the profile's hooks block entirely, even field by field within it (e.g. an instance setting only hooks.onEveryReady does not pick up the profile's hooks.onFirstReady — that field is simply gone, not merged in). The profile's hooks is only used when the instance doesn't set hooks at all. This is deliberate: deep-merging two independently authored onFirstReady/onAssignmentReady/onEveryReady lists would silently union two different lifecycle configs instead of letting the more specific source take over cleanly.

Merge behavior no longer depends on which resource references the profile: Nextcloud and standalone NextcloudInstance both go through the same deep-merge logic (this used to differ — see the behavior-change note below).

Example — a profile with defaults.ingress.tls.certManager: false and an instance that only sets ingress.host:

kind: NextcloudInstance   # or Nextcloud — merge behavior is now identical
spec:
  profile: byo-cert
  ingress:
    host: cloud.example.com
# Result: certManager: false and every other profile-provided ingress field
# (tls.secretName, className, trustedProxies, ...) survive; only host comes
# from the instance.

Behavior change in 0.20.0

Before 0.20.0, only apps and placement deep-merged; every other spec-level key was all-or-nothing — if the instance set any part of that key at all, the profile's entire value for that key was silently discarded, not merged (the example above used to lose certManager: false on a standalone NextcloudInstance, though not on a Nextcloud). This is fixed in 0.20.0: every spec-level key now deep-merges the same way, on every resource. This is a real, user-visible behavior change — an existing instance that partially overrides a spec-level key it shares with its profile will start receiving the profile's other fields for that key on its next reconcile after upgrading, which can change the rendered Helm values and trigger a rolling update. If you were relying on the old all-or-nothing behavior to fully replace a profile-provided block, see the caveat below.

Caveat: connection-shaped keys (s3, oidc, mail, database)

Most spec-level keys are independent toggles (ingress, resources, placement, …) where merging field-by-field is unambiguous. A few — s3, oidc, mail, database — describe one coherent external target (bucket + region + endpoint; issuer + client ID + discovery URL; SMTP host + port + auth). Deep-merging these the same generic way means a partial instance override only replaces the fields it names; the profile's other fields for that same block keep applying:

# Profile: defaults.s3 = {bucket: shared-bucket, endpoint: s3.eu-west.example.com, region: eu-west-1}
# Instance only wants a different endpoint:
spec:
  profile: my-s3-profile
  s3:
    endpoint: s3.eu-central.example.com
# Result: bucket and region still come from the profile -- the instance now
# points at a mix of an "eu-central" endpoint with an "eu-west" bucket/region,
# which may not be what was intended.

Rule of thumb: if you mean to fully redirect a connection-shaped block (s3, oidc, mail, database) to a different target, override every field that identifies that target, not just the one that changed.

Custom Profiles (NextcloudProfile CRD)

The operator supports custom profiles via the NextcloudProfile CRD, which is cluster-scoped. Custom profiles take precedence over built-in profiles of the same name.

Creating a Custom Profile

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudProfile
metadata:
  name: enterprise
spec:
  description: "Enterprise profile with HA and backups"
  defaults:
    replicaCount: 3
    resources:
      requests:
        cpu: "1000m"
        memory: "1Gi"
      limits:
        cpu: "4000m"
        memory: "4Gi"
    persistence:
      enabled: true
      size: "200Gi"
      storageClass: "fast-ssd"
    internalDatabase:
      enabled: false
    # Optional: set default container image for all instances using this profile
    image:
      registry: my-registry.example.com
      repository: nextcloud
      pullSecrets:
        - name: enterprise-registry
  helm:
    version: "5.0.0"

Custom Images

Profiles can set defaults.image to configure a custom container image for all instances using the profile. This is useful for organizations with private registries. See Version Management for the full image resolution priority.

Using a Custom Profile

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: my-instance
spec:
  profile: enterprise
  database:
    type: postgresql
    credentialsSecret: db-creds

Pinning the Nextcloud Version

spec.defaults accepts both Helm chart values and instance spec-level fields — see How Defaults Merge above for the full key-classification table and merge semantics. Setting a spec-level key in defaults pre-fills it on every instance using the profile; where both profile and instance set the same key, they deep-merge (instance wins conflicts, profile fills gaps).

The most common use case is pinning the Nextcloud version:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudProfile
metadata:
  name: nextcloud-31-pinned
spec:
  description: Pin all instances using this profile to the latest Nextcloud 31.x
  defaults:
    version: "31"            # Resolved via NextcloudVersionMap → latest 31.x
    replicaCount: 2

Then instances inherit the version automatically:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudInstance
metadata:
  name: tenant-a
spec:
  profile: nextcloud-31-pinned
  # no spec.version — picks up "31" from the profile

defaults.version follows the same resolution rules as spec.version -- exact versions, prefix shorthand, and aliases (latest, stable). To override on a single instance, set spec.version explicitly. See Version Management for the full version-resolution chain. A complete example lives at examples/profile-pinned-version.yaml.

Overriding Built-in Profiles

Create a NextcloudProfile with the same name as a built-in profile:

apiVersion: k8s.bnerd.com/v1alpha1
kind: NextcloudProfile
metadata:
  name: production           # Overrides built-in 'production'
spec:
  description: "Our customized production profile"
  defaults:
    replicaCount: 3          # Different from built-in (2)

Profile Management Notes

  • A profile edit to an already-materialized key never propagates to an existing instance's persisted spec or its rendered Helm values — profile defaults are written into the spec once, at instance creation, and nothing — not a spec-field update, not the k8s.bnerd.com/reconcile annotation (see the Operations & Annotations guide), not the periodic timer — re-applies them afterward. Since 0.21.1, a new key added to the profile does render on the next reconcile (it was never materialized, so nothing overrides it), and instances whose spec write-back never ran (pre-v0.10.2, or GitOps-managed) get the full profile rendered instead of silently missing it. See When Profiles Apply above for the full mechanism and the pgBouncer exception.
  • Don't delete in-use profiles: Instances will fail to reconcile until the profile is recreated or the profile field is changed.
  • Validation: Invalid Helm values in profiles will cause HelmRelease failures. Test profiles in a non-production environment first.

Troubleshooting

Profile Not Applied

Symptom: Resources don't match expected profile values

Check:

  1. Verify profile field: kubectl get nci <name> -o yaml | grep profile
  2. Check for helm.values overrides that might be taking precedence
  3. Look at generated HelmRelease: kubectl get helmrelease <name> -o yaml
  4. If the profile was edited after the instance was created, whether this is expected depends on which key: an edit to a key the instance already has a materialized value for is expected, not a bug — see When Profiles Apply: that value only applies once, at creation (except managed-PostgreSQL pgBouncer). kubectl get nci <name> -o jsonpath='{.status.appliedProfile}' non-empty confirms the instance is already materialized. But since 0.21.1, a key genuinely new to the profile since creation, or the profile as a whole for an instance whose spec write-back never ran (pre-v0.10.2 or GitOps-managed), should render on the next reconcile — if it doesn't, that's a real bug, not this expected behavior.

Profile Conflicts with Helm Values

Helm values have highest priority and will override profile defaults:

spec:
  profile: production  # Sets replicaCount: 2
  helm:
    values:
      replicaCount: 1  # This wins! Final result: 1 replica

Invalid Profile Name

Use one of: production, testing, development (case-sensitive, lowercase), or the name of a custom NextcloudProfile CRD.