Repository: cloudnative-pg/cloudnative-pg

Issue:
[Feature]: Dynamic loading of PostgreSQL extensions
### Is there an existing issue already for this feature request/idea?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new feature request to be evaluated.

### What problem is this feature going to solve? Why should it be added?

Currently, extensions must be part of the base image of the operand, requiring pre-determination of the entire content of the Postgres image and preventing dynamic loading of extensions.

The reason is detailed in this blog article that I wrote: https://www.gabrielebartolini.it/articles/2025/03/the-immutable-future-of-postgresql-extensions-in-kubernetes-with-cloudnativepg/

### Describe the solution you'd like

Following the exploration work done in #6546 and the PostgreSQL 18 feature for `extension_control_path` we contributed to (see https://github.com/postgres/postgres/commit/4f7f7b0375854e2f89876473405a8f21c95012af), we should enable dynamic loading of extensions via the new `ImageVolume` feature introduced in Kubernetes 1.33. This guarantees immutability of container images.

Rollout is required by image volumes.

This feature will only be available with Postgres 18 and Kubernetes 1.33+. Also, extensions must be distributed as self-contained images - this is part of another initiative, outside CloudNativePG.

Image catalogs must be considered as well.

### Describe alternatives you've considered

We have considered mutable solutions (use a mutable volume to install needed extensions), but we preferred to contribute to Postgres improvements and exploit innovation in Kubernetes with Image Volumes.

### Additional context

See the blog article cited above.

### Backport?

No

### Are you willing to actively contribute to this feature?

Yes

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Discussion:
@gbartolini: My initial (unbinding) proposal is to enable the definition of extension images at the following two layers:
 
- Image Catalog (either global or local in the namespace)
- Directly in the extension definition (`.spec.postgresql.extension`), overriding the above when defined

The following scenarios could provide some ideas and help elaborate on the solution to the problem.

## No image catalog involved

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg18
spec:
  instances: 1
  imageName: # ... PostgreSQL 18 container image
  postgresql:
    extensions:
      - name: pgvector
        image:
          reference: # ...pgvector container image for PostgreSQL 18
  storage:
    size: 1Gi
```

The operator will mount the referenced image as a volume.

## With image catalog

Consider the following catalog:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ImageCatalog
metadata:
  name: postgresql
  namespace: default
spec:
  images:
    # ...
    - major: 18
      image: # ... PostgreSQL 18 container image
      extensions:
        - name: pgvector
          image: # ...pgvector container image for PostgreSQL 18
```

A possible cluster definition could be:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg18
spec:
  instances: 1
  imageCatalogRef:
    apiGroup: postgresql.cnpg.io
    kind: ImageCatalog
    name: postgresql
    major: 18
  postgresql:
    extensions:
      - name: pgvector
  storage:
    size: 1Gi
```

Given that the `Cluster` already points to a catalog definition for version 18 and requires the `pgvector` extension, the operator will point to the only `pgvector` extension image declared in the catalog for PostgreSQL 18. If no such extension exists, the change fails.

Theoretically, we could also enable overriding the extension definition in the `.spec.postgresql.extensions` stanza, like in the following example.

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg18-direct-extension
spec:
  instances: 1
  imageCatalogRef:
    apiGroup: postgresql.cnpg.io
    kind: ImageCatalog
    name: postgresql
    major: 18
  postgresql:
    extensions:
      - name: pgvector
        image:
          reference: # ... alternative pgvector container image for PostgreSQL 18
  storage:
    size: 1Gi
```

The override here will request a different image from the one in the catalog.

<strike>
## Extension version specification

A catalog might enable the specification of multiple versions of an extension:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ImageCatalog
metadata:
  name: postgresql
  namespace: default
spec:
  images:
    # ...
    - major: 18
      image: # ... PostgreSQL 18 container image
      extensions:
        - name: pgvector
          version: x
          image: # ... pgvector version x container image for PostgreSQL 18
        - name: pgvector
          version: y
          image: # ... pgvector version y container image for PostgreSQL 18
```

NOTE: Should we enforce x and y to be specific versions of the extension like 0.8.0? Or leave it at the discretion of the catalog maintainer?

When versions are specified, we should make sure that ambiguity is removed at the Cluster level. Take this example:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg18-direct-extension
spec:
  instances: 1
  imageCatalogRef:
    apiGroup: postgresql.cnpg.io
    kind: ImageCatalog
    name: postgresql
    major: 18
  postgresql:
    extensions:
      - name: pgvector
        version: y
        image:
          reference: # ... alternative pgvector container image for PostgreSQL 18
  storage:
    size: 1Gi
```

In this case, the "y" version of the extension image is selected.

We need to decide what to do when the version is defined in just one of the locations:

- defined in the image catalog, not defined in the cluster definition
- vice-versa

A conservative approach is to reject any change and request that the catalog image and the cluster definition be both versioned, or not.
</strike>@theory: A few thoughts:

* Does the catalog need a way to specify the default version of an extension when it has multiple versions? This would hand the case where the Cluster doesn't declare a version.
* Regarding your question about enforcing the version spec in the catalog, I think no, since there is no standard for Postgres extension version formats (alas).
* In the last example, the `.spec.postgresql.extensions` stanza has an item that declares both a version and a reference. Seems like the version would be ignored in this case, right? Maybe it should be disallowed when the image reference is present.
* Oh, I see, maybe you're suggesting that it would use the version from the catalog if it exists, and if not fall back on the image reference? Honestly that feels a little dodgy to me; it makes the Cluster manifest less deterministic.

Also, with a list of extensions in the `Cluster` manifest, what would this configuration do?

```yaml
      - name: pgvector
        version: x
      - name: pgvector
        version: y
```

Seems like the same name should be disallowed, yes? In which case maybe it should be an object rather than an array?@gbartolini: Thanks for your time @theory.

> * Does the catalog need a way to specify the default version of an extension when it has multiple versions? This would hand the case where the Cluster doesn't declare a version.

That could be a good idea indeed. Something like:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ImageCatalog
metadata:
  name: postgresql
  namespace: default
spec:
  images:
    # ...
    - major: 18
      image: # ... PostgreSQL 18 container image
      extensions:
        - name: pgvector
          version: x
          image: # ... pgvector version x container image for PostgreSQL 18
        - name: pgvector
          version: y
          image: # ... pgvector version y container image for PostgreSQL 18
          default: true
```

> * Regarding your question about enforcing the version spec in the catalog, I think no, since there is no standard for Postgres extension version formats (alas).

👍🏻 

> * In the last example, the `.spec.postgresql.extensions` stanza has an item that declares both a version and a reference. Seems like the version would be ignored in this case, right? Maybe it should be disallowed when the image reference is present.

I guess we could use the version as a suffix in the mount point. For example, "pgvector-x". In case we want to mount multiple versions of the same extensions (see next item).

> * Oh, I see, maybe you're suggesting that it would use the version from the catalog if it exists, and if not fall back on the image reference? Honestly that feels a little dodgy to me; it makes the Cluster manifest less deterministic.

I am suggesting to override the catalog. But, it is up to us. If it complicates the interface, it is probably better to start with a conservative approach. Either you use the catalog or not.

> Also, with a list of extensions in the `Cluster` manifest, what would this configuration do?
> 
>       - name: pgvector
>         version: x
>       - name: pgvector
>         version: y
> Seems like the same name should be disallowed, yes? In which case maybe it should be an object rather than an array?

You have more experience than me on this topic. I was thinking of enabling multiple versions of the same extension, in different paths. If you don't think there's a use case for that, we can simplify the implementation a lot.
@theory: > That could be a good idea indeed. Something like:

Yeah, or else list versions:

```yaml
      extensions:
        pgvector:
          default_version: x
          versions:
            - version: x
              image: # ... pgvector version x container image for PostgreSQL 18
            - version: y
              image: # ... pgvector version y container image for PostgreSQL 18
```

Which is perhaps easier to validate.

> I am suggesting to override the catalog. But, it is up to us. If it complicates the interface, it is probably better to start with a conservative approach. Either you use the catalog or not.

I say start conservative and see where demand leads.

> You have more experience than me on this topic. I was thinking of enabling multiple versions of the same extension, in different paths. If you don't think there's a use case for that, we can simplify the implementation a lot.

Postgres doesn't really support that pattern. If, for example, we have

```ini
extension_control_path = '$system:/extensions/vector-0.6.5/share:/extensions/vector-0.7.0/share'
```

When someone executes `CREATE EXTENSION vector`, Postgres will find the control file in `/extensions/vector-0.6.5/share` and never even search `/extensions/vector-0.7.0/share`.

Until Postgres itself supports this pattern, I wouldn't bother supporting multipole versions, either.@gbartolini: Based on your reply, I would start with a simple approach for now that doesn't consider versions. I have struck through that section above.@itay-grudev: In addition to what's discussed here I want to raise your attention to another problem. This is the sequence of events when we perform a rolling upgrade with a switchover and the new Docker image ships a new version of a shared preload library that contains non-backwards compatible ABI changes:

1. Replica 1 is upgraded to the new Docker Image
2. Replica 2 is upgraded to the new Docker Image
3. Switchover of the current primary to Replica 1
4. `ALTER EXTENSION UPDATE TO ...` on the new primary
5. Old primary is upgraded to the new Docker Image

Between step 1 and step 4, there are two read-only replicas that are in a state, where they are running the new version of a shared preload library extension before its schema is upgraded (as that can only happen after a switchover with an upgraded primary).

Any calls to functions with a changed signature will result in a Postgres FATAL crash. There is a failure scenario where standby replicas would continuously crash preventing a switchover. This could partially cripple the cluster.

I raised this as a potential problem in the May 21 and June 19 meetings. I was investigating an option to disable connections to un-upgraded instances by modifying Ready-ness probes, so they are not included in their corresponding Kubernetes services. But that won't work, as the operator won't promote an replica that's not ready.

In the latter meeting @gbartolini proposed a much better alternative solution by modifying the `pg_hba` file to prevent connections to replicas that haven't received the `ALTER EXTENSION UPDATE` changes. This behavior can be toggled with a configuration option called `disableConnectionsIfNotAligned` on your proposed new extension configuration. I also believe the option should be `false` by default, as enabling it would decrease the cluster's read-only query capacity during upgrades. And while this is a potentially dangerous scenario it is unlikely to happen as it requires: (1) And extension upgrade with non-backwards compatible ABI changes and (2) queries that constantly call the problematic functions. I think such a situation is rare, and the configuration option should only be enabled on extensions known to have changes.@theory: Seems like yet another example of the overall challenge of ABI/API compatibility of extension upgrades. I like the `disableConnectionsIfNotAligned` proposal, though it will have to be aware of what's installed in every database in a cluster. Not a big deal, I suppose. I agree it should be false by default, as many extensions have no compatibility issues. But maybe we want to have some sort of per-extension configuration to indicate that `ALTER EXTENSION UPDATE` is required. Would be super useful in metadata in each extension image.@gbartolini: > Seems like yet another example of the overall challenge of ABI/API compatibility of extension upgrades. I like the `disableConnectionsIfNotAligned` proposal, though it will have to be aware of what's installed in every database in a cluster. Not a big deal, I suppose. I agree it should be false by default, as many extensions have no compatibility issues. But maybe we want to have some sort of per-extension configuration to indicate that `ALTER EXTENSION UPDATE` is required. Would be super useful in metadata in each extension image.

Let's keep the option open for this feature, although I would postpone this for the next release.@gbartolini: I spoke with @NiccoloFei and @mnencia this morning. We plan to prioritise an initial implementation that only covers the `.spec.postgresql.extension` definition, deferring the image catalog implementation to a later moment, potentially even the 1.28 release (at the end of the day, the image catalog is nothing but a default definition of an extension image).
@gbartolini: # Proposal of configuration control

During the call, we also talked about keeping open the possibility to host multiple extensions in a single image.

The idea is to propose three more fields:

- `extension_control_path` (default: `['share']`): list of local paths (relative to the image root folder) containing `.control` files
- `dynamic_library_path` (default: `['lib']`): list of local paths to dynamic libraries
- `ld_library_path` (default: `[]`): list of local paths to system libraries (to set `LD_LIBRARY_PATH` environment variable for the main postgres process)

## Example of a single extension image

By default, images will have content directly in the root directory (e.g. `share` and `lib`), as per our current recommendation.

For example:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
# ...
  postgresql:
    extensions:
      - name: acdc
        #extension_control_path: ['share']
        #dynamic_library_path: ['lib']
        #ld_library_path: []
        image:
          # ...
# ...
```

The idea is that the above extension image will be mounted under `/extensions/acdc`.

The `acdc` image will have two folders in the root directory: `share` and `libs`. CloudNativePG will set the `extension_control_path` and `dynamic_library_path` as follows:

- `extension_control_path: /extensions/hardrock/acdc/share:$system`
- `dynamic_library_path: /extensions/hardrock/acdc/lib:$libdir`

## Example of multi-extension image

The three proposed options (`extension_control_path`, `dynamic_library_path`, and `ld_library_path`) will enable mounting images with multiple extensions, like in the following example:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
# ...
  postgresql:
    extensions:
      - name: hardrock
        extension_control_path: ['acdc/share', 'deep-purple/share']
        dynamic_library_path: ['acdc/lib', 'deep-purple/lib']
        image:
          # ...
# ...
```

The container image will have two subfolders (`acdc` and `deep-purple`), each with its standard structure (`share` and `lib`), and CloudNativePG will mount it under `/extensions/hardrock`. CloudNativePG will set the `extension_control_path` and `dynamic_library_path` as follows:

- `extension_control_path: /extensions/hardrock/acdc/share:/extensions/hardrock/deep-purple/share:$system`
- `dynamic_library_path: /extensions/hardrock/acdc/lib:/extensions/hardrock/deep-purple/lib:$libdir`

## Example of library-only image

There can also be extensions that do not need any `extension_control_path` but need `dynamic_library_path`. One example is `pg_failover_slots` (supposing the libraries are in the suggested `lib` folder in the image).

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
# ...
  postgresql:
    extensions:
      - name: pg_failover_slots
        extension_control_path: []
        #dynamic_library_path: ['lib']
        #ld_library_path: []
        image:
          # ...
# ...
```@gbartolini: Worth linking this: https://github.com/kubernetes/enhancements/pull/5450

Solution patch:
diff --git a/.wordlist-en-custom.txt b/.wordlist-en-custom.txt
index b8f7d568..e0f5f2f5 100644
--- a/.wordlist-en-custom.txt
+++ b/.wordlist-en-custom.txt
@@ -160,6 +160,7 @@ EnvVar
 EphemeralVolumeSource
 EphemeralVolumesSizeLimit
 EphemeralVolumesSizeLimitConfiguration
+ExtensionConfiguration
 ExtensionSpec
 ExtensionStatus
 ExternalCluster
@@ -180,6 +181,7 @@ GUC
 GUCs
 Gabriele
 GaugeVec
+GeoSpatial
 Gi
 GitOps
 GoArch
@@ -204,6 +206,8 @@ ImageCatalog
 ImageCatalogRef
 ImageCatalogSpec
 ImageInfo
+ImageVolume
+ImageVolumeSource
 ImportSource
 InfoSec
 Innocenti
@@ -771,6 +775,7 @@ dir
 disableDefaultQueries
 disablePassword
 disabledDefaultServices
+discoverable
 displayName
 displayName
 distro
@@ -885,6 +890,7 @@ imageCatalogRef
 imageName
 imagePullPolicy
 imagePullSecrets
+imageVolume
 imagecatalogs
 img
 immediateCheckpoint
@@ -951,6 +957,7 @@ lastSuccessfulBackupByMethod
 latestGeneratedNode
 latn
 lc
+ld
 ldap
 ldapBindPassword
 ldaps
@@ -1093,6 +1100,7 @@ pgBouncerSecrets
 pgDataImageInfo
 pgDumpExtraOptions
 pgRestoreExtraOptions
+pgRouting
 pgSQL
 pgadmin
 pgaudit
diff --git a/api/v1/cluster_types.go b/api/v1/cluster_types.go
index 987a813d..a342f58e 100644
--- a/api/v1/cluster_types.go
+++ b/api/v1/cluster_types.go
@@ -1429,6 +1429,37 @@ type PostgresConfiguration struct {
 	// Defaults to false.
 	// +optional
 	EnableAlterSystem bool `json:"enableAlterSystem,omitempty"`
+
+	// The configuration of the extensions to be added
+	// +optional
+	Extensions []ExtensionConfiguration `json:"extensions,omitempty"`
+}
+
+// ExtensionConfiguration is the configuration used to add
+// PostgreSQL extensions to the Cluster.
+type ExtensionConfiguration struct {
+	// The name of the extension, required
+	// +kubebuilder:validation:MinLength=1
+	// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
+	Name string `json:"name"`
+
+	// The image containing the extension, required
+	// +kubebuilder:validation:XValidation:rule="has(self.reference)",message="An image reference is required"
+	ImageVolumeSource corev1.ImageVolumeSource `json:"image"`
+
+	// The list of directories inside the image which should be added to extension_control_path.
+	// If not defined, defaults to "/share".
+	// +optional
+	ExtensionControlPath []string `json:"extension_control_path,omitempty"`
+
+	// The list of directories inside the image which should be added to dynamic_library_path.
+	// If not defined, defaults to "/lib".
+	// +optional
+	DynamicLibraryPath []string `json:"dynamic_library_path,omitempty"`
+
+	// The list of directories inside the image which should be added to ld_library_path.
+	// +optional
+	LdLibraryPath []string `json:"ld_library_path,omitempty"`
 }
 
 // BootstrapConfiguration contains information about how to create the PostgreSQL
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 042fe567..c6805684 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -1262,6 +1262,37 @@ func (in *EphemeralVolumesSizeLimitConfiguration) DeepCopy() *EphemeralVolumesSi
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExtensionConfiguration) DeepCopyInto(out *ExtensionConfiguration) {
+	*out = *in
+	out.ImageVolumeSource = in.ImageVolumeSource
+	if in.ExtensionControlPath != nil {
+		in, out := &in.ExtensionControlPath, &out.ExtensionControlPath
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.DynamicLibraryPath != nil {
+		in, out := &in.DynamicLibraryPath, &out.DynamicLibraryPath
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.LdLibraryPath != nil {
+		in, out := &in.LdLibraryPath, &out.LdLibraryPath
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionConfiguration.
+func (in *ExtensionConfiguration) DeepCopy() *ExtensionConfiguration {
+	if in == nil {
+		return nil
+	}
+	out := new(ExtensionConfiguration)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ExtensionSpec) DeepCopyInto(out *ExtensionSpec) {
 	*out = *in
@@ -2300,6 +2331,13 @@ func (in *PostgresConfiguration) DeepCopyInto(out *PostgresConfiguration) {
 		*out = new(LDAPConfig)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Extensions != nil {
+		in, out := &in.Extensions, &out.Extensions
+		*out = make([]ExtensionConfiguration, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresConfiguration.
diff --git a/config/crd/bases/postgresql.cnpg.io_clusters.yaml b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
index f1a78bc1..57da62e7 100644
--- a/config/crd/bases/postgresql.cnpg.io_clusters.yaml
+++ b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
@@ -4011,6 +4011,67 @@ spec:
                       This should only be used for debugging and troubleshooting.
                       Defaults to false.
                     type: boolean
+                  extensions:
+                    description: The configuration of the extensions to be added
+                    items:
+                      description: |-
+                        ExtensionConfiguration is the configuration used to add
+                        PostgreSQL extensions to the Cluster.
+                      properties:
+                        dynamic_library_path:
+                          description: |-
+                            The list of directories inside the image which should be added to dynamic_library_path.
+                            If not defined, defaults to "/lib".
+                          items:
+                            type: string
+                          type: array
+                        extension_control_path:
+                          description: |-
+                            The list of directories inside the image which should be added to extension_control_path.
+                            If not defined, defaults to "/share".
+                          items:
+                            type: string
+                          type: array
+                        image:
+                          description: The image containing the extension, required
+                          properties:
+                            pullPolicy:
+                              description: |-
+                                Policy for pulling OCI objects. Possible values are:
+                                Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails.
+                                Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present.
+                                IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails.
+                                Defaults to Always if :latest tag is specified, or IfNotPresent otherwise.
+                              type: string
+                            reference:
+                              description: |-
+                                Required: Image or artifact reference to be used.
+                                Behaves in the same way as pod.spec.containers[*].image.
+                                Pull secrets will be assembled in the same way as for the container image by looking up node credentials, SA image pull secrets, and pod spec image pull secrets.
+                                More info: https://kubernetes.io/docs/concepts/containers/images
+                                This field is optional to allow higher level config management to default or override
+                                container images in workload controllers like Deployments and StatefulSets.
+                              type: string
+                          type: object
+                          x-kubernetes-validations:
+                          - message: An image reference is required
+                            rule: has(self.reference)
+                        ld_library_path:
+                          description: The list of directories inside the image which
+                            should be added to ld_library_path.
+                          items:
+                            type: string
+                          type: array
+                        name:
+                          description: The name of the extension, required
+                          minLength: 1
+                          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                          type: string
+                      required:
+                      - image
+                      - name
+                      type: object
+                    type: array
                   ldap:
                     description: Options to specify LDAP configuration
                     properties:
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index bdc84133..b76cfb33 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -63,6 +63,7 @@ nav:
   - postgis.md
   - e2e.md
   - container_images.md
+  - imagevolume_extensions.md
   - operator_capability_levels.md
   - controller.md
   - samples.md
diff --git a/docs/src/cloudnative-pg.v1.md b/docs/src/cloudnative-pg.v1.md
index 2b9e6d51..92ca69e4 100644
--- a/docs/src/cloudnative-pg.v1.md
+++ b/docs/src/cloudnative-pg.v1.md
@@ -2825,6 +2825,61 @@ storage</p>
 </tbody>
 </table>
 
+## ExtensionConfiguration     {#postgresql-cnpg-io-v1-ExtensionConfiguration}
+
+
+**Appears in:**
+
+- [PostgresConfiguration](#postgresql-cnpg-io-v1-PostgresConfiguration)
+
+
+<p>ExtensionConfiguration is the configuration used to add
+PostgreSQL extensions to the Cluster.</p>
+
+
+<table class="table">
+<thead><tr><th width="30%">Field</th><th>Description</th></tr></thead>
+<tbody>
+<tr><td><code>name</code> <B>[Required]</B><br/>
+<i>string</i>
+</td>
+<td>
+   <p>The name of the extension, required</p>
+</td>
+</tr>
+<tr><td><code>image</code> <B>[Required]</B><br/>
+<a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#imagevolumesource-v1-core"><i>core/v1.ImageVolumeSource</i></a>
+</td>
+<td>
+   <p>The image containing the extension, required</p>
+</td>
+</tr>
+<tr><td><code>extension_control_path</code><br/>
+<i>[]string</i>
+</td>
+<td>
+   <p>The list of directories inside the image which should be added to extension_control_path.
+If not defined, defaults to &quot;/share&quot;.</p>
+</td>
+</tr>
+<tr><td><code>dynamic_library_path</code><br/>
+<i>[]string</i>
+</td>
+<td>
+   <p>The list of directories inside the image which should be added to dynamic_library_path.
+If not defined, defaults to &quot;/lib&quot;.</p>
+</td>
+</tr>
+<tr><td><code>ld_library_path</code><br/>
+<i>[]string</i>
+</td>
+<td>
+   <p>The list of directories inside the image which should be added to ld_library_path.</p>
+</td>
+</tr>
+</tbody>
+</table>
+
 ## ExtensionSpec     {#postgresql-cnpg-io-v1-ExtensionSpec}
 
 
@@ -4453,6 +4508,13 @@ This should only be used for debugging and troubleshooting.
 Defaults to false.</p>
 </td>
 </tr>
+<tr><td><code>extensions</code><br/>
+<a href="#postgresql-cnpg-io-v1-ExtensionConfiguration"><i>[]ExtensionConfiguration</i></a>
+</td>
+<td>
+   <p>The configuration of the extensions to be added</p>
+</td>
+</tr>
 </tbody>
 </table>
 
diff --git a/docs/src/imagevolume_extensions.md b/docs/src/imagevolume_extensions.md
new file mode 100644
index 00000000..bc89d3b6
--- /dev/null
+++ b/docs/src/imagevolume_extensions.md
@@ -0,0 +1,343 @@
+# Image Volume Extensions
+<!-- SPDX-License-Identifier: CC-BY-4.0 -->
+
+CloudNativePG supports the **dynamic loading of PostgreSQL extensions** into a
+`Cluster` at Pod startup using the [Kubernetes `ImageVolume` feature](https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/)
+and the `extension_control_path` GUC introduced in PostgreSQL 18, to which this
+project contributed.
+
+This feature allows you to mount a [PostgreSQL extension](https://www.postgresql.org/docs/current/extend-extensions.html),
+packaged as an OCI-compliant container image, as a read-only and immutable
+volume inside a running pod at a known filesystem path.
+
+You can make the extension available either globally, using the
+[`shared_preload_libraries` option](postgresql_conf.md#shared-preload-libraries),
+or at the database level through the `CREATE EXTENSION` command. For the
+latter, you can use the [`Database` resource’s declarative extension management](declarative_database_management.md/#managing-extensions-in-a-database)
+to ensure consistent, automated extension setup within your PostgreSQL
+databases.
+
+## Benefits
+
+Image volume extensions decouple the distribution of PostgreSQL operand
+container images from the distribution of extensions. This eliminates the
+need to define and embed extensions at build time within your PostgreSQL
+images—a major adoption blocker for PostgreSQL as a containerized workload,
+including from a security and supply chain perspective.
+
+As a result, you can:
+
+- Use the [official PostgreSQL `minimal` operand images](https://github.com/cloudnative-pg/postgres-containers?tab=readme-ov-file#minimal-images)
+  provided by CloudNativePG.
+- Dynamically add the extensions you need to your `Cluster` definitions,
+  without rebuilding or maintaining custom PostgreSQL images.
+- Reduce your operational surface by using immutable, minimal, and secure base
+  images while adding only the extensions required for each workload.
+
+Extension images must be built according to the
+[documented specifications](#image-specifications).
+
+## Requirements
+
+To use image volume extensions with CloudNativePG, you need:
+
+- **PostgreSQL 18 or later**, with support for `extension_control_path`.
+- **Kubernetes 1.33**, with the `ImageVolume` feature gate enabled.
+- **CloudNativePG-compatible extension container images**, ensuring:
+    - Matching PostgreSQL major version of the `Cluster` resource.
+    - Compatible operating system distribution of the `Cluster` resource.
+    - Matching CPU architecture of the `Cluster` resource.
+
+## How it works
+
+Extension images are defined in the `.spec.postgresql.extensions` stanza of a
+`Cluster` resource, which accepts an ordered list of extensions to be added to
+the PostgreSQL cluster.
+
+!!! Info
+    For field-level details, see the
+    [API reference for `ExtensionConfiguration`](cloudnative-pg.v1.md#postgresql-cnpg-io-v1-ExtensionConfiguration).
+
+Each image volume is mounted at `/extensions/<EXTENSION_NAME>`.
+
+By default, CloudNativePG automatically manages the relevant GUCs, setting:
+
+- `extension_control_path` to `/extensions/<EXTENSION_NAME>/share`, allowing
+  PostgreSQL to locate any extension control file within `/extensions/<EXTENSION_NAME>/share/extension`
+- `dynamic_library_path` to `/extensions/<EXTENSION_NAME>/lib`
+
+These values are appended in the order in which the extensions are defined in
+the `extensions` list, ensuring deterministic path resolution within
+PostgreSQL. This allows PostgreSQL to discover and load the extension without
+requiring manual configuration inside the pod.
+
+!!! Info
+    Depending on how your extension container images are built and their layout,
+    you may need to adjust the default `extension_control_path` and
+    `dynamic_library_path` values to match the image structure.
+
+!!! Important
+    If the extension image includes shared libraries, they must be compiled
+    with the same PostgreSQL major version, operating system distribution, and CPU
+    architecture as the PostgreSQL container image used by your cluster, to ensure
+    compatibility and prevent runtime issues.
+
+## How to add a new extension
+
+Adding an extension to a database in CloudNativePG involves a few steps:
+
+1. Define the extension image in the `Cluster` resource so that PostgreSQL can
+   discover and load it.
+2. Add the library to [`shared_preload_libraries`](postgresql_conf.md#shared-preload-libraries)
+   if the extension requires it.
+3. Declare the extension in the `Database` resource where you want it
+   installed, if the extension supports `CREATE EXTENSION`.
+
+For illustration purposes, this guide uses a simple, fictitious extension named
+`foo` that supports `CREATE EXTENSION`.
+
+### Adding a new extension to a `Cluster` resource
+
+You can add an `ImageVolume`-based extension to a `Cluster` using the
+`.spec.postgresql.extensions` stanza. For example:
+
+```yaml
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: foo-18
+spec:
+  # ...
+  postgresql:
+    extensions:
+      - name: foo
+        image:
+          reference: # registry path for your extension image
+  # ...
+```
+
+The `name` field is **mandatory** and **must be unique within the cluster**, as
+it determines the mount path (`/extensions/foo` in this example). It must
+consist of *lowercase alphanumeric characters or hyphens (`-`)* and must start
+and end with an alphanumeric character.
+
+The `image` stanza follows the [Kubernetes `ImageVolume` API](https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/).
+The `reference` must point to a valid container registry path for the extension
+image.
+
+!!! Important
+    When a new extension is added to a running `Cluster`, CloudNativePG will
+    automatically trigger a [rolling update](rolling_update.md) to attach the new
+    image volume to each pod. Before adding a new extension in production,
+    ensure you have thoroughly tested it in a staging environment to prevent
+    configuration issues that could leave your PostgreSQL cluster in an unhealthy
+    state.
+
+Once mounted, CloudNativePG will automatically configure PostgreSQL by appending:
+
+- `/extensions/foo/share` to `extension_control_path`
+- `/extensions/foo/lib` to `dynamic_library_path`
+
+This ensures that the PostgreSQL container is ready to serve the `foo`
+extension when requested by a database, as described in the next section. The
+`CREATE EXTENSION foo` command, triggered automatically during the
+[reconciliation of the `Database` resource](declarative_database_management.md/#managing-extensions-in-a-database),
+will work without additional configuration, as PostgreSQL will locate:
+
+- the extension control file at `/extensions/foo/share/extension/foo.control`
+- the shared library at `/extensions/foo/lib/foo.so`
+
+### Adding a new extension to a `Database` resource
+
+Once the extension is available in the PostgreSQL instance, you can leverage
+declarative databases to [manage the lifecycle of your extensions](declarative_database_management.md#managing-extensions-in-a-database)
+within the target database.
+
+Continuing with the `foo` example, you can request the installation of the
+`foo` extension in the `app` database of the `foo-18` cluster using the
+following resource definition:
+
+```yaml
+apiVersion: postgresql.cnpg.io/v1
+kind: Database
+metadata:
+  name: foo-app
+spec:
+  name: app
+  owner: app
+  cluster:
+    name: foo-18
+  extensions:
+    - name: foo
+      version: 1.0
+```
+
+CloudNativePG will automatically reconcile this resource, executing the
+`CREATE EXTENSION foo` command inside the `app` database if it is not
+already installed, ensuring your desired state is maintained without manual
+intervention.
+
+## Advanced Topics
+
+In some cases, the default expected structure may be insufficient for your
+extension image, particularly when:
+
+- The extension requires additional system libraries.
+- Multiple extensions are bundled in the same image.
+- The image uses a custom directory structure.
+
+Following the *"convention over configuration"* paradigm, CloudNativePG allows
+you to finely control the configuration of each extension image through the
+following fields:
+
+- `extension_control_path`: A list of relative paths within the container image
+  to be appended to PostgreSQL’s `extension_control_path`, allowing it to
+  locate extension control files.
+- `dynamic_library_path`: A list of relative paths within the container image
+  to be appended to PostgreSQL’s `dynamic_library_path`, enabling it to locate
+  shared library files for extensions.
+- `ld_library_path`: A list of relative paths within the container image to be
+  appended to the `LD_LIBRARY_PATH` environment variable of the instance
+  manager process, allowing PostgreSQL to locate required system libraries at
+  runtime.
+
+This flexibility enables you to support complex or non-standard extension
+images while maintaining clarity and predictability.
+
+### Setting Custom Paths
+
+If your extension image does not use the default `lib` and `share` directories
+for its libraries and control files, you can override the defaults by
+explicitly setting `extension_control_path` and `dynamic_library_path`.
+
+For example:
+
+```yaml
+spec:
+  postgresql:
+    extensions:
+      - name: my-extension
+        extension_control_path:
+          - my/share/path
+        dynamic_library_path:
+          - my/lib/path
+        image:
+          reference: # registry path for your extension image
+```
+
+CloudNativePG will configure PostgreSQL with:
+
+- `/extensions/my-extension/my/share/path` appended to `extension_control_path`
+- `/extensions/my-extension/my/lib/path` appended to `dynamic_library_path`
+
+This allows PostgreSQL to discover your extension’s control files and shared
+libraries correctly, even with a non-standard layout.
+
+### Multi-extension Images
+
+You may need to include multiple extensions within the same container image,
+adopting a structure where each extension’s files reside in their own
+subdirectory.
+
+For example, to package PostGIS and pgRouting together in a single image, each
+in its own subdirectory:
+
+```yaml
+# ...
+spec:
+  # ...
+  postgresql:
+    extensions:
+      - name: geospatial
+        extension_control_path:
+          - postgis/share
+          - pgrouting/share
+        dynamic_library_path:
+          - postgis/lib
+          - pgrouting/lib
+        # ...
+        image:
+          reference: # registry path for your geospatial image
+      # ...
+    # ...
+  # ...
+```
+
+### Including System Libraries
+
+Some extensions, such as PostGIS, require system libraries that may not be
+present in the base PostgreSQL image. To support these requirements, you can
+package the necessary libraries within your extension container image and make
+them available to PostgreSQL using the `ld_library_path` field.
+
+For example, if your extension image includes a `system` directory with the
+required libraries:
+
+```yaml
+# ...
+spec:
+  # ...
+  postgresql:
+    extensions:
+      - name: postgis
+        # ...
+        ld_library_path:
+          - syslib
+        image:
+          reference: # registry path for your PostGIS image
+      # ...
+    # ...
+  # ...
+```
+
+CloudNativePG will set the `LD_LIBRARY_PATH` environment variable to include
+`/extensions/postgis/system`, allowing PostgreSQL to locate and load these
+system libraries at runtime.
+
+!!! Important
+    Since `ld_library_path` must be set when the PostgreSQL process starts,
+    changing this value requires a **cluster restart** for the new value to take effect.
+    CloudNativePG does not currently trigger this restart automatically; you will need to
+    manually restart the cluster (e.g., using `cnpg restart`) after modifying `ld_library_path`.
+
+## Image Specifications
+
+A standard extension container image for CloudNativePG includes two
+required directories at its root:
+
+- `share`: contains the extension control file (e.g., `<EXTENSION>.control`)
+  and any SQL files.
+- `lib`: contains the extension's shared library (e.g., `<EXTENSION>.so`) and
+  any additional required libraries.
+
+Following this structure ensures that the extension will be automatically
+discoverable and usable by PostgreSQL within CloudNativePG without requiring
+manual configuration.
+
+!!! Important
+    We encourage PostgreSQL extension developers to publish OCI-compliant extension
+    images following this layout as part of their artifact distribution, making
+    their extensions easily consumable within Kubernetes environments.
+    Ideally, extension images should target a specific operating system
+    distribution and architecture, be tied to a particular PostgreSQL version, and
+    be built using the distribution’s native packaging system (for example, using
+    Debian or RPM packages). This approach ensures consistency, security, and
+    compatibility with the PostgreSQL images used in your clusters.
+
+## Caveats
+
+Currently, adding, removing, or updating an extension image triggers a
+restart of the PostgreSQL pods. This behavior is inherited from how
+[image volumes](https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/)
+work in Kubernetes.
+
+Before performing an extension update, ensure you have:
+
+- Thoroughly tested the update process in a staging environment.
+- Verified that the extension image contains the required upgrade path between
+  the currently installed version and the target version.
+- Updated the `version` field for the extension in the relevant `Database`
+  resource definition to align with the new version in the image.
+
+These steps help prevent downtime or data inconsistencies in your PostgreSQL
+clusters during extension updates.
diff --git a/hack/setup-cluster.sh b/hack/setup-cluster.sh
index 138c9115..52e22b11 100755
--- a/hack/setup-cluster.sh
+++ b/hack/setup-cluster.sh
@@ -174,6 +174,15 @@ EOF
     done
   fi
 
+  # Enable ImageVolume support from kindest/node v1.33.1
+  if [[ "$(printf '%s\n' "1.33.1" "${k8s_version#v}" | sort -V | head -n1)" == "1.33.1" ]]; then
+    cat >>"${config_file}" <<-EOF
+
+featureGates:
+  ImageVolume: true
+EOF
+  fi
+
   # Add containerdConfigPatches section
   cat >>"${config_file}" <<-EOF
 
diff --git a/internal/cmd/manager/instance/join/cmd.go b/internal/cmd/manager/instance/join/cmd.go
index a82779e4..dbea010f 100644
--- a/internal/cmd/manager/instance/join/cmd.go
+++ b/internal/cmd/manager/instance/join/cmd.go
@@ -116,6 +116,7 @@ func joinSubCommand(ctx context.Context, instance *postgres.Instance, info postg
 		contextLogger.Error(err, "Error while getting cluster")
 		return err
 	}
+	instance.Cluster = &cluster
 
 	if _, err := instancecertificate.NewReconciler(client, instance).RefreshSecrets(ctx, &cluster); err != nil {
 		contextLogger.Error(err, "Error while refreshing secrets")
diff --git a/internal/cmd/manager/instance/upgrade/execute/cmd.go b/internal/cmd/manager/instance/upgrade/execute/cmd.go
index 418ce4f3..3ecb2148 100644
--- a/internal/cmd/manager/instance/upgrade/execute/cmd.go
+++ b/internal/cmd/manager/instance/upgrade/execute/cmd.go
@@ -160,6 +160,7 @@ func (ui upgradeInfo) upgradeSubCommand(ctx context.Context, instance *postgres.
 		contextLogger.Error(err, "Error while getting cluster")
 		return err
 	}
+	instance.Cluster = &cluster
 
 	if _, err := instancecertificate.NewReconciler(client, instance).RefreshSecrets(ctx, &cluster); err != nil {
 		return fmt.Errorf("error while downloading secrets: %w", err)
diff --git a/internal/management/controller/instance_controller.go b/internal/management/controller/instance_controller.go
index 085c0c34..454b9247 100644
--- a/internal/management/controller/instance_controller.go
+++ b/internal/management/controller/instance_controller.go
@@ -942,6 +942,7 @@ func (r *InstanceReconciler) reconcileInstance(cluster *apiv1.Cluster) {
 	r.instance.MaxStopDelay = cluster.GetMaxStopDelay()
 	r.instance.SmartStopDelay = cluster.GetSmartShutdownTimeout()
 	r.instance.RequiresDesignatedPrimaryTransition = detectRequiresDesignatedPrimaryTransition()
+	r.instance.Cluster = cluster
 }
 
 // PostgreSQLAutoConfWritable reconciles the permissions bit of `postgresql.auto.conf`
diff --git a/internal/webhook/v1/cluster_webhook.go b/internal/webhook/v1/cluster_webhook.go
index e9fd7678..72d1009e 100644
--- a/internal/webhook/v1/cluster_webhook.go
+++ b/internal/webhook/v1/cluster_webhook.go
@@ -220,6 +220,7 @@ func (v *ClusterCustomValidator) validate(r *apiv1.Cluster) (allErrs field.Error
 		v.validatePromotionToken,
 		v.validatePluginConfiguration,
 		v.validateLivenessPingerProbe,
+		v.validateExtensions,
 	}
 
 	for _, validate := range validations {
@@ -2630,3 +2631,67 @@ func (v *ClusterCustomValidator) validateLivenessPingerProbe(r *apiv1.Cluster) f
 
 	return nil
 }
+
+func (v *ClusterCustomValidator) validateExtensions(r *apiv1.Cluster) field.ErrorList {
+	ensureNotEmptyOrDuplicate := func(path *field.Path, list *stringset.Data, value string) *field.Error {
+		if value == "" {
+			return field.Invalid(
+				path,
+				value,
+				"value cannot be empty",
+			)
+		}
+
+		if list.Has(value) {
+			return field.Duplicate(
+				path,
+				value,
+			)
+		}
+		return nil
+	}
+
+	if len(r.Spec.PostgresConfiguration.Extensions) == 0 {
+		return nil
+	}
+
+	var result field.ErrorList
+
+	extensionNames := stringset.New()
+
+	for i, v := range r.Spec.PostgresConfiguration.Extensions {
+		basePath := field.NewPath("spec", "postgresql", "extensions").Index(i)
+		if nameErr := ensureNotEmptyOrDuplicate(basePath.Child("name"), extensionNames, v.Name); nameErr != nil {
+			result = append(result, nameErr)
+		}
+		extensionNames.Put(v.Name)
+
+		controlPaths := stringset.New()
+		for j, path := range v.ExtensionControlPath {
+			if validateErr := ensureNotEmptyOrDuplicate(
+				basePath.Child("extension_control_path").Index(j),
+				controlPaths,
+				path,
+			); validateErr != nil {
+				result = append(result, validateErr)
+			}
+
+			controlPaths.Put(path)
+		}
+
+		libraryPaths := stringset.New()
+		for j, path := range v.DynamicLibraryPath {
+			if validateErr := ensureNotEmptyOrDuplicate(
+				basePath.Child("dynamic_library_path").Index(j),
+				libraryPaths,
+				path,
+			); validateErr != nil {
+				result = append(result, validateErr)
+			}
+
+			libraryPaths.Put(path)
+		}
+	}
+
+	return result
+}
diff --git a/pkg/management/postgres/configuration.go b/pkg/management/postgres/configuration.go
index 1445e086..3d577588 100644
--- a/pkg/management/postgres/configuration.go
+++ b/pkg/management/postgres/configuration.go
@@ -426,6 +426,18 @@ func createPostgresqlConfiguration(
 	}
 	sort.Strings(info.TemporaryTablespaces)
 
+	// Set additional extensions
+	for _, extension := range cluster.Spec.PostgresConfiguration.Extensions {
+		info.AdditionalExtensions = append(
+			info.AdditionalExtensions,
+			postgres.AdditionalExtensionConfiguration{
+				Name:                 extension.Name,
+				ExtensionControlPath: extension.ExtensionControlPath,
+				DynamicLibraryPath:   extension.DynamicLibraryPath,
+			},
+		)
+	}
+
 	// Setup minimum replay delay if we're on a replica cluster
 	if cluster.IsReplica() && cluster.Spec.ReplicaCluster.MinApplyDelay != nil {
 		info.RecoveryMinApplyDelay = cluster.Spec.ReplicaCluster.MinApplyDelay.Duration
diff --git a/pkg/management/postgres/initdb.go b/pkg/management/postgres/initdb.go
index 21d250fd..5951e2c5 100644
--- a/pkg/management/postgres/initdb.go
+++ b/pkg/management/postgres/initdb.go
@@ -168,7 +168,7 @@ func (info InitInfo) EnsureTargetDirectoriesDoNotExist(ctx context.Context) erro
 		return nil
 	}
 
-	out, err := info.GetInstance().GetPgControldata()
+	out, err := info.GetInstance(nil).GetPgControldata()
 	if err == nil {
 		contextLogger.Info("pg_controldata check on existing directory succeeded, renaming the folders", "out", out)
 		return info.renameExistingTargetDataDirectories(ctx, pgWalExists)
@@ -295,10 +295,11 @@ func (info InitInfo) CreateDataDirectory() error {
 }
 
 // GetInstance gets the PostgreSQL instance which correspond to these init information
-func (info InitInfo) GetInstance() *Instance {
+func (info InitInfo) GetInstance(cluster *apiv1.Cluster) *Instance {
 	postgresInstance := NewInstance()
 	postgresInstance.PgData = info.PgData
 	postgresInstance.StartupOptions = []string{"listen_addresses='127.0.0.1'"}
+	postgresInstance.Cluster = cluster
 	return postgresInstance
 }
 
@@ -477,7 +478,7 @@ func (info InitInfo) Bootstrap(ctx context.Context) error {
 		return err
 	}
 
-	instance := info.GetInstance()
+	instance := info.GetInstance(cluster)
 
 	// Detect an initdb bootstrap with import
 	isImportBootstrap := cluster.Spec.Bootstrap != nil &&
diff --git a/pkg/management/postgres/instance.go b/pkg/management/postgres/instance.go
index 58d768f8..0011a676 100644
--- a/pkg/management/postgres/instance.go
+++ b/pkg/management/postgres/instance.go
@@ -38,6 +38,7 @@ import (
 	"time"
 
 	"github.com/blang/semver"
+	"github.com/cloudnative-pg/machinery/pkg/envmap"
 	"github.com/cloudnative-pg/machinery/pkg/execlog"
 	"github.com/cloudnative-pg/machinery/pkg/fileutils"
 	"github.com/cloudnative-pg/machinery/pkg/fileutils/compatibility"
@@ -223,6 +224,9 @@ type Instance struct {
 	MetricsPortTLS bool
 
 	serverCertificateHandler serverCertificateHandler
+
+	// Cluster is the cluster this instance belongs to
+	Cluster *apiv1.Cluster
 }
 
 type serverCertificateHandler struct {
@@ -723,16 +727,56 @@ func (instance *Instance) Run() (*execlog.StreamingCmd, error) {
 	return streamingCmd, nil
 }
 
+// buildPostgresEnv builds the environment variables that should be used by PostgreSQL
+// to run the main process, taking care of adding any library path that is needed for
+// extensions.
 func (instance *Instance) buildPostgresEnv() []string {
 	env := instance.Env
 	if env == nil {
 		env = os.Environ()
 	}
-	env = append(env,
-		"PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj",
-		"PG_OOM_ADJUST_VALUE=0",
-	)
-	return env
+	envMap, _ := envmap.Parse(env)
+	envMap["PG_OOM_ADJUST_FILE"] = "/proc/self/oom_score_adj"
+	envMap["PG_OOM_ADJUST_VALUE"] = "0"
+
+	if instance.Cluster == nil {
+		return envMap.StringSlice()
+	}
+
+	// If there are no additional library paths, we use the environment variables
+	// of the current process
+	additionalLibraryPaths := collectLibraryPaths(instance.Cluster.Spec.PostgresConfiguration.Extensions)
+	if len(additionalLibraryPaths) == 0 {
+		return envMap.StringSlice()
+	}
+
+	// We add the additional library paths after the entries that are already
+	// available.
+	currentLibraryPath := envMap["LD_LIBRARY_PATH"]
+	if currentLibraryPath != "" {
+		currentLibraryPath += ":"
+	}
+	currentLibraryPath += strings.Join(additionalLibraryPaths, ":")
+	envMap["LD_LIBRARY_PATH"] = currentLibraryPath
+
+	return envMap.StringSlice()
+}
+
+// collectLibraryPaths returns a list of PATHS which should be added to LD_LIBRARY_PATH
+// given an extension
+func collectLibraryPaths(extensionList []apiv1.ExtensionConfiguration) []string {
+	result := make([]string, 0, len(extensionList))
+
+	for _, extension := range extensionList {
+		for _, libraryPath := range extension.LdLibraryPath {
+			result = append(
+				result,
+				filepath.Join(postgres.ExtensionsBaseDirectory, extension.Name, libraryPath),
+			)
+		}
+	}
+
+	return result
 }
 
 // WithActiveInstance execute the internal function while this
diff --git a/pkg/management/postgres/restore.go b/pkg/management/postgres/restore.go
index cf053adb..affdeb61 100644
--- a/pkg/management/postgres/restore.go
+++ b/pkg/management/postgres/restore.go
@@ -108,10 +108,10 @@ func (info InitInfo) RestoreSnapshot(ctx context.Context, cli client.Client, imm
 	// We're creating a new replica of an existing cluster, and the PVCs
 	// have been initialized by a set of VolumeSnapshots.
 	if immediate {
-		// If the instance will start as a primary, we will enter in the
+		// If the instance starts as a primary, we will enter in the
 		// same logic attaching an old primary back after a failover.
 		// We don't need that as this instance has never diverged.
-		if err := info.GetInstance().Demote(ctx, cluster); err != nil {
+		if err := info.GetInstance(nil).Demote(ctx, cluster); err != nil {
 			return fmt.Errorf("error while demoting the instance: %w", err)
 		}
 		return nil
@@ -183,7 +183,7 @@ func (info InitInfo) concludeRestore(
 	// we recover from a base which has postgresql.auto.conf
 	// the override.conf and include statement is present, what we need to do is to
 	// migrate the content
-	if _, err := info.GetInstance().migratePostgresAutoConfFile(ctx); err != nil {
+	if _, err := info.GetInstance(cluster).migratePostgresAutoConfFile(ctx); err != nil {
 		return err
 	}
 	if cluster.IsReplica() {
@@ -836,7 +836,7 @@ func (info InitInfo) WriteInitialPostgresqlConf(ctx context.Context, cluster *ap
 		return fmt.Errorf("while creating a temporary data directory: %w", err)
 	}
 
-	temporaryInstance := temporaryInitInfo.GetInstance().
+	temporaryInstance := temporaryInitInfo.GetInstance(cluster).
 		WithNamespace(info.Namespace).
 		WithClusterName(info.ClusterName)
 
@@ -903,7 +903,7 @@ func (info InitInfo) WriteRestoreHbaConf(ctx context.Context) error {
 	}
 
 	// Create only the local map referred in the HBA configuration
-	_, err = info.GetInstance().RefreshPGIdent(ctx, nil)
+	_, err = info.GetInstance(nil).RefreshPGIdent(ctx, nil)
 	return err
 }
 
@@ -914,7 +914,7 @@ func (info InitInfo) WriteRestoreHbaConf(ctx context.Context) error {
 func (info InitInfo) ConfigureInstanceAfterRestore(ctx context.Context, cluster *apiv1.Cluster, env []string) error {
 	contextLogger := log.FromContext(ctx)
 
-	instance := info.GetInstance()
+	instance := info.GetInstance(cluster)
 	instance.Env = env
 
 	if err := instance.VerifyPgDataCoherence(ctx); err != nil {
diff --git a/pkg/postgres/configuration.go b/pkg/postgres/configuration.go
index 118dcd5c..b5259c26 100644
--- a/pkg/postgres/configuration.go
+++ b/pkg/postgres/configuration.go
@@ -23,7 +23,10 @@ import (
 	"bytes"
 	"crypto/sha256"
 	"fmt"
+	"iter"
 	"math"
+	"path/filepath"
+	"slices"
 	"sort"
 	"strings"
 	"text/template"
@@ -245,6 +248,15 @@ local {{.Username}} postgres
 
 	// SynchronousStandbyNames is the postgresql parameter key for synchronous standbys
 	SynchronousStandbyNames = "synchronous_standby_names"
+
+	// ExtensionControlPath is the postgresql parameter key for extension_control_path
+	ExtensionControlPath = "extension_control_path"
+
+	// DynamicLibraryPath is the postgresql parameter key dynamic_library_path
+	DynamicLibraryPath = "dynamic_library_path"
+
+	// ExtensionsBaseDirectory is the base directory to store ImageVolume Extensions
+	ExtensionsBaseDirectory = "/extensions"
 )
 
 // hbaTemplate is the template used to create the HBA configuration
@@ -337,6 +349,9 @@ type ConfigurationInfo struct {
 
 	// Minimum apply delay of transaction
 	RecoveryMinApplyDelay time.Duration
+
+	// The list of additional extensions to be loaded into the PostgreSQL configuration
+	AdditionalExtensions []AdditionalExtensionConfiguration
 }
 
 // getAlterSystemEnabledValue returns a config compatible value for IsAlterSystemEnabled
@@ -652,7 +667,7 @@ func CreatePostgresqlConfiguration(info ConfigurationInfo) *PgConfiguration {
 	ignoreFixedSettingsFromUser := info.IncludingMandatory || !info.PreserveFixedSettingsFromUser
 
 	// Set all the default settings
-	setDefaultConfigurations(info, configuration)
+	configuration.setDefaultConfigurations(info)
 
 	// Apply all the values from the user, overriding defaults,
 	// ignoring those which are fixed if ignoreFixedSettingsFromUser is true
@@ -728,10 +743,10 @@ func CreatePostgresqlConfiguration(info ConfigurationInfo) *PgConfiguration {
 
 	if info.IncludingSharedPreloadLibraries {
 		// Set all managed shared preload libraries
-		setManagedSharedPreloadLibraries(info, configuration)
+		configuration.setManagedSharedPreloadLibraries(info)
 
 		// Set all user provided shared preload libraries
-		setUserSharedPreloadLibraries(info, configuration)
+		configuration.setUserSharedPreloadLibraries(info)
 	}
 
 	// Apply the list of temporary tablespaces
@@ -739,33 +754,39 @@ func CreatePostgresqlConfiguration(info ConfigurationInfo) *PgConfiguration {
 		configuration.OverwriteConfig("temp_tablespaces", strings.Join(info.TemporaryTablespaces, ","))
 	}
 
+	// Setup additional extensions
+	if len(info.AdditionalExtensions) > 0 {
+		configuration.setExtensionControlPath(info)
+		configuration.setDynamicLibraryPath(info)
+	}
+
 	return configuration
 }
 
 // setDefaultConfigurations sets all default configurations into the configuration map
 // from the provided info
-func setDefaultConfigurations(info ConfigurationInfo, configuration *PgConfiguration) {
+func (p *PgConfiguration) setDefaultConfigurations(info ConfigurationInfo) {
 	// start from the global default settings
 	for key, value := range info.Settings.GlobalDefaultSettings {
-		configuration.OverwriteConfig(key, value)
+		p.OverwriteConfig(key, value)
 	}
 
 	// apply settings relative to a certain PostgreSQL version
 	for constraints, settings := range info.Settings.DefaultSettings {
 		if constraints.Min <= info.MajorVersion && info.MajorVersion < constraints.Max {
 			for key, value := range settings {
-				configuration.OverwriteConfig(key, value)
+				p.OverwriteConfig(key, value)
 			}
 		}
 	}
 }
 
 // setManagedSharedPreloadLibraries sets all additional preloaded libraries
-func setManagedSharedPreloadLibraries(info ConfigurationInfo, configuration *PgConfiguration) {
+func (p *PgConfiguration) setManagedSharedPreloadLibraries(info ConfigurationInfo) {
 	for _, extension := range ManagedExtensions {
 		if extension.IsUsed(info.UserSettings) {
 			for _, library := range extension.SharedPreloadLibraries {
-				configuration.AddSharedPreloadLibrary(library)
+				p.AddSharedPreloadLibrary(library)
 			}
 		}
 	}
@@ -775,8 +796,8 @@ func setManagedSharedPreloadLibraries(info ConfigurationInfo, configuration *PgC
 // The resulting list will have all the user provided libraries, followed by all the ones managed
 // by the operator, removing any duplicate and keeping the first occurrence in case of duplicates.
 // Therefore the user provided order is preserved, if an overlap (with the ones already present) happens
-func setUserSharedPreloadLibraries(info ConfigurationInfo, configuration *PgConfiguration) {
-	oldLibraries := strings.Split(configuration.GetConfig(SharedPreloadLibraries), ",")
+func (p *PgConfiguration) setUserSharedPreloadLibraries(info ConfigurationInfo) {
+	oldLibraries := strings.Split(p.GetConfig(SharedPreloadLibraries), ",")
 	dedupedLibraries := make(map[string]bool, len(oldLibraries)+len(info.AdditionalSharedPreloadLibraries))
 	var libraries []string
 	for _, library := range append(info.AdditionalSharedPreloadLibraries, oldLibraries...) {
@@ -790,7 +811,7 @@ func setUserSharedPreloadLibraries(info ConfigurationInfo, configuration *PgConf
 		}
 	}
 	if len(libraries) > 0 {
-		configuration.OverwriteConfig(SharedPreloadLibraries, strings.Join(libraries, ","))
+		p.OverwriteConfig(SharedPreloadLibraries, strings.Join(libraries, ","))
 	}
 }
 
@@ -821,3 +842,99 @@ func CreatePostgresqlConfFile(configuration *PgConfiguration) (string, string) {
 func escapePostgresConfValue(value string) string {
 	return fmt.Sprintf("'%v'", strings.ReplaceAll(value, "'", "''"))
 }
+
+// AdditionalExtensionConfiguration is the configuration for an Extension added via ImageVolume
+type AdditionalExtensionConfiguration struct {
+	// The name of the Extension
+	Name string
+
+	// The list of directories that should be added to ExtensionControlPath.
+	ExtensionControlPath []string
+
+	// The list of directories that should be added to DynamicLibraryPath.
+	DynamicLibraryPath []string
+}
+
+// absolutizePaths returns an iterator over the passed paths, absolutized
+// using the name of the extension
+func (ext *AdditionalExtensionConfiguration) absolutizePaths(paths []string) iter.Seq[string] {
+	return func(yield func(string) bool) {
+		for _, path := range paths {
+			if !yield(filepath.Join(ExtensionsBaseDirectory, ext.Name, path)) {
+				break
+			}
+		}
+	}
+}
+
+// getRuntimeExtensionControlPath collects the absolute directories to be put
+// into the `extension_control_path` GUC to support this additional extension
+func (ext *AdditionalExtensionConfiguration) getRuntimeExtensionControlPath() iter.Seq[string] {
+	paths := []string{"share"}
+	if len(ext.ExtensionControlPath) > 0 {
+		paths = ext.ExtensionControlPath
+	}
+
+	return ext.absolutizePaths(paths)
+}
+
+// getDynamicLibraryPath collects the absolute directories to be put
+// into the `dynamic_library_path` GUC to support this additional extension
+func (ext *AdditionalExtensionConfiguration) getDynamicLibraryPath() iter.Seq[string] {
+	paths := []string{"lib"}
+	if len(ext.DynamicLibraryPath) > 0 {
+		paths = ext.DynamicLibraryPath
+	}
+
+	return ext.absolutizePaths(paths)
+}
+
+// setExtensionControlPath manages the `extension_control_path` GUC, merging
+// the paths defined by the user with the ones provided by the
+// `.spec.postgresql.extensions` stanza
+func (p *PgConfiguration) setExtensionControlPath(info ConfigurationInfo) {
+	extensionControlPath := []string{"$system"}
+
+	for _, extension := range info.AdditionalExtensions {
+		extensionControlPath = slices.AppendSeq(
+			extensionControlPath,
+			extension.getRuntimeExtensionControlPath(),
+		)
+	}
+
+	extensionControlPath = slices.AppendSeq(
+		extensionControlPath,
+		strings.SplitSeq(p.GetConfig(ExtensionControlPath), ":"),
+	)
+
+	extensionControlPath = slices.DeleteFunc(
+		extensionControlPath,
+		func(s string) bool { return s == "" },
+	)
+
+	p.OverwriteConfig(ExtensionControlPath, strings.Join(extensionControlPath, ":"))
+}
+
+// setDynamicLibraryPath manages the `dynamic_library_path` GUC, merging the
+// paths defined by the user with the ones provided by the
+// `.spec.postgresql.extensions` stanza
+func (p *PgConfiguration) setDynamicLibraryPath(info ConfigurationInfo) {
+	dynamicLibraryPath := []string{"$libdir"}
+
+	for _, extension := range info.AdditionalExtensions {
+		dynamicLibraryPath = slices.AppendSeq(
+			dynamicLibraryPath,
+			extension.getDynamicLibraryPath())
+	}
+
+	dynamicLibraryPath = slices.AppendSeq(
+		dynamicLibraryPath,
+		strings.SplitSeq(p.GetConfig(DynamicLibraryPath), ":"))
+
+	dynamicLibraryPath = slices.DeleteFunc(
+		dynamicLibraryPath,
+		func(s string) bool { return s == "" },
+	)
+
+	p.OverwriteConfig(DynamicLibraryPath, strings.Join(dynamicLibraryPath, ":"))
+}
diff --git a/pkg/specs/volumes.go b/pkg/specs/volumes.go
index 4ac7dbdd..f77ff6c0 100644
--- a/pkg/specs/volumes.go
+++ b/pkg/specs/volumes.go
@@ -22,6 +22,7 @@ package specs
 import (
 	"fmt"
 	"path"
+	"path/filepath"
 	"sort"
 	"strings"
 
@@ -147,6 +148,9 @@ func createPostgresVolumes(cluster *apiv1.Cluster, podName string) []corev1.Volu
 	if cluster.ShouldCreateProjectedVolume() {
 		result = append(result, createProjectedVolume(cluster))
 	}
+
+	result = append(result, createExtensionVolumes(cluster)...)
+
 	return result
 }
 
@@ -275,6 +279,9 @@ func CreatePostgresVolumeMounts(cluster apiv1.Cluster) []corev1.VolumeMount {
 			)
 		}
 	}
+
+	volumeMounts = append(volumeMounts, createExtensionVolumeMounts(&cluster)...)
+
 	return volumeMounts
 }
 
@@ -313,3 +320,33 @@ func createProjectedVolume(cluster *apiv1.Cluster) corev1.Volume {
 		},
 	}
 }
+
+func createExtensionVolumes(cluster *apiv1.Cluster) []corev1.Volume {
+	extensionVolumes := make([]corev1.Volume, 0, len(cluster.Spec.PostgresConfiguration.Extensions))
+	for _, extension := range cluster.Spec.PostgresConfiguration.Extensions {
+		extensionVolumes = append(extensionVolumes,
+			corev1.Volume{
+				Name: extension.Name,
+				VolumeSource: corev1.VolumeSource{
+					Image: &extension.ImageVolumeSource,
+				},
+			},
+		)
+	}
+
+	return extensionVolumes
+}
+
+func createExtensionVolumeMounts(cluster *apiv1.Cluster) []corev1.VolumeMount {
+	extensionVolumeMounts := make([]corev1.VolumeMount, 0, len(cluster.Spec.PostgresConfiguration.Extensions))
+	for _, extension := range cluster.Spec.PostgresConfiguration.Extensions {
+		extensionVolumeMounts = append(extensionVolumeMounts,
+			corev1.VolumeMount{
+				Name:      extension.Name,
+				MountPath: filepath.Join(postgres.ExtensionsBaseDirectory, extension.Name),
+			},
+		)
+	}
+
+	return extensionVolumeMounts
+}



Test patch:
diff --git a/internal/webhook/v1/cluster_webhook_test.go b/internal/webhook/v1/cluster_webhook_test.go
index 3c62c700..f858f112 100644
--- a/internal/webhook/v1/cluster_webhook_test.go
+++ b/internal/webhook/v1/cluster_webhook_test.go
@@ -5258,7 +5258,7 @@ var _ = Describe("validatePluginConfiguration", func() {
 	})
 })
 
-var _ = Describe("", func() {
+var _ = Describe("liveness probe validation", func() {
 	var v *ClusterCustomValidator
 	BeforeEach(func() {
 		v = &ClusterCustomValidator{}
@@ -5298,6 +5298,230 @@ var _ = Describe("", func() {
 	})
 })
 
+var _ = Describe("validateExtensions", func() {
+	var v *ClusterCustomValidator
+	BeforeEach(func() {
+		v = &ClusterCustomValidator{}
+	})
+
+	It("returns no error when extensions are not specified", func() {
+		cluster := &apiv1.Cluster{}
+		Expect(v.validateExtensions(cluster)).To(BeEmpty())
+	})
+
+	It("returns no error if the specified extensions are unique", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+						},
+						{
+							Name: "extTwo",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		Expect(v.validateExtensions(cluster)).To(BeEmpty())
+	})
+
+	It("returns an error per duplicate extension name", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+						},
+						{
+							Name: "extTwo",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+						},
+						{
+							Name: "extTwo",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extTwo:1",
+							},
+						},
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne:1",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		err := v.validateExtensions(cluster)
+		Expect(err).To(HaveLen(2))
+		Expect(err[0].BadValue).To(Equal("extTwo"))
+		Expect(err[1].BadValue).To(Equal("extOne"))
+	})
+
+	It("returns multiple errors for both invalid ExtensionControlPath and DynamicLibraryPath", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+							ExtensionControlPath: []string{
+								"/valid/path",
+								"",
+							},
+							DynamicLibraryPath: []string{
+								"",
+								"/valid/lib/path",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		err := v.validateExtensions(cluster)
+		Expect(err).To(HaveLen(2))
+		Expect(err[0].Field).To(ContainSubstring("extensions[0].extension_control_path[1]"))
+		Expect(err[1].Field).To(ContainSubstring("extensions[0].dynamic_library_path[0]"))
+	})
+
+	It("returns no error when ExtensionControlPath and DynamicLibraryPath are valid", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+							ExtensionControlPath: []string{
+								"/usr/share/postgresql/extension",
+								"/opt/custom/extensions",
+							},
+							DynamicLibraryPath: []string{
+								"/usr/lib/postgresql/lib",
+								"/opt/custom/lib",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		Expect(v.validateExtensions(cluster)).To(BeEmpty())
+	})
+
+	It("returns errors for duplicate ExtensionControlPath entries", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+							ExtensionControlPath: []string{
+								"/usr/share/postgresql/extension",
+								"/opt/custom/extensions",
+								"/usr/share/postgresql/extension", // duplicate
+							},
+						},
+					},
+				},
+			},
+		}
+
+		err := v.validateExtensions(cluster)
+		Expect(err).To(HaveLen(1))
+		Expect(err[0].Type).To(Equal(field.ErrorTypeDuplicate))
+		Expect(err[0].Field).To(ContainSubstring("extensions[0].extension_control_path[2]"))
+		Expect(err[0].BadValue).To(Equal("/usr/share/postgresql/extension"))
+	})
+
+	It("returns errors for duplicate DynamicLibraryPath entries", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+							DynamicLibraryPath: []string{
+								"/usr/lib/postgresql/lib",
+								"/opt/custom/lib",
+								"/usr/lib/postgresql/lib",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		err := v.validateExtensions(cluster)
+		Expect(err).To(HaveLen(1))
+		Expect(err[0].Type).To(Equal(field.ErrorTypeDuplicate))
+		Expect(err[0].Field).To(ContainSubstring("extensions[0].dynamic_library_path[2]"))
+		Expect(err[0].BadValue).To(Equal("/usr/lib/postgresql/lib"))
+	})
+
+	It("returns errors for duplicates in both ExtensionControlPath and DynamicLibraryPath", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "extOne",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "extOne",
+							},
+							ExtensionControlPath: []string{
+								"/usr/share/postgresql/extension",
+								"/usr/share/postgresql/extension",
+							},
+							DynamicLibraryPath: []string{
+								"/usr/lib/postgresql/lib",
+								"/usr/lib/postgresql/lib",
+							},
+						},
+					},
+				},
+			},
+		}
+
+		err := v.validateExtensions(cluster)
+		Expect(err).To(HaveLen(2))
+
+		Expect(err[0].Type).To(Equal(field.ErrorTypeDuplicate))
+		Expect(err[0].BadValue).To(Equal("/usr/share/postgresql/extension"))
+
+		Expect(err[1].Type).To(Equal(field.ErrorTypeDuplicate))
+		Expect(err[1].BadValue).To(Equal("/usr/lib/postgresql/lib"))
+	})
+})
+
 var _ = Describe("getInTreeBarmanWarnings", func() {
 	It("returns no warnings when BarmanObjectStore is not configured", func() {
 		cluster := &apiv1.Cluster{
diff --git a/pkg/management/postgres/instance_test.go b/pkg/management/postgres/instance_test.go
index 8aade973..5801cc3c 100644
--- a/pkg/management/postgres/instance_test.go
+++ b/pkg/management/postgres/instance_test.go
@@ -24,8 +24,11 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/cloudnative-pg/machinery/pkg/fileutils"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
 	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
@@ -266,3 +269,95 @@ var _ = Describe("ALTER SYSTEM enable and disable in PostgreSQL <17", func() {
 		Expect(info.Mode()).To(BeEquivalentTo(0o400))
 	})
 })
+
+var _ = Describe("buildPostgresEnv", func() {
+	var cluster apiv1.Cluster
+	var instance Instance
+
+	BeforeEach(func() {
+		err := os.Unsetenv("LD_LIBRARY_PATH")
+		Expect(err).ToNot(HaveOccurred())
+
+		cluster = apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "cluster-example",
+				Namespace: "default",
+			},
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "foo",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "foo:dev",
+							},
+						},
+						{
+							Name: "bar",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "bar:dev",
+							},
+						},
+					},
+				},
+			},
+		}
+		instance.Cluster = &cluster
+	})
+
+	Context("Extensions enabled, LD_LIBRARY_PATH undefined", func() {
+		It("should be empty by default", func() {
+			ldLibraryPath := getLibraryPathFromEnv(instance.buildPostgresEnv())
+			Expect(ldLibraryPath).To(BeEmpty())
+		})
+	})
+
+	Context("Extensions enabled, LD_LIBRARY_PATH defined", func() {
+		const (
+			path1 = postgres.ExtensionsBaseDirectory + "/foo/syslib"
+			path2 = postgres.ExtensionsBaseDirectory + "/foo/sample"
+			path3 = postgres.ExtensionsBaseDirectory + "/bar/syslib"
+			path4 = postgres.ExtensionsBaseDirectory + "/bar/sample"
+		)
+		finalPaths := strings.Join([]string{path1, path2, path3, path4}, ":")
+
+		BeforeEach(func() {
+			cluster.Spec.PostgresConfiguration.Extensions[0].LdLibraryPath = []string{"/syslib", "sample/"}
+			cluster.Spec.PostgresConfiguration.Extensions[1].LdLibraryPath = []string{"./syslib", "./sample/"}
+		})
+
+		It("should be defined", func() {
+			ldLibraryPath := getLibraryPathFromEnv(instance.buildPostgresEnv())
+			Expect(ldLibraryPath).To(Equal(fmt.Sprintf("LD_LIBRARY_PATH=%s", finalPaths)))
+		})
+		It("should retain existing values", func() {
+			GinkgoT().Setenv("LD_LIBRARY_PATH", "/my/library/path")
+
+			ldLibraryPath := getLibraryPathFromEnv(instance.buildPostgresEnv())
+			Expect(ldLibraryPath).To(BeEquivalentTo(fmt.Sprintf("LD_LIBRARY_PATH=/my/library/path:%s", finalPaths)))
+		})
+	})
+
+	Context("Extensions disabled", func() {
+		BeforeEach(func() {
+			cluster.Spec.PostgresConfiguration.Extensions = []apiv1.ExtensionConfiguration{}
+		})
+		It("LD_LIBRARY_PATH should be empty", func() {
+			ldLibraryPath := getLibraryPathFromEnv(instance.buildPostgresEnv())
+			Expect(ldLibraryPath).To(BeEmpty())
+		})
+	})
+})
+
+func getLibraryPathFromEnv(envs []string) string {
+	var ldLibraryPath string
+
+	for i := len(envs) - 1; i >= 0; i-- {
+		if strings.HasPrefix(envs[i], "LD_LIBRARY_PATH=") {
+			ldLibraryPath = envs[i]
+			break
+		}
+	}
+
+	return ldLibraryPath
+}
diff --git a/pkg/postgres/configuration_test.go b/pkg/postgres/configuration_test.go
index 415c896d..e6366d29 100644
--- a/pkg/postgres/configuration_test.go
+++ b/pkg/postgres/configuration_test.go
@@ -391,3 +391,105 @@ var _ = Describe("recovery_min_apply_delay", func() {
 		Expect(config.GetConfig(ParameterRecoveryMinApplyDelay)).To(Equal("3600s"))
 	})
 })
+
+var _ = Describe("PostgreSQL Extensions", func() {
+	Context("configuring extension_control_path and dynamic_library_path", func() {
+		const (
+			share1 = ExtensionsBaseDirectory + "/postgis/share"
+			share2 = ExtensionsBaseDirectory + "/pgvector/share"
+			lib1   = ExtensionsBaseDirectory + "/postgis/lib"
+			lib2   = ExtensionsBaseDirectory + "/pgvector/lib"
+		)
+		sharePaths := strings.Join([]string{share1, share2}, ":")
+		libPaths := strings.Join([]string{lib1, lib2}, ":")
+
+		It("both empty when there are no Extensions defined", func() {
+			info := ConfigurationInfo{
+				Settings:           CnpgConfigurationSettings,
+				MajorVersion:       18,
+				IncludingMandatory: true,
+			}
+			config := CreatePostgresqlConfiguration(info)
+			Expect(config.GetConfig(ExtensionControlPath)).To(BeEmpty())
+			Expect(config.GetConfig(DynamicLibraryPath)).To(BeEmpty())
+		})
+
+		It("configures them when an Extension is defined", func() {
+			info := ConfigurationInfo{
+				Settings:           CnpgConfigurationSettings,
+				MajorVersion:       18,
+				IncludingMandatory: true,
+				AdditionalExtensions: []AdditionalExtensionConfiguration{
+					{
+						Name: "postgis",
+					},
+					{
+						Name: "pgvector",
+					},
+				},
+			}
+			config := CreatePostgresqlConfiguration(info)
+			Expect(config.GetConfig(ExtensionControlPath)).To(BeEquivalentTo("$system:" + sharePaths))
+			Expect(config.GetConfig(DynamicLibraryPath)).To(BeEquivalentTo("$libdir:" + libPaths))
+		})
+
+		It("correctly merges the configuration with UserSettings", func() {
+			info := ConfigurationInfo{
+				Settings:           CnpgConfigurationSettings,
+				MajorVersion:       18,
+				IncludingMandatory: true,
+				UserSettings: map[string]string{
+					ExtensionControlPath: "/my/extension/path",
+					DynamicLibraryPath:   "/my/library/path",
+				},
+				AdditionalExtensions: []AdditionalExtensionConfiguration{
+					{
+						Name: "postgis",
+					},
+					{
+						Name: "pgvector",
+					},
+				},
+			}
+			config := CreatePostgresqlConfiguration(info)
+			Expect(config.GetConfig(ExtensionControlPath)).To(BeEquivalentTo("$system:" + sharePaths + ":/my/extension/path"))
+			Expect(config.GetConfig(DynamicLibraryPath)).To(BeEquivalentTo("$libdir:" + libPaths + ":/my/library/path"))
+		})
+
+		It("when custom paths are provided (multi-extension)", func() {
+			const (
+				geoShare1     = ExtensionsBaseDirectory + "/geo/postgis/share"
+				geoShare2     = ExtensionsBaseDirectory + "/geo/pgrouting/share"
+				geoLib1       = ExtensionsBaseDirectory + "/geo/postgis/lib"
+				geoLib2       = ExtensionsBaseDirectory + "/geo/pgrouting/lib"
+				utilityShare1 = ExtensionsBaseDirectory + "/utility/pgaudit/share"
+				utilityShare2 = ExtensionsBaseDirectory + "/utility/pg-failover-slots/share"
+				utilityLib1   = ExtensionsBaseDirectory + "/utility/pgaudit/lib"
+				utilityLib2   = ExtensionsBaseDirectory + "/utility/pg-failover-slots/lib"
+			)
+			sharePaths = strings.Join([]string{geoShare1, geoShare2, utilityShare1, utilityShare2}, ":")
+			libPaths = strings.Join([]string{geoLib1, geoLib2, utilityLib1, utilityLib2}, ":")
+
+			info := ConfigurationInfo{
+				Settings:           CnpgConfigurationSettings,
+				MajorVersion:       18,
+				IncludingMandatory: true,
+				AdditionalExtensions: []AdditionalExtensionConfiguration{
+					{
+						Name:                 "geo",
+						ExtensionControlPath: []string{"postgis/share", "./pgrouting/share"},
+						DynamicLibraryPath:   []string{"postgis/lib/", "/pgrouting/lib/"},
+					},
+					{
+						Name:                 "utility",
+						ExtensionControlPath: []string{"pgaudit/share", "./pg-failover-slots/share"},
+						DynamicLibraryPath:   []string{"pgaudit/lib/", "/pg-failover-slots/lib/"},
+					},
+				},
+			}
+			config := CreatePostgresqlConfiguration(info)
+			Expect(config.GetConfig(ExtensionControlPath)).To(BeEquivalentTo("$system:" + sharePaths))
+			Expect(config.GetConfig(DynamicLibraryPath)).To(BeEquivalentTo("$libdir:" + libPaths))
+		})
+	})
+})
diff --git a/pkg/specs/volumes_test.go b/pkg/specs/volumes_test.go
index 6370da93..1cce8d69 100644
--- a/pkg/specs/volumes_test.go
+++ b/pkg/specs/volumes_test.go
@@ -22,9 +22,11 @@ package specs
 import (
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -521,3 +523,78 @@ var _ = Describe("createEphemeralVolume", func() {
 		Expect(*ephemeralVolume.VolumeSource.EmptyDir.SizeLimit).To(Equal(quantity))
 	})
 })
+
+var _ = Describe("ImageVolume Extensions", func() {
+	var cluster apiv1.Cluster
+
+	BeforeEach(func() {
+		cluster = apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "cluster-example",
+				Namespace: "default",
+			},
+			Spec: apiv1.ClusterSpec{
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Extensions: []apiv1.ExtensionConfiguration{
+						{
+							Name: "foo",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "foo:dev",
+							},
+						},
+						{
+							Name: "bar",
+							ImageVolumeSource: corev1.ImageVolumeSource{
+								Reference: "bar:dev",
+							},
+						},
+					},
+				},
+			},
+		}
+	})
+
+	Context("createExtensionVolumes", func() {
+		When("Extensions are disabled", func() {
+			It("shouldn't create Volumes", func() {
+				cluster.Spec.PostgresConfiguration.Extensions = []apiv1.ExtensionConfiguration{}
+				extensionVolumes := createExtensionVolumes(&cluster)
+				Expect(extensionVolumes).To(BeEmpty())
+			})
+		})
+		When("Extensions are enabled", func() {
+			It("should create a Volume for each Extension", func() {
+				extensionVolumes := createExtensionVolumes(&cluster)
+				Expect(len(extensionVolumes)).To(BeEquivalentTo(2))
+				Expect(extensionVolumes[0].Name).To(Equal("foo"))
+				Expect(extensionVolumes[0].VolumeSource.Image.Reference).To(Equal("foo:dev"))
+				Expect(extensionVolumes[1].Name).To(Equal("bar"))
+				Expect(extensionVolumes[1].VolumeSource.Image.Reference).To(Equal("bar:dev"))
+			})
+		})
+	})
+
+	Context("createExtensionVolumeMounts", func() {
+		When("Extensions are disabled", func() {
+			It("shouldn't create VolumeMounts", func() {
+				cluster.Spec.PostgresConfiguration.Extensions = []apiv1.ExtensionConfiguration{}
+				extensionVolumeMounts := createExtensionVolumeMounts(&cluster)
+				Expect(extensionVolumeMounts).To(BeEmpty())
+			})
+		})
+		When("Extensions are enabled", func() {
+			It("should create a VolumeMount for each Extension", func() {
+				const (
+					fooMountPath = postgres.ExtensionsBaseDirectory + "/foo"
+					barMountPath = postgres.ExtensionsBaseDirectory + "/bar"
+				)
+				extensionVolumeMounts := createExtensionVolumeMounts(&cluster)
+				Expect(len(extensionVolumeMounts)).To(BeEquivalentTo(2))
+				Expect(extensionVolumeMounts[0].Name).To(Equal("foo"))
+				Expect(extensionVolumeMounts[0].MountPath).To(Equal(fooMountPath))
+				Expect(extensionVolumeMounts[1].Name).To(Equal("bar"))
+				Expect(extensionVolumeMounts[1].MountPath).To(Equal(barMountPath))
+			})
+		})
+	})
+})
diff --git a/tests/e2e/fixtures/imagevolume_extensions/cluster-with-extensions.yaml.template b/tests/e2e/fixtures/imagevolume_extensions/cluster-with-extensions.yaml.template
new file mode 100644
index 00000000..332cc3ef
--- /dev/null
+++ b/tests/e2e/fixtures/imagevolume_extensions/cluster-with-extensions.yaml.template
@@ -0,0 +1,36 @@
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: postgresql-with-extensions
+spec:
+  instances: 3
+
+  postgresql:
+    parameters:
+      max_connections: "110"
+      log_checkpoints: "on"
+      log_lock_waits: "on"
+      log_min_duration_statement: '1000'
+      log_statement: 'ddl'
+      log_temp_files: '1024'
+      log_autovacuum_min_duration: '1s'
+      log_replication_commands: 'on'
+    extensions:
+      - name: postgis
+        ld_library_path:
+          - system
+        image:
+          reference: ghcr.io/niccolofei/postgis:18beta1-master-bullseye # wokeignore:rule=master
+
+  bootstrap:
+    initdb:
+      database: app
+      owner: app
+
+  # Persistent storage configuration
+  storage:
+    storageClass: ${E2E_DEFAULT_STORAGE_CLASS}
+    size: 1Gi
+  walStorage:
+    storageClass: ${E2E_DEFAULT_STORAGE_CLASS}
+    size: 1Gi
diff --git a/tests/e2e/fixtures/imagevolume_extensions/database.yaml.template b/tests/e2e/fixtures/imagevolume_extensions/database.yaml.template
new file mode 100644
index 00000000..af3929cb
--- /dev/null
+++ b/tests/e2e/fixtures/imagevolume_extensions/database.yaml.template
@@ -0,0 +1,26 @@
+apiVersion: postgresql.cnpg.io/v1
+kind: Database
+metadata:
+  name: app
+spec:
+  name: app
+  owner: app
+  cluster:
+    name: postgresql-with-extensions
+  extensions:
+  - name: postgis
+    ensure: present
+  - name: postgis_raster
+    ensure: present
+  - name: postgis_sfcgal
+    ensure: present
+  - name: fuzzystrmatch
+    ensure: present
+  - name: address_standardizer
+    ensure: present
+  - name: address_standardizer_data_us
+    ensure: present
+  - name: postgis_tiger_geocoder
+    ensure: present
+  - name: postgis_topology
+    ensure: present
diff --git a/tests/e2e/imagevolume_extensions_test.go b/tests/e2e/imagevolume_extensions_test.go
new file mode 100644
index 00000000..51c4c32f
--- /dev/null
+++ b/tests/e2e/imagevolume_extensions_test.go
@@ -0,0 +1,234 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package e2e
+
+import (
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/blang/semver"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
+	"github.com/cloudnative-pg/cloudnative-pg/tests"
+	"github.com/cloudnative-pg/cloudnative-pg/tests/utils/clusterutils"
+	postgresutils "github.com/cloudnative-pg/cloudnative-pg/tests/utils/postgres"
+	"github.com/cloudnative-pg/cloudnative-pg/tests/utils/timeouts"
+	"github.com/cloudnative-pg/cloudnative-pg/tests/utils/yaml"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("ImageVolume Extensions", Label(tests.LabelPostgresConfiguration), func() {
+	const (
+		clusterManifest  = fixturesDir + "/imagevolume_extensions/cluster-with-extensions.yaml.template"
+		databaseManifest = fixturesDir + "/imagevolume_extensions/database.yaml.template"
+		namespacePrefix  = "cluster-imagevolume-extensions"
+		level            = tests.Low
+	)
+
+	BeforeEach(func() {
+		if testLevelEnv.Depth < int(level) {
+			Skip("Test depth is lower than the amount requested for this test")
+		}
+		if !IsLocal() {
+			Skip("This test is only run on local cluster")
+		}
+		if env.PostgresVersion < 18 {
+			Skip("This test is only run on PostgreSQL v18 or greater")
+		}
+		// Require K8S 1.33 or greater
+		versionInfo, err := env.Interface.Discovery().ServerVersion()
+		Expect(err).NotTo(HaveOccurred())
+		currentVersion, err := semver.Parse(strings.TrimPrefix(versionInfo.String(), "v"))
+		Expect(err).NotTo(HaveOccurred())
+		k8s133, err := semver.Parse("1.33.0")
+		Expect(err).NotTo(HaveOccurred())
+		if currentVersion.LT(k8s133) {
+			Skip("This test runs only on Kubernetes 1.33 or greater")
+		}
+	})
+	var namespace, clusterName, databaseName string
+	var err error
+
+	assertVolumeMounts := func(podList *corev1.PodList, imageVolumeExtension string) {
+		found := false
+		mountPath := filepath.Join(postgres.ExtensionsBaseDirectory, imageVolumeExtension)
+		for _, pod := range podList.Items {
+			for _, volumeMount := range pod.Spec.Containers[0].VolumeMounts {
+				if volumeMount.Name == imageVolumeExtension && volumeMount.MountPath == mountPath {
+					found = true
+				}
+			}
+		}
+		Expect(found).To(BeTrue())
+	}
+
+	assertVolumes := func(podList *corev1.PodList, imageVolumeExtension string) {
+		found := false
+		for _, pod := range podList.Items {
+			for _, volume := range pod.Spec.Volumes {
+				if volume.Name == imageVolumeExtension && volume.Image.Reference != "" {
+					found = true
+				}
+			}
+		}
+		Expect(found).To(BeTrue())
+	}
+
+	assertExtensions := func(namespace, databaseName string) {
+		database := &apiv1.Database{}
+		databaseNamespacedName := types.NamespacedName{
+			Namespace: namespace,
+			Name:      databaseName,
+		}
+		Eventually(func(g Gomega) {
+			err := env.Client.Get(env.Ctx, databaseNamespacedName, database)
+			g.Expect(err).ToNot(HaveOccurred())
+			g.Expect(database.Status.Applied).Should(HaveValue(BeTrue()))
+			g.Expect(database.Status.Message).Should(BeEmpty())
+			for _, extension := range database.Status.Extensions {
+				Expect(extension.Applied).Should(HaveValue(BeTrue()))
+				Expect(extension.Message).Should(BeEmpty())
+			}
+		}, 60).WithPolling(10 * time.Second).Should(Succeed())
+	}
+
+	assertPostgis := func(namespace, clusterName string) {
+		row, err := postgresutils.RunQueryRowOverForward(
+			env.Ctx,
+			env.Client,
+			env.Interface,
+			env.RestClientConfig,
+			namespace, clusterName, postgresutils.AppDBName,
+			apiv1.ApplicationUserSecretSuffix,
+			"SELECT ST_AsText(geom) AS wkt, ST_Area(geom) AS area"+
+				" FROM (SELECT ST_GeomFromText('POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))', 4326) AS geom) AS subquery;")
+		Expect(err).ToNot(HaveOccurred())
+
+		var wkt, area string
+		err = row.Scan(&wkt, &area)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(wkt).To(BeEquivalentTo("POLYGON((0 0,0 10,10 10,10 0,0 0))"))
+		Expect(area).To(BeEquivalentTo("100"))
+	}
+
+	assertVector := func(namespace, clusterName string) {
+		row, err := postgresutils.RunQueryRowOverForward(
+			env.Ctx,
+			env.Client,
+			env.Interface,
+			env.RestClientConfig,
+			namespace, clusterName, postgresutils.AppDBName,
+			apiv1.ApplicationUserSecretSuffix,
+			"SELECT"+
+				" '[1, 2, 3]'::vector AS vec1,"+
+				" '[4, 5, 6]'::vector AS vec2,"+
+				" cosine_distance('[1, 2, 3]'::vector, '[4, 5, 6]'::vector) AS cosine_sim,"+
+				" l2_distance('[1, 2, 3]'::vector, '[4, 5, 6]'::vector) AS l2_dist,"+
+				" inner_product('[1, 2, 3]'::vector, '[4, 5, 6]'::vector) AS dot_product;")
+		Expect(err).ToNot(HaveOccurred())
+
+		var vec1, vec2, cosineDist, distance, dotProduct string
+		err = row.Scan(&vec1, &vec2, &cosineDist, &distance, &dotProduct)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(vec1).To(BeEquivalentTo("[1,2,3]"))
+		Expect(vec2).To(BeEquivalentTo("[4,5,6]"))
+		Expect(cosineDist).To(BeEquivalentTo("0.025368153802923787"))
+		Expect(distance).To(BeEquivalentTo("5.196152422706632"))
+		Expect(dotProduct).To(BeEquivalentTo("32"))
+	}
+
+	It("can use ImageVolume extensions", func() {
+		By("creating the cluster", func() {
+			namespace, err = env.CreateUniqueTestNamespace(env.Ctx, env.Client, namespacePrefix)
+			Expect(err).ToNot(HaveOccurred())
+			clusterName, err = yaml.GetResourceNameFromYAML(env.Scheme, clusterManifest)
+			Expect(err).ToNot(HaveOccurred())
+			databaseName, err = yaml.GetResourceNameFromYAML(env.Scheme, databaseManifest)
+			Expect(err).NotTo(HaveOccurred())
+			AssertCreateCluster(namespace, clusterName, clusterManifest, env)
+			CreateResourceFromFile(namespace, databaseManifest)
+		})
+
+		By("checking volumes and volumeMounts", func() {
+			podList, err := clusterutils.ListPods(env.Ctx, env.Client, namespace, clusterName)
+			Expect(err).ToNot(HaveOccurred())
+			assertVolumeMounts(podList, "postgis")
+			assertVolumes(podList, "postgis")
+		})
+
+		By("checking extensions have been created", func() {
+			assertExtensions(namespace, databaseName)
+		})
+
+		By("adding a new extension to an existing Cluster", func() {
+			database := &apiv1.Database{}
+			databaseNamespacedName := types.NamespacedName{
+				Namespace: namespace,
+				Name:      databaseName,
+			}
+
+			Eventually(func(g Gomega) {
+				// Updating the Cluster
+				cluster, err := clusterutils.Get(env.Ctx, env.Client, namespace, clusterName)
+				g.Expect(err).NotTo(HaveOccurred())
+				cluster.Spec.PostgresConfiguration.Extensions = append(cluster.Spec.PostgresConfiguration.Extensions,
+					apiv1.ExtensionConfiguration{
+						Name: "pgvector",
+						ImageVolumeSource: corev1.ImageVolumeSource{
+							Reference: "ghcr.io/niccolofei/pgvector:18beta1-master-bullseye", // wokeignore:rule=master
+						},
+					})
+				g.Expect(env.Client.Update(env.Ctx, cluster)).To(Succeed())
+
+				// Updating the Database
+				err = env.Client.Get(env.Ctx, databaseNamespacedName, database)
+				g.Expect(err).ToNot(HaveOccurred())
+				database.Spec.Extensions = append(database.Spec.Extensions, apiv1.ExtensionSpec{
+					DatabaseObjectSpec: apiv1.DatabaseObjectSpec{
+						Name:   "vector",
+						Ensure: apiv1.EnsurePresent,
+					},
+				})
+				g.Expect(env.Client.Update(env.Ctx, database)).To(Succeed())
+			}, 60, 5).Should(Succeed())
+
+			AssertClusterEventuallyReachesPhase(namespace, clusterName,
+				[]string{apiv1.PhaseUpgrade, apiv1.PhaseWaitingForInstancesToBeActive}, 30)
+			AssertClusterIsReady(namespace, clusterName, testTimeouts[timeouts.ClusterIsReadyQuick], env)
+
+			podList, err := clusterutils.ListPods(env.Ctx, env.Client, namespace, clusterName)
+			Expect(err).ToNot(HaveOccurred())
+			assertVolumeMounts(podList, "pgvector")
+			assertVolumes(podList, "pgvector")
+			assertExtensions(namespace, databaseName)
+		})
+
+		By("verifying the extension's usage ", func() {
+			assertPostgis(namespace, clusterName)
+			assertVector(namespace, clusterName)
+		})
+	})
+})