> ## Documentation Index
> Fetch the complete documentation index at: https://docs.controlplane.com/llms.txt
> Use this file to discover all available pages before exploring further.

# pgvector

> PostgreSQL 18 with the pgvector 0.8.6 extension pre-created for embedding storage and similarity search. Single server on a persistent volume with optional PgBouncer pooling and scheduled S3, GCS or MinIO backups. Deploys into an existing GVC; needs a dictionary credentials secret first.

## Overview

[pgvector](https://github.com/pgvector/pgvector) adds a `vector` column type and approximate nearest-neighbor indexes to PostgreSQL, so embeddings live in the same database as the relational data they describe. This template deploys one PostgreSQL 18 server with pgvector 0.8.6 on a persistent volume, an optional [PgBouncer](https://www.pgbouncer.org/) connection pooler in front of it, and an optional cron job that writes compressed `pg_dumpall` backups to AWS S3, Google Cloud Storage or a MinIO (S3-compatible) bucket.

The extension is not just installed in the image — the template **creates it on first boot**, in the database named in your credentials secret. A fresh install can store vectors and run similarity queries with no setup SQL.

Database credentials are **not** template values. The server reads its username, password and database name from a [dictionary secret](/guides/create-secret/dictionary) you create before installing, so no password passes through Helm or lands in the release. The template creates no credential secret of its own.

<Note>
  This template deploys into an existing [GVC](/reference/gvc) that you already have. It does not create or manage a GVC.
</Note>

<Note>
  This is a single server, not a cluster — there is no replication or failover. For an automatically failing-over cluster use [PostgreSQL Highly Available](/template-catalog/templates/postgres-highly-available), whose image also carries pgvector; see [Scaling and Availability](#scaling-and-availability) for the version difference.
</Note>

### What Gets Created

| Resource | Name | Purpose | Optional |
| - | - | - | - |
| Workload | `RELEASE_NAME-pgvector` | PostgreSQL 18 with pgvector 0.8.6 (stateful, 1 replica, TCP `5432`) | — |
| Volume Set | `RELEASE_NAME-pgvector-vs` | `PGDATA` on an `ext4` general-purpose SSD volume; a final snapshot is kept for 7 days when the volume is deleted | — |
| Secret | `RELEASE_NAME-pgvector-init` | First-boot SQL mounted at `/docker-entrypoint-initdb.d/00-pgvector.sql`; holds the `CREATE EXTENSION` statements and no credentials | — |
| Identity | `RELEASE_NAME-pgvector-identity` | Shared by the database, pooler and backup workloads; carries the cloud account binding when backups use AWS or GCP | — |
| Policy | `RELEASE_NAME-pgvector-policy` | Grants the identity `reveal` on your credentials secret, the first-boot SQL secret and, for MinIO backups, the MinIO credentials secret — nothing else | — |
| Workload | `RELEASE_NAME-pgbouncer` | PgBouncer connection pooler (serverless, TCP `5432`) | `pgbouncer.enabled: true` |
| Workload | `RELEASE_NAME-pgvector-backup` | Scheduled `pg_dumpall` to S3, GCS or MinIO (cron) | `backup.enabled: true` |

## Prerequisites

One secret must exist before you install. It holds the credentials your applications put in their connection strings. Secrets are org-level, so no GVC flag is involved.

<Steps>
  <Step title="Create the database credentials secret">
    A [dictionary secret](/guides/create-secret/dictionary) holding exactly three keys — `username`, `password` and `database`. PostgreSQL creates that user (as a superuser) and that database the first time the volume is initialized, and the template creates the `vector` extension inside it:

    ```bash theme={null}
    cpln secret create-dictionary --name SECRET_NAME \
      --entry username=pgvector \
      --entry password='YOUR-STRONG-PASSWORD' \
      --entry database=vectors
    ```

    Set `config.credentialsSecretName` to this name. Read it back later with `cpln secret reveal SECRET_NAME -o yaml` — a bare `cpln secret reveal` prints only a summary table, not the values.
  </Step>
</Steps>

<Warning>
  **Create the secret before installing, or the deployment wedges silently.** The template refuses to render when `config.credentialsSecretName` is blank, but a name that points at a secret which does not exist installs successfully and then never starts. The container never runs, so `cpln logs` returns zero lines. The one place the reason appears is `status.versions[].message`:

  ```bash theme={null}
  cpln workload get-deployments RELEASE_NAME-pgvector --gvc GVC_NAME -o yaml
  ```

  ```text theme={null}
  The secret SECRET_NAME no longer exists. Workload updates are paused until the
  secret is added or the reference to the secret removed.
  ```

  Use `get-deployments` — plain `cpln workload get` has no `versions` key. Creating the missing secret repairs the deployment on its own within several minutes, or run `cpln workload force-redeployment RELEASE_NAME-pgvector --gvc GVC_NAME` to skip the wait. First boot then runs normally and the `vector` extension is created.
</Warning>

Backups are optional and need a bucket plus, for AWS or GCP, a Control Plane [cloud account](/guides/create-cloud-account) — see [Backing Up](#backing-up). MinIO backups need a second dictionary secret holding `accessKey` and `secretKey`. Nothing else is required for a default install.

## Installation

Install the chart from the marketplace registry, pointing it at the secret you created:

```bash theme={null}
cpln helm install RELEASE_NAME oci://ghcr.io/controlplane-com/templates/pgvector \
  --version 1.0.0 --gvc GVC_NAME \
  --set config.credentialsSecretName=SECRET_NAME
```

Or follow the instructions for your preferred method:

<CardGroup cols={2}>
  <Card title="UI" href="/template-catalog/install-manage/ui" icon="laptop">
    Browse, install, and manage templates visually
  </Card>

  <Card title="CLI" href="/template-catalog/install-manage/cli" icon="terminal">
    Manage templates from your terminal
  </Card>

  <Card title="Terraform" href="/template-catalog/install-manage/terraform" icon={<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill-rule="evenodd"><path d="M77.941 44.5v36.836L46.324 62.918V26.082zm0 0" fill="#5c4ee5"/><path d="M81.41 81.336l31.633-18.418V26.082L81.41 44.5zm0 0" fill="#4040b2"/><path d="M11.242 42.36L42.86 60.776V23.941L11.242 5.523zm0 0M77.941 85.375L46.324 66.957v36.82l31.617 18.418zm0 0" fill="#5c4ee5"/></g></svg>}>
    Declare templates in your Terraform configurations
  </Card>

  <Card
    title="Pulumi"
    href="/template-catalog/install-manage/pulumi"
    icon={<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Pulumi-Icon--Streamline-Svg-Logos" height="24" width="24">
    <desc>
        Pulumi Icon Streamline Icon: https://streamlinehq.com
    </desc>
    <path fill="#f26e7e" d="M4.683025 13.3318c0.869125 -0.5018 0.870575 -2.1264 0.003225 -3.62865s-2.27504 -2.313275 -3.1441725 -1.811475C0.672945 8.3935 0.6715 10.0181 1.53885 11.52035c0.86735 1.502275 2.27505 2.313275 3.144175 1.81145Zm0.0052 3.2167c0.86735 1.502275 0.865925 3.126875 -0.003225 3.628675 -0.86915 0.5018 -2.2768275 -0.309225 -3.144175 -1.81145 -0.8673525 -1.50225 -0.8659075 -3.126875 0.003225 -3.628675 0.8691325 -0.5018 2.276825 0.309225 3.144175 1.81145Zm5.922875 3.4243c0.86735 1.50225 0.8659 3.126775 -0.003225 3.62875 -0.869125 0.501775 -2.27685 -0.309325 -3.1442 -1.81155 -0.867325 -1.50225 -0.865875 -3.12685 0.00325 -3.628675 0.869125 -0.5018 2.276825 0.309225 3.144175 1.811475Zm-0.001925 -6.845275c0.86735 1.50225 0.8659 3.12685 -0.003225 3.628675 -0.869125 0.5018 -2.276825 -0.309225 -3.144175 -1.811475 -0.86735 -1.50225 -0.8659 -3.12685 0.003225 -3.62865 0.869125 -0.501825 2.276825 0.3092 3.144175 1.81145Z" stroke-width="0.25"></path>
    <path fill="#8a3391" d="M22.45775 11.524125c0.86725 -1.502225 0.865925 -3.12685 -0.003225 -3.62865 -0.869125 -0.501825 -2.276825 0.3092 -3.144175 1.811475 -0.86735 1.50225 -0.8659 3.126825 0.003225 3.62865 0.869125 0.501825 2.276825 -0.3092 3.144175 -1.811475Zm0.000175 3.2151c0.869075 0.5018 0.870625 2.1264 0.003225 3.62865 -0.86735 1.50225 -2.27505 2.313275 -3.144175 1.81145 -0.869125 -0.5018 -0.870575 -2.126425 -0.003225 -3.62865 0.86735 -1.50225 2.27505 -2.313275 3.144175 -1.81145ZM16.536225 18.157875c0.86915 0.501825 0.8706 2.126425 0.00325 3.628675 -0.86735 1.502125 -2.275075 2.313225 -3.1442 1.81145 -0.869125 -0.50175 -0.870575 -2.126425 -0.003225 -3.62865 0.867375 -1.502275 2.27505 -2.3133 3.144175 -1.811475Zm-0.003325 -6.843775c0.869125 0.5018 0.870575 2.126425 0.003225 3.628675s-2.27505 2.313275 -3.1442 1.811475c-0.869125 -0.501825 -0.870575 -2.126425 -0.003225 -3.628675 0.86735 -1.502275 2.27505 -2.313275 3.1442 -1.811475Z" stroke-width="0.25"></path>
    <path fill="#f7bf2a" d="M15.138225 2.06721c0 1.003615 -1.40625 1.817215 -3.14095 1.817215 -1.7347 0 -3.14095 -0.8136 -3.14095 -1.817215C8.856325 1.06359 10.262575 0.25 11.997275 0.25c1.7347 0 3.14095 0.81359 3.14095 1.81721ZM9.2166 5.482375c0 1.003625 -1.40625 1.8172 -3.14095 1.8172 -1.7347 0 -3.14095 -0.813575 -3.14095 -1.8172s1.40625 -1.817225 3.14095 -1.817225c1.7347 0 3.14095 0.8136 3.14095 1.817225Zm8.71005 1.8172c1.7347 0 3.14095 -0.813575 3.14095 -1.8172s-1.40625 -1.817225 -3.14095 -1.817225c-1.7347 0 -3.14095 0.8136 -3.14095 1.817225s1.40625 1.8172 3.14095 1.8172Zm-2.788425 1.605625c0 1.003625 -1.40625 1.8172 -3.14095 1.8172 -1.7347 0 -3.14095 -0.813575 -3.14095 -1.8172 0 -1.0036 1.40625 -1.8172 3.14095 -1.8172 1.7347 0 3.14095 0.8136 3.14095 1.8172Z" stroke-width="0.25"></path>
    </svg>}
  >
    Declare templates in your Pulumi programs
  </Card>
</CardGroup>

## Configuration

Each block below is the shipped default for that part of `values.yaml`.

### Server and Resources

```yaml theme={null}
image: pgvector/pgvector:0.8.6-pg18 # PostgreSQL 18 + pgvector 0.8.6 (Debian bookworm base)

resources:
  minCpu: 300m
  minMemory: 512Mi
  maxCpu: 1000m     # vector search is CPU-bound; keep maxCpu:minCpu at or below 4:1 or the apply is rejected
  maxMemory: 2048Mi # HNSW index builds are far faster when the graph fits in memory
```

<Note>
  On a stateful workload the ratio of `maxCpu` to `minCpu` may not exceed 4:1. Raising `maxCpu` without raising `minCpu` can cross that limit and is rejected when the workload is applied.
</Note>

### Credentials and Extensions

```yaml theme={null}
config:
  # REQUIRED PREREQUISITE SECRET — CREATE IT BEFORE YOU INSTALL.
  # A `dictionary` secret holding exactly three keys: `username`, `password`
  # and `database`. If it does not exist at install time the deployment
  # WEDGES silently — `cpln logs` returns nothing at all. See Prerequisites
  # in the README for the exact `cpln secret create-dictionary` command.
  credentialsSecretName: my-pgvector-credentials

  # Extra extensions created alongside `vector`, FIRST BOOT ONLY (ignored on an
  # existing volume). Must already exist in the image, e.g. pg_trgm, pgcrypto,
  # btree_gin. An unknown name fails the first boot — see the README.
  extraExtensions: []
```

The three credential keys are applied only when the volume is first initialized. Changing the secret afterwards does not change the stored password — see [Rotating the Password](#rotating-the-password).

`config.extraExtensions` lists additional extensions to create alongside `vector` at first boot, such as `pg_trgm`, `pgcrypto` or `btree_gin`. Each entry must be an extension the image already ships and must match `^[a-z][a-z0-9_]*$`; anything else is refused when the chart renders. The `vector` extension is always created and does not need to be listed.

<Warning>
  **A name the image does not carry costs you that extension.** The first-boot script stops at the bad name, the container exits once, and the restart finds a populated data directory and skips initialization — leaving a healthy server that has `vector` (created first) but not your extra extension, with nothing in the deployment status to say so. Adding an extension later is one statement:

  ```sql theme={null}
  CREATE EXTENSION IF NOT EXISTS pg_trgm;
  ```
</Warning>

### Storage

```yaml theme={null}
volumeset:
  capacity: 10 # initial capacity in GiB (minimum is 10)
  autoscaling:
    enabled: false # Set to true to enable autoscaling
    maxCapacity: 100 # Maximum capacity in GiB when autoscaling is enabled
    minFreePercentage: 10 # Minimum free percentage to trigger scaling when autoscaling is enabled
    scalingFactor: 1.2 # Scaling factor to determine how much to scale up when autoscaling is triggered
```

### Internal Access

```yaml theme={null}
internalAccess: # Sets the internal firewall scope - if set to none, nothing in the GVC can reach the database
  type: same-gvc # options: none, same-gvc, same-org, workload-list
  workloads:  # Note: can only be used if type is same-gvc or workload-list
    #- //gvc/GVC_NAME/workload/WORKLOAD_NAME
```

| Type | Who can reach port `5432` |
| - | - |
| `none` | No workload |
| `same-gvc` | Every workload in the same GVC |
| `same-org` | Every workload in the same org |
| `workload-list` | Only the workloads listed in `internalAccess.workloads` |

The same setting applies to the PgBouncer workload when it is enabled. A change to `internalAccess` can take several minutes to take effect — re-test before concluding it did not apply.

### Public Access

```yaml theme={null}
publicAccess:
  enabled: false # exposes 5432 via a TCP load balancer; connections are unencrypted — prefer internal access
```

When enabled, Control Plane assigns a canonical endpoint. Read it from `status.canonicalEndpoint`:

```bash theme={null}
cpln workload get RELEASE_NAME-pgvector --gvc GVC_NAME -o yaml
```

<Warning>
  **Public connections are unencrypted.** The image ships no TLS certificate, so a client that demands `sslmode=require` cannot connect; the libpq default of `sslmode=prefer` falls back to plaintext. Anyone who reaches the endpoint needs only the database password, so prefer internal access over `RELEASE_NAME-pgvector.GVC_NAME.cpln.local` where you can. A change to `publicAccess` can take several minutes to take effect.
</Warning>

### PgBouncer

PgBouncer multiplexes many application connections into a small pool of real database connections — the bursty, short-lived connections typical of retrieval and embedding services. When enabled it becomes the endpoint your applications connect to, and it reads the same credentials secret and identity as the server — nothing extra to configure.

```yaml theme={null}
pgbouncer:
  enabled: false
  image: edoburu/pgbouncer:v1.25.1-p0
  poolMode: transaction # session, transaction, statement; in transaction mode session GUCs LEAK between clients
  defaultPoolSize: 25   # number of real Postgres connections PgBouncer maintains
  maxClientConn: 1000   # maximum number of client connections PgBouncer accepts
  replicas: 1

  resources:
    cpu: 200m
    memory: 128Mi
```

| `poolMode` | Behaviour |
| - | - |
| `transaction` | The server connection is held for one transaction. Best for most web and API workloads; not compatible with plain `SET` variables, temporary tables or advisory locks. |
| `session` | Held for the whole client session. Compatible with everything, less reuse — raise `defaultPoolSize` to your expected concurrency. |
| `statement` | Returned after every statement. Transactions are not supported. |

<Warning>
  **In `transaction` mode a plain `SET hnsw.ef_search` is not bound to your client — it can leak between clients.** PgBouncer hands the same server connection to different clients, so your setting may be lost, may be replaced by another client's value, or may raise `ERROR: unrecognized configuration parameter "hnsw.ef_search"`. It matters on this template because `ef_search` is the recall knob. Either scope the setting to a transaction with `SET LOCAL` (see [Using pgvector](#using-pgvector)), or set `pgbouncer.poolMode: session` at the cost of connection multiplexing. This is upstream PgBouncer behaviour; PgBouncer is off by default, so a default install is not exposed to it.
</Warning>

### Backup

A cron workload runs `pg_dumpall` on the schedule and uploads a gzip-compressed SQL dump of every database and role to the bucket for the chosen `provider`. It authenticates with the `username` and `password` from your credentials secret. Each provider's bucket, cloud account and policy steps are under [Backing Up](#backing-up).

```yaml theme={null}
backup:
  enabled: false
  image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 # PG18 client, matches the server major
  schedule: "0 2 * * *"   # daily at 2am UTC

  resources:
    cpu: 100m
    memory: 128Mi

  provider: aws # Options: aws, gcp, or minio

  aws:
    bucket: my-pgvector-bucket
    region: us-east-1
    cloudAccountName: my-s3-cloud-account
    policyName: my-pgvector-backup-policy # bucket-scoped IAM policy, see README
    prefix: pgvector/backups # folder name where your backups will be stored

  gcp:
    bucket: my-pgvector-bucket
    cloudAccountName: my-gcs-cloud-account
    prefix: pgvector/backups # folder name where your backups will be stored

  minio: # Backup to a self-hosted MinIO workload (or any S3-compatible endpoint)
    endpoint: http://my-minio-workload:9000 # e.g. http://WORKLOAD_NAME:9000 for an internal MinIO template deployment
    bucket: my-pgvector-bucket
    # REQUIRED PREREQUISITE SECRET when provider is `minio` — a `dictionary`
    # secret holding `accessKey` and `secretKey`. See Storage setup in the README.
    credentialsSecretName: my-pgvector-minio-credentials
    prefix: pgvector/backups # folder name where your backups will be stored
```

<Warning>
  **Changing `backup.provider` on an existing release leaves the old cloud binding attached.** Control Plane merges an identity's cloud-binding block and never removes one, so a release switched from `aws` to `gcp` keeps both bindings even though the template renders only the new one. Uninstall and reinstall to change providers cleanly.
</Warning>

## Connecting

Everything below is reachable from inside the GVC, subject to `internalAccess.type`; the public path exists only when `publicAccess.enabled: true`.

| What | Address | Notes |
| - | - | - |
| PostgreSQL, direct | `RELEASE_NAME-pgvector.GVC_NAME.cpln.local:5432` | Always available. |
| PostgreSQL, pooled | `RELEASE_NAME-pgbouncer.GVC_NAME.cpln.local:5432` | Only when `pgbouncer.enabled: true`. Prefer it when the pooler is on. |
| PostgreSQL, public | `status.canonicalEndpoint` on the `RELEASE_NAME-pgvector` workload, port `5432` | Only when `publicAccess.enabled: true`. Unencrypted. |
| Credentials | `cpln secret reveal SECRET_NAME -o yaml` | The `username`, `password` and `database` keys. Never stored in the Helm release. |

To verify from your own machine, tunnel to the server and confirm the extension is present:

```bash theme={null}
cpln port-forward RELEASE_NAME-pgvector 5432:5432 --gvc GVC_NAME
```

```bash theme={null}
psql "postgresql://USERNAME:PASSWORD@127.0.0.1:5432/DATABASE" -c "select extversion from pg_extension where extname = 'vector'"
```

Or run `psql` inside the container, where the credentials are already present as environment variables:

```bash theme={null}
cpln workload exec RELEASE_NAME-pgvector --gvc GVC_NAME --container pgvector -- psql -U USERNAME -d DATABASE -c "select extversion from pg_extension where extname = 'vector'"
```

### Using pgvector

The `vector` extension is already created in the database named in your credentials secret, so there is no setup SQL to run:

```sql theme={null}
CREATE TABLE items (id bigserial PRIMARY KEY, content text, embedding vector(1536));
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
SELECT id, content FROM items ORDER BY embedding <=> '[0.1,0.2,...]' LIMIT 5;
```

| Operator | Distance | Index operator class |
| - | - | - |
| `<->` | L2 (Euclidean) | `vector_l2_ops` |
| `<=>` | Cosine | `vector_cosine_ops` |
| `<#>` | Negative inner product | `vector_ip_ops` |
| `<+>` | L1 (taxicab) | `vector_l1_ops` |

Both index types are available; the planner chooses them on cost.

| Index | Characteristics | Recall knob |
| - | - | - |
| `hnsw` | Graph built incrementally; slower to build, better recall per query | `SET hnsw.ef_search = 100;` (default `40`) |
| `ivfflat` | Wants representative data present at build time, and a list count: `WITH (lists = 100)` | `SET ivfflat.probes = 10;` (default `1`) |

Approximate indexes trade recall for speed, so an exact match can be missed at the default settings — raise the recall knob when results look incomplete. Large index builds are much faster when the graph fits in `maintenance_work_mem`, for example `SET maintenance_work_mem = '512MB';` within the `resources.maxMemory` you have given the server.

With PgBouncer in `transaction` mode, set these per query inside a transaction so the setting cannot leak to another client:

```sql theme={null}
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM items ORDER BY embedding <=> '[0.1,0.2,...]' LIMIT 5;
COMMIT;
```

A `vector` column holds up to **16,000** dimensions but can only be **indexed** up to **2,000** — 1,536-dimension embeddings index fine, while 3,072-dimension ones are rejected with `column cannot have more than 2000 dimensions for hnsw index`. Use `halfvec`, which indexes up to 4,000 dimensions, or reduce the dimensionality.

## Operations

### Backing Up

Backups are off by default. Enable them with `backup.enabled: true`, pick a `backup.provider`, and complete that provider's setup below first. The job writes `postgres-TIMESTAMP.sql.gz` objects under `backup.PROVIDER.prefix` in your bucket on every run of `backup.schedule`.

#### Backup Prerequisites

Complete the steps for your provider before enabling backups.

#### AWS S3

1. Create an S3 bucket. Set `backup.aws.bucket` to its name and `backup.aws.region` to its region.

2. If you do not have one yet, create a Control Plane [cloud account](/guides/create-cloud-account) for the AWS account holding the bucket. Set `backup.aws.cloudAccountName` to its name.

3. Create an IAM policy scoped to exactly that bucket, replacing `YOUR_BUCKET_NAME`:

```json theme={null}
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject",
                "s3:ListBucket",
                "s3:GetObjectVersion",
                "s3:DeleteObjectVersion",
                "s3:GetBucketLocation",
                "s3:AbortMultipartUpload",
                "s3:ListBucketMultipartUploads",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": [
                "arn:aws:s3:::YOUR_BUCKET_NAME",
                "arn:aws:s3:::YOUR_BUCKET_NAME/*"
            ]
        }
    ]
}
```

4. Set `backup.aws.policyName` to that policy's name. The template attaches it to the workload identity and nothing else — the bucket in your policy is the only storage the backup job can reach.

#### GCS

1. Create a GCS bucket. Set `backup.gcp.bucket` to its name.

2. Create a Control Plane [cloud account](/guides/create-cloud-account) for the GCP project holding the bucket, and set `backup.gcp.cloudAccountName` to its name.

3. Grant the cloud account's service account the **Storage Admin** (`roles/storage.admin`) role. The template additionally binds the identity to `roles/storage.objectAdmin` on exactly the bucket in `backup.gcp.bucket`.

#### MinIO

No cloud account is needed — the job authenticates with a second prerequisite secret.

1. Create the bucket in MinIO. Set `backup.minio.bucket` to its name.

2. Set `backup.minio.endpoint` to the S3 API address including the port. For the [MinIO](/template-catalog/templates/minio) template deployed in the same GVC that is `http://WORKLOAD_NAME.GVC_NAME.cpln.local:9000`.

3. Create a [dictionary secret](/guides/create-secret/dictionary) holding exactly the keys `accessKey` and `secretKey`, and set `backup.minio.credentialsSecretName` to its name. For the MinIO template these are its admin username and password:

```bash theme={null}
cpln secret create-dictionary --name SECRET_NAME \
  --entry accessKey=MINIO_ACCESS_KEY \
  --entry secretKey=MINIO_SECRET_KEY
```

The policy this template creates grants the identity `reveal` on this secret as well as the database credentials secret and the first-boot SQL secret, and on nothing else.

### Restoring a Backup

Each backup is a gzip-compressed `pg_dumpall` script, so it is restored by connecting to the `postgres` maintenance database as the superuser from your credentials secret; the script recreates the roles and databases it contains. Because the dump includes `CREATE EXTENSION IF NOT EXISTS vector`, **restore into a server whose image carries pgvector** — a fresh install of this template, for example. Replaying it into a stock `postgres:18` fails at that line and the tables are never created.

Run the commands from a client that can reach both the bucket and the server — a workload inside the GVC, or your own machine with `cpln port-forward RELEASE_NAME-pgvector 5432:5432 --gvc GVC_NAME` open and `--host=127.0.0.1` in place of the internal hostname.

<Note>
  This procedure is derived from the backup image's script and the dump format it produces; it has not been exercised against a live install of this template. Rehearse a backup and restore before relying on it.
</Note>

<Tabs>
  <Tab title="AWS S3">
    ```bash theme={null}
    export PGPASSWORD="PASSWORD"

    aws s3 cp "s3://BUCKET_NAME/PREFIX/postgres-TIMESTAMP.sql.gz" - \
      | gunzip \
      | psql \
          --host=RELEASE_NAME-pgvector.GVC_NAME.cpln.local \
          --port=5432 \
          --username=USERNAME \
          --dbname=postgres

    unset PGPASSWORD
    ```
  </Tab>

  <Tab title="GCS">
    ```bash theme={null}
    export PGPASSWORD="PASSWORD"

    gsutil cp "gs://BUCKET_NAME/PREFIX/postgres-TIMESTAMP.sql.gz" - \
      | gunzip \
      | psql \
          --host=RELEASE_NAME-pgvector.GVC_NAME.cpln.local \
          --port=5432 \
          --username=USERNAME \
          --dbname=postgres

    unset PGPASSWORD
    ```
  </Tab>

  <Tab title="MinIO">
    ```bash theme={null}
    export PGPASSWORD="PASSWORD"
    export AWS_ACCESS_KEY_ID="MINIO_ACCESS_KEY"
    export AWS_SECRET_ACCESS_KEY="MINIO_SECRET_KEY"
    aws configure set default.s3.addressing_style path

    aws s3 cp "s3://BUCKET_NAME/PREFIX/postgres-TIMESTAMP.sql.gz" - --endpoint-url "http://MINIO_ENDPOINT:9000" \
      | gunzip \
      | psql \
          --host=RELEASE_NAME-pgvector.GVC_NAME.cpln.local \
          --port=5432 \
          --username=USERNAME \
          --dbname=postgres

    unset PGPASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
    ```
  </Tab>
</Tabs>

`already exists` errors for the role, the database and the `vector` extension that the server created at first boot are expected; the rest of the script loads normally. Restore into an empty database (a fresh install, or one whose tables you have dropped) to avoid duplicate-object errors on existing tables. Use a PostgreSQL 18 `psql` — an older client prints `invalid command \unrestrict` at the end of a PostgreSQL 18 dump.

### Rotating the Password

The server reads the secret only when the volume is first initialized, so change the password inside PostgreSQL first, then update the secret to match:

```sql theme={null}
ALTER ROLE USERNAME WITH PASSWORD 'NEW-STRONG-PASSWORD';
```

Workloads resolve the secret when a replica starts, so an updated secret is not picked up by a running replica. The backup job reads it fresh on its next scheduled run; if PgBouncer is enabled, force a redeployment so it reconnects with the new password:

```bash theme={null}
cpln workload force-redeployment RELEASE_NAME-pgbouncer --gvc GVC_NAME
```

### Scaling and Availability

* The server is pinned to one replica. Do not raise it — a second stateful replica gets its own volume, which is a second empty database rather than a replica.
* PgBouncer is stateless; `pgbouncer.replicas` scales it horizontally for high connection counts.
* Every `cpln helm upgrade` restarts the server, including the first upgrade after an install even when nothing changed. Plan each one as a short write outage. Data on the volume survives, including built HNSW graphs, which are not rebuilt.
* A node failure reschedules the server and reattaches the same volume. The exposure is minutes of downtime, not data loss.
* For automatic failover use [PostgreSQL Highly Available](/template-catalog/templates/postgres-highly-available), whose image also carries pgvector — but not the same build. Both `hnsw` and `ivfflat` exist there, so the gap is fixes and refinements rather than a missing index type; verify against your own queries before treating the two as interchangeable.

| | pgvector template | postgres-highly-available |
| - | - | - |
| PostgreSQL | 18 | 17 |
| pgvector | 0.8.6 | 0.8.0 |

### Troubleshooting

<AccordionGroup>
  <Accordion title="Workload never becomes ready and cpln logs returns nothing">
    **Symptom:** the install succeeds, the workload stays not ready indefinitely, and `cpln logs '{gvc="GVC_NAME", workload="RELEASE_NAME-pgvector"}' --limit 50 --since 10m` returns zero lines.

    **Cause:** `config.credentialsSecretName` names a secret that does not exist, so the container is never started. The message is only visible in `status.versions[].message`:

    ```bash theme={null}
    cpln workload get-deployments RELEASE_NAME-pgvector --gvc GVC_NAME -o yaml
    ```

    ```text theme={null}
    The secret SECRET_NAME no longer exists. Workload updates are paused until the
    secret is added or the reference to the secret removed.
    ```

    **Fix:** create the secret as in [Prerequisites](#prerequisites). The deployment recovers on its own within several minutes, or run `cpln workload force-redeployment RELEASE_NAME-pgvector --gvc GVC_NAME`.
  </Accordion>

  <Accordion title="Install refused with 'Invalid config.extraExtensions entry'">
    **Symptom:** `cpln helm install` or `upgrade` stops before touching any resource:

    ```text theme={null}
    Invalid config.extraExtensions entry "pg-trgm": an extension name must match ^[a-z][a-z0-9_]*$
    (e.g. pg_trgm, pgcrypto, btree_gin).
    ```

    **Cause:** an entry in `config.extraExtensions` contains a hyphen, a capital letter or other punctuation. Entries are interpolated into SQL, so the chart only accepts bare lower-case identifiers.

    **Fix:** use the extension's exact name as PostgreSQL knows it — `pg_trgm`, not `pg-trgm`.
  </Accordion>

  <Accordion title="Server is healthy but an extension from config.extraExtensions is missing">
    **Symptom:** `vector` exists but `CREATE EXTENSION` for one of your extra extensions never ran, and the first-boot log shows `ERROR:  extension "NAME" is not available` followed by a restart.

    **Cause:** the name is valid but the image does not ship that extension. The first-boot script stopped at that line; the restart found an initialized data directory and skipped initialization, so the remaining statements never ran.

    **Fix:** create the extensions the image does carry by hand with `CREATE EXTENSION IF NOT EXISTS NAME;`, and remove the unavailable one from `config.extraExtensions`. To start over with a corrected list, uninstall (which deletes the volume set) and reinstall.
  </Accordion>

  <Accordion title="ERROR: unrecognized configuration parameter &#x22;hnsw.ef_search&#x22;, or recall varies between identical queries through PgBouncer">
    **Symptom:** through `RELEASE_NAME-pgbouncer` a plain `SET hnsw.ef_search = ...` sometimes errors with `unrecognized configuration parameter`, or `SHOW hnsw.ef_search` returns a value you did not set. Direct connections to `RELEASE_NAME-pgvector` behave normally.

    **Cause:** `pgbouncer.poolMode: transaction` hands the same server connection to different clients, so a session-level `SET` is not bound to the client that issued it.

    **Fix:** scope the setting with `SET LOCAL` inside a transaction (see [Using pgvector](#using-pgvector)), or set `pgbouncer.poolMode: session`.
  </Accordion>

  <Accordion title="CREATE INDEX fails with 'column cannot have more than 2000 dimensions for hnsw index'">
    **Symptom:** creating an `hnsw` (or `ivfflat`) index on a `vector(N)` column with `N` above 2,000 fails with this error, although inserting the vectors worked.

    **Cause:** a `vector` column stores up to 16,000 dimensions but pgvector indexes it only up to 2,000.

    **Fix:** store the embeddings as `halfvec(N)`, which indexes up to 4,000 dimensions, or reduce the embedding dimensionality.
  </Accordion>

  <Accordion title="Client fails to connect to the public endpoint with sslmode=require">
    **Symptom:** a connection string carrying `sslmode=require` (or `verify-ca` / `verify-full`) cannot connect to the canonical endpoint, while the same credentials work without it.

    **Cause:** the image ships no TLS certificate, so the server does not offer SSL.

    **Fix:** use the libpq default `sslmode=prefer`, or `sslmode=disable`, and prefer the internal address `RELEASE_NAME-pgvector.GVC_NAME.cpln.local` over the public endpoint wherever possible.
  </Accordion>

  <Accordion title="Apply rejected with 'The ratio between cpu and minCpu must be less than 4:1'">
    **Symptom:** an install or upgrade fails and the server workload is not created or updated.

    **Cause:** `resources.maxCpu` is more than four times `resources.minCpu`, which the platform rejects on a stateful workload.

    **Fix:** raise `resources.minCpu` or lower `resources.maxCpu` so the ratio is at most 4:1.
  </Accordion>
</AccordionGroup>

## Important Notes

* **Create the credentials secret before installing.** A reference to a secret that does not exist wedges the workload with no log output; see [Prerequisites](#prerequisites) for the one command that shows the reason.
* **The `vector` extension is created by this template, not by the image**, in the database named in your credentials secret, on first boot only.
* **Credentials and `config.extraExtensions` are read only when the volume is first initialized.** Add an extension later with `CREATE EXTENSION IF NOT EXISTS ...`; rotate a password with `ALTER ROLE`, then update the secret — see [Rotating the Password](#rotating-the-password).
* **PgBouncer in `transaction` mode leaks session settings between clients**, including `hnsw.ef_search`. Use `SET LOCAL` inside a transaction, or `poolMode: session`.
* **`publicAccess` is unencrypted** — the image ships no TLS certificate, so `sslmode=require` fails. Prefer internal access.
* **Do not scale the server past one replica.** It is a single instance on a single volume, not a replicated cluster.
* **Every `cpln helm upgrade` restarts the server.** Treat each one as a short planned write outage.
* **Restore with a PostgreSQL 18 `psql`, into a server that carries pgvector.** A stock `postgres:18` fails on the dump's `CREATE EXTENSION` line.
* **`cpln helm uninstall` deletes the volume set** and the database with it. Your credentials secret is yours and is left alone.

## External References

<CardGroup cols={2}>
  <Card title="pgvector Documentation" icon="book" href="https://github.com/pgvector/pgvector">
    Upstream reference for vector types, operators, and index tuning
  </Card>

  <Card title="pgvector Image" icon="docker" href="https://hub.docker.com/r/pgvector/pgvector">
    The image this template deploys, and its available tags
  </Card>

  <Card title="PostgreSQL 18 Documentation" icon="database" href="https://www.postgresql.org/docs/18/index.html">
    Official documentation for the server version shipped here
  </Card>

  <Card title="PgBouncer Documentation" icon="database" href="https://www.pgbouncer.org/config.html">
    PgBouncer configuration reference
  </Card>

  <Card title="pgvector Template" icon="github" href="https://github.com/controlplane-com/templates/tree/main/pgvector">
    Source files, default values, and chart definition
  </Card>
</CardGroup>
