> ## Documentation Index
> Fetch the complete documentation index at: https://controlplanecorporation-majid-docs-content-expansion.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# PostgreSQL

> Deploy PostgreSQL on Control Plane using the Template Catalog. Covers the prerequisite credentials secret, volumes, internal access, PgBouncer pooling, scheduled S3, GCS and MinIO backups, and upgrading from template version 3.3.0.

## Overview

PostgreSQL is a powerful open-source relational database. This template deploys a single-replica PostgreSQL instance with persistent storage, an optional PgBouncer connection pooler, and optional scheduled backups to AWS S3, GCS, or a self-hosted MinIO instance.

Database credentials are **not** template values. PostgreSQL reads its username, password and database name from a dictionary secret you create before installing, so no password passes through Helm or lands in the release.

<Warning>
  **Template version 3.4.0 is a breaking security change.** `config.username`, `config.password` and `config.database` were removed, and an install or upgrade that still sets any of them now fails at render instead of silently falling back to a published default password. If you are running 3.3.0 or earlier, read [Upgrading From 3.3.0 or Earlier](#upgrading-from-3-3-0-or-earlier) before you touch the release.
</Warning>

<Note>
  PostgreSQL on Control Plane operates as a single-replica deployment. Do not scale up the replica count, as this would result in multiple isolated instances rather than a replicated cluster. For a highly available setup, use the [PostgreSQL Highly Available](/template-catalog/templates/postgres-highly-available) template instead, or [Postgres Multi Location](/template-catalog/templates/postgres-multi-location) to span regions.
</Note>

### What Gets Created

* **Stateful Postgres Workload** — (`RELEASE_NAME-postgres`): a single-replica PostgreSQL container serving TCP on port `5432`, with configurable resources.
* **Volume Set** — (`RELEASE_NAME-pg-vs`): an `ext4` volume holding `PGDATA`, on general-purpose SSD with daily snapshots and 7-day retention, with optional autoscaling.
* **Identity & Policy** — (`RELEASE_NAME-pg-identity`, `RELEASE_NAME-pg-policy`): an identity bound to the database, pooler and backup workloads, and a policy granting it `reveal` on exactly the credential secrets you created — nothing else. When backups are enabled, the identity also carries the Cloud Account binding the backup job uses to reach your bucket.
* **PgBouncer Workload** *(optional)* — (`RELEASE_NAME-pgbouncer`): a connection pooler deployed as a separate workload in front of PostgreSQL, created when `pgbouncer.enabled: true`.
* **Backup Cron Workload** *(optional)* — (`RELEASE_NAME-postgres-backup`): a scheduled `pg_dump` that writes compressed SQL dumps to AWS S3, GCS or MinIO, created when `backup.enabled: true`.

The template creates **no credential secret of its own**. Every password lives in the prerequisite secrets described below.

<Note>
  This template does not create a GVC. You must deploy it into an existing GVC.
</Note>

## Upgrading From 3.3.0 or Earlier

Template versions up to 3.3.0 took the database credentials as plain Helm values and shipped a working default password for them, and wrote those credentials into a chart-owned secret named after the release. Version 3.4.0 removes both.

|                             | 3.3.0 and earlier                                              | 3.4.0                                                                 |
| --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| Database credentials        | `config.username`, `config.password`, `config.database` values | `config.credentialsSecretName` → a dictionary secret you create       |
| Credential secret           | `RELEASE_NAME-pg-config`, created by the chart                 | Not created — credentials live only in your secret                    |
| MinIO backup credentials    | `backup.minio.accessKey`, `backup.minio.secretKey` values      | `backup.minio.credentialsSecretName` → a dictionary secret you create |
| Backup identity permissions | Bucket-scoped policy plus `aws::ReadOnlyAccess`                | Bucket-scoped policy only                                             |
| `backup.schedule`           | Any cron beginning with `*` failed to render                   | Accepts every cron form, including `*/15 * * * *`                     |

<Warning>
  **Carrying old values forward stops the upgrade.** Each removed key is rejected at render, so `cpln helm upgrade` fails and your existing release is left untouched and running rather than quietly restarting against a different password:

  ```text theme={null}
  Error: execution error at (postgres/templates/workload-postgres.yaml:1:4): config.username was
  REMOVED in postgres 3.4.0. Database credentials are no longer values: create a `dictionary` secret
  holding the keys `username`, `password` and `database`, and set config.credentialsSecretName to its
  name. See Prerequisites in the README.
  ```

  `config.password` and `config.database` each produce the same message under their own name, and MinIO backups produce:

  ```text theme={null}
  Error: ... backup.minio.accessKey and backup.minio.secretKey were REMOVED in postgres 3.4.0. Create a
  `dictionary` secret holding the keys `accessKey` and `secretKey`, and set
  backup.minio.credentialsSecretName to its name. See Storage setup in the README.
  ```
</Warning>

<Steps>
  <Step title="Read the credentials the database already uses">
    Credentials are written into the data directory the first time the volume is initialized, so an existing database keeps whatever it was created with. Recover them from your current values file, or from the secret the old version created — do this **before** upgrading:

    ```bash theme={null}
    cpln secret reveal RELEASE_NAME-pg-config -o yaml
    ```

    The keys are `username`, `password` and `database`.
  </Step>

  <Step title="Create the prerequisite secret with those same values">
    Follow [Prerequisites](#prerequisites), using the existing username, password and database name. Using different values here does not change the database — it just leaves the workload unable to authenticate.
  </Step>

  <Step title="Remove the old keys from your values">
    Delete `config.username`, `config.password` and `config.database`, and set `config.credentialsSecretName` to your secret's name. If you back up to MinIO, replace `backup.minio.accessKey` and `backup.minio.secretKey` with `backup.minio.credentialsSecretName`.
  </Step>

  <Step title="Upgrade, then rotate the password">
    After the upgrade succeeds, change any password that came from a 3.3.x default — those defaults were published in the public template repository, so treat them as compromised. Rotate inside PostgreSQL and update the secret to match:

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

    Use your own username in place of `appuser`, then update the secret so the workload can still authenticate after a restart.
  </Step>
</Steps>

<Warning>
  **The chart no longer creates `RELEASE_NAME-pg-config`.** Anything outside this release that referenced that secret by name — another workload's environment, a policy, or a parent chart that bundles PostgreSQL as a subchart — must be pointed at the secret you created instead. A reference to a secret that no longer exists does not fail loudly; it wedges the referring workload silently.
</Warning>

<Note>
  **`aws::ReadOnlyAccess` was removed from the backup identity, and no action is required from you.** Control Plane attaches each policy reference as a managed policy on a per-identity derived IAM role, so the identity's permissions are the union of those references. `ReadOnlyAccess` contains no write actions at all — its S3 portion is `Get*` and `List*` on `*` — so it was never carrying the backup upload; what it granted was read access to every bucket in the account. Backups continue to work without it. **Existing bucket policies are also unaffected:** a 30.9 MB multipart upload succeeded under the six-action policy documented by earlier template versions, because a multipart upload authorizes under `s3:PutObject`. The four additional actions in the [AWS S3](#aws-s3) policy below are defensive — they let a failed upload abort its own parts instead of leaving them to bill silently — not required.
</Note>

<Note>
  **`backup.schedule` now accepts every cron form.** In 3.3.0 and earlier, any schedule beginning with `*` — such as `*/15 * * * *` — failed to render with a raw YAML parse error naming a template file, because an unquoted YAML scalar starting with `*` is an alias indicator. If you worked around this with a digit-leading schedule, you can now use the interval form directly.
</Note>

## Prerequisites

**One secret must exist before you install.** It holds the credentials your applications put in their connection strings. The values never pass through Helm, so they do not land in the release. 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 and that database on first boot:

    ```bash theme={null}
    cpln secret create-dictionary --name my-postgres-credentials \
      --entry username=postgres \
      --entry password='YOUR-STRONG-PASSWORD' \
      --entry database=mydb
    ```

    Set `config.credentialsSecretName` to the name you used.
  </Step>

  <Step title="Read the secret back later">
    Pass `-o yaml`. A bare `cpln secret reveal` prints only a summary table, not the values:

    ```bash theme={null}
    cpln secret reveal my-postgres-credentials -o yaml
    ```
  </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** — there is nothing to log, and every summary surface just looks like a slow deploy. The one place the reason appears is `status.versions[].message`:

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

  ```text theme={null}
  The secret my-postgres-credentials 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 and will show you nothing. Creating the missing secret repairs it on its own with no further action, in roughly **5.5 to 10.5 minutes** measured across five templates, or run `cpln workload force-redeployment RELEASE_NAME-postgres --gvc GVC_NAME` to clear it in about 90 seconds.
</Warning>

Backups need a bucket, and for AWS or GCP a Control Plane Cloud Account, before they can be enabled — see [Backup Prerequisites](#backup-prerequisites). MinIO backups need a second dictionary secret holding `accessKey` and `secretKey`. Nothing else is required.

## Installation

To install, 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

The default `values.yaml` for this template:

```yaml theme={null}
image: postgres:18  # versions before postgres:17 are compatible but do not support backup feature

resources:
  minCpu: 200m
  minMemory: 128Mi
  maxCpu: 500m
  maxMemory: 256Mi

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-postgres-credentials

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

internalAccess: # Sets the internal firewall scope - if set to none, replicas will not be able to reach each other
  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
    #- //gvc/GVC_NAME/workload/WORKLOAD_NAME

pgbouncer:
  enabled: false
  image: edoburu/pgbouncer:v1.25.1-p0
  poolMode: transaction # options: session, transaction, statement
  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

backup: # compatible with Postgres 17+
  enabled: false
  image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 # tag 18.1.0 = Postgres 18, 17.1.0 = Postgres 17
  schedule: "0 2 * * *"   # daily at 2am UTC

  resources:
    cpu: 100m
    memory: 128Mi

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

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

  gcp:
    bucket: my-postgres-bucket
    cloudAccountName: my-gcs-cloud-account
    prefix: postgres/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-postgres-bucket
    # REQUIRED PREREQUISITE SECRET when provider is `minio` — a `dictionary`
    # secret holding `accessKey` and `secretKey`. See Storage setup in the README.
    credentialsSecretName: my-postgres-minio-credentials
    prefix: postgres/backups # folder name where your backups will be stored
```

### Credentials

* `config.credentialsSecretName` — Name of the dictionary secret holding `username`, `password` and `database`. PostgreSQL creates that user and that database on first boot, and this is the credential your applications put in their connection strings.

The secret must exist before installing — see [Prerequisites](#prerequisites). The workload reads it through `cpln://secret/...` references, so the values appear in neither the Helm release nor the stored workload spec.

<Note>
  These credentials are only applied on first startup when the data directory is empty. Rotating the secret afterwards does not change the stored password; change it inside PostgreSQL with `ALTER ROLE ... WITH PASSWORD` (and `ALTER DATABASE ... RENAME` for the database name) and update the secret to match.
</Note>

### Resources

* `resources.minCpu` / `resources.minMemory` — Minimum CPU and memory guaranteed to the workload.
* `resources.maxCpu` / `resources.maxMemory` — Maximum CPU and memory the workload can use.

<Note>
  On a stateful workload the ratio of `maxCpu` to `minCpu` may not exceed 4:1. The shipped `500m` / `200m` is 2.5:1; raising `maxCpu` without raising `minCpu` can cross the limit and is rejected when the workload is applied.
</Note>

### Storage

* `volumeset.capacity` — Initial volume size in GiB (minimum 10).
* `volumeset.autoscaling.enabled` — Allow the volume to grow as it fills. When enabled:
  * `maxCapacity` — Maximum volume size in GiB.
  * `minFreePercentage` — Trigger a scale-up when free space drops below this percentage.
  * `scalingFactor` — Multiply the current capacity by this factor when scaling up.

### Internal Access

* `internalAccess.type` — Controls which workloads can connect to PostgreSQL on port `5432`:

| Type            | Description                                                     |
| --------------- | --------------------------------------------------------------- |
| `none`          | No internal access allowed                                      |
| `same-gvc`      | Allow access from all workloads in the same GVC                 |
| `same-org`      | Allow access from all workloads in the same organization        |
| `workload-list` | Allow access only from specific workloads listed in `workloads` |

Firewall changes take 30 to 150 seconds to propagate. After changing `internalAccess`, re-test rather than trusting the first response.

### PgBouncer Connection Pooling

PgBouncer is an optional connection pooler that sits in front of PostgreSQL and multiplexes application connections into a smaller pool of real database connections. This reduces connection overhead and protects PostgreSQL from exhaustion under high concurrency.

When enabled, PgBouncer is deployed as a separate workload and becomes the primary connection endpoint for your applications:

```text theme={null}
RELEASE_NAME-pgbouncer.GVC_NAME.cpln.local:5432
```

* `pgbouncer.enabled` — Enable or disable PgBouncer.
* `pgbouncer.image` — PgBouncer container image.
* `pgbouncer.poolMode` — Controls how connections are reused:

| Mode          | Description                                                                                                                                                                                                             |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transaction` | Connection held only for the duration of a transaction, then returned to the pool. Best for most web and API workloads. Not compatible with session-level features (`SET` variables, temporary tables, advisory locks). |
| `session`     | Connection held for the entire client session. Compatible with all PostgreSQL features but provides less reuse. Increase `defaultPoolSize` to match your expected concurrent client count.                              |
| `statement`   | Connection returned after every statement. Transactions are not supported. Rarely used.                                                                                                                                 |

* `pgbouncer.defaultPoolSize` — Number of real PostgreSQL connections PgBouncer maintains per pool (default: `25`).
* `pgbouncer.maxClientConn` — Maximum number of client connections PgBouncer accepts (default: `1000`).
* `pgbouncer.replicas` — Number of PgBouncer instances. PgBouncer is stateless and can be scaled horizontally for high-throughput workloads.
* `pgbouncer.resources.cpu` / `pgbouncer.resources.memory` — Resources allocated to each PgBouncer replica.

<Note>
  PgBouncer reads the same credentials secret and identity as the PostgreSQL workload — no additional secrets or IAM configuration is required. Its `userlist.txt` and `pgbouncer.ini` are generated at startup from the `username`, `password` and `database` keys in that secret.
</Note>

<Note>
  PgBouncer's own admin console (`-d pgbouncer`) rejects the database user with `FATAL: not allowed`. The image restricts admin access to the `postgres` user; this is upstream behavior, not a template setting.
</Note>

### Backup

Backup is disabled by default. When enabled, a cron workload runs `pg_dump` on the configured schedule and uploads compressed SQL dumps to AWS S3, GCS or MinIO. The job authenticates with the username and password from your credentials secret and dumps the database named in it.

<Note>
  Backup requires PostgreSQL 17 or later. Set `backup.image` to match your PostgreSQL version: `ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0` for Postgres 18, or `ghcr.io/controlplane-com/backup-images/postgres-backup:17.1.0` for Postgres 17.
</Note>

* `backup.enabled` — Enable scheduled backups.
* `backup.image` — Backup container image, matched to your PostgreSQL major version.
* `backup.schedule` — Cron expression for backup frequency (default: daily at 2am UTC). Interval forms such as `*/15 * * * *` are accepted.
* `backup.provider` — `aws`, `gcp`, or `minio`.
* `backup.resources.cpu` / `backup.resources.memory` — Resources for the backup cron container.
* `backup.PROVIDER.prefix` — Folder path within the bucket where backups are stored.

The **AWS S3** and **MinIO** paths were exercised end to end at the shipped settings, with the resulting objects confirmed from outside the cluster and a dump downloaded and restored to verify its contents; the AWS path was also exercised at 30.9 MB to force a multipart upload. **GCS** renders and binds correctly but was not exercised in the same run — rehearse it before relying on it.

## Backup Prerequisites

Only needed when `backup.enabled: true`.

### 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 a Control Plane Cloud Account set up, follow the [Create a Cloud Account](/guides/create-cloud-account) guide. Set `backup.aws.cloudAccountName` to its name.

3. Create an IAM policy with the following JSON, 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 the name of the policy created in step 3. The template attaches it to the workload's identity, and attaches nothing else — the bucket in your policy is the only storage the backup job can reach.

5. Set `backup.aws.prefix` to the folder path where backups will be stored.

### GCS

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

2. If you do not have a Control Plane Cloud Account set up, follow the [Create a Cloud Account](/guides/create-cloud-account) guide. Set `backup.gcp.cloudAccountName` to its name.

3. Add the **Storage Admin** role to the GCP service account associated with the Cloud Account. The template additionally binds the identity to `roles/storage.objectAdmin` on exactly the bucket in `backup.gcp.bucket`.

4. Set `backup.gcp.prefix` to the folder path where backups will be stored.

### MinIO

No Cloud Account is needed — credentials are supplied as a secret.

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

2. Set `backup.minio.endpoint` to the MinIO S3 API address including the port. For the [minio](/template-catalog/templates/minio) template deployed in the same GVC, use `http://WORKLOAD_NAME: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 `admin.password`:

```bash theme={null}
cpln secret create-dictionary --name my-postgres-minio-credentials \
  --entry accessKey=MINIO_ACCESS_KEY \
  --entry secretKey=MINIO_SECRET_KEY
```

4. Set `backup.minio.prefix` to the folder path where backups will be stored.

<Note>
  The policy this template creates grants the workload `reveal` on both the database credentials secret and the MinIO credentials secret, and on nothing else.
</Note>

## Restoring a Backup

Run the following from a client with access to the backup bucket, using the username and password from your credentials secret.

**AWS S3:**

```sh theme={null}
export PGPASSWORD="PASSWORD"

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

unset PGPASSWORD
```

**GCS:**

```sh theme={null}
export PGPASSWORD="PASSWORD"

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

unset PGPASSWORD
```

**MinIO:**

```sh 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/BACKUP_FILE.sql.gz" - --endpoint-url "http://MINIO_ENDPOINT:9000" \
  | gunzip \
  | psql \
      --host=RELEASE_NAME-postgres.GVC_NAME.cpln.local \
      --port=5432 \
      --username=USERNAME \
      --dbname=postgres

unset PGPASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
```

## Connecting

This template exposes no public endpoint. Everything below is reachable from inside the GVC only.

| Path               | Address                                              | Notes                                                                            |
| ------------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------- |
| PostgreSQL, pooled | `RELEASE_NAME-pgbouncer.GVC_NAME.cpln.local:5432`    | Only when `pgbouncer.enabled: true`. Prefer this endpoint when the pooler is on. |
| PostgreSQL, direct | `RELEASE_NAME-postgres.GVC_NAME.cpln.local:5432`     | Subject to `internalAccess.type`.                                                |
| Credentials        | `cpln secret reveal CREDENTIALS_SECRET_NAME -o yaml` | Never stored in the Helm release.                                                |

## Important Notes

* **Create the credentials secret before installing.** A reference to a secret that does not exist wedges the workload with no log output at all; see [Prerequisites](#prerequisites) for the one command that shows the reason.
* **Credentials are read only when the volume is first initialized.** Rotating the secret afterwards does not change the stored password — use `ALTER ROLE` inside PostgreSQL and update the secret to match.
* **Do not scale past one replica.** This is a single instance on a single volume, not a replicated cluster.
* **Data lives on the volume set** and survives redeploys; `cpln helm uninstall` deletes it, taking the database with it. Your credentials secret is yours and is left alone.
* **A `cpln helm upgrade` restarts the server.** Nothing serializes the rollout on a stateful workload, so treat every upgrade as a short planned write outage. The first upgrade after an install re-applies resources even when the values are byte-identical.
* **Upgrading from 3.3.0 or earlier is a breaking change** — the removed keys stop the upgrade rather than resetting a password. See [Upgrading From 3.3.0 or Earlier](#upgrading-from-3-3-0-or-earlier).

## External References

<CardGroup cols={2}>
  <Card title="PostgreSQL Documentation" icon="database" href="https://www.postgresql.org/docs/">
    Official PostgreSQL documentation
  </Card>

  <Card title="PostgreSQL Backup and Restore" icon="book" href="https://www.postgresql.org/docs/current/backup-dump.html">
    Upstream guidance on logical dumps and restores
  </Card>

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

  <Card title="Cloud Accounts" icon="cloud" href="https://docs.controlplane.com/guides/create-cloud-account">
    Create a Control Plane Cloud Account for backup storage access
  </Card>

  <Card title="Backup Image Source" icon="github" href="https://github.com/controlplane-com/backup-images/tree/main/postgres-backup">
    Source code for the PostgreSQL backup container image
  </Card>

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