> ## 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.

# Polaris

> Deploy Apache Polaris, an Apache Iceberg REST catalog, on Control Plane. A stateless, horizontally scalable server with a bundled PostgreSQL metastore (single-instance or HA). Needs an existing GVC plus two prerequisite secrets, root client credentials and a token signing key.

## Overview

Apache Polaris is an [Apache Iceberg](https://iceberg.apache.org/) **REST catalog**: the service that tells a query engine which tables exist, where their metadata lives in object storage, and who may read them. It is the piece that turns a bucket of Parquet files into a lakehouse. Together with [SeaweedFS](/template-catalog/templates/seaweedfs) (or any S3-compatible bucket) for storage and [Trino](/template-catalog/templates/trino) for queries, it completes an Iceberg stack wired entirely over internal GVC DNS.

The Polaris server is stateless. Every catalog, namespace, table pointer, principal and grant lives in a PostgreSQL metastore that the template deploys for you, single-instance by default or highly available with one flag, so the `replicas` knob scales the catalog horizontally with nothing else to coordinate.

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

### What Gets Created

| Resource | Name | Purpose | Optional |
| - | - | - | - |
| Workload | `RELEASE_NAME-polaris` | Polaris server (standard, `replicas` replicas, no volume): Iceberg REST and management API on port `8181`, health and metrics on port `8182` | — |
| Workload | `RELEASE_NAME-polaris-bootstrap` | Runs the Polaris admin tool to create the realm and its root principal, then idles (standard, 1 replica, no ports) | — |
| Secret | `RELEASE_NAME-polaris-bootstrap-script` | Opaque secret holding the bootstrap script, mounted as a file | — |
| Identity & Policy | `RELEASE_NAME-polaris-identity`, `RELEASE_NAME-polaris-policy` | Server identity with `reveal` on the metastore credentials, the token signing key and (when set) the object-storage credentials | — |
| Identity & Policy | `RELEASE_NAME-polaris-bootstrap-identity`, `RELEASE_NAME-polaris-bootstrap-policy` | Bootstrap identity with `reveal` on the metastore credentials, the root credentials and the bootstrap script. Only this identity can read the root credentials | — |
| Secret | `my-polaris-db-credentials` | Dictionary secret **created by this chart** from `postgres.credentials.*`, holding `username`, `password` and `database`. Its name is the active metastore block's `config.credentialsSecretName` | — |
| Workload | `RELEASE_NAME-postgres` | Single-instance PostgreSQL metastore (stateful, 1 replica) | `postgres.enabled: true` (default) |
| Volume Set | `RELEASE_NAME-pg-vs` | 10 GiB `ext4` volume for the metastore | `postgres.enabled: true` (default) |
| Identity & Policy | `RELEASE_NAME-pg-identity`, `RELEASE_NAME-pg-policy` | PostgreSQL subchart identity with `reveal` on the credentials secret | `postgres.enabled: true` (default) |
| Workload | `RELEASE_NAME-postgres-backup` | Scheduled logical backup cron job | `postgres.backup.enabled: true` |
| Workload | `RELEASE_NAME-postgres-ha` | Patroni PostgreSQL cluster (stateful, 3 replicas) | `postgresHA.enabled: true` |
| Workload | `RELEASE_NAME-etcd` | etcd consensus store for Patroni (stateful, 3 replicas) | `postgresHA.enabled: true` |
| Workload | `RELEASE_NAME-postgres-ha-proxy` | HAProxy leader endpoint that Polaris connects through | `postgresHA.enabled: true` |
| Volume Set | `RELEASE_NAME-postgres-ha-vs`, `RELEASE_NAME-etcd-vs` | 10 GiB `ext4` per replica | `postgresHA.enabled: true` |
| Identity & Policy | `RELEASE_NAME-postgres-ha-identity`, `RELEASE_NAME-postgres-ha-policy`, `RELEASE_NAME-etcd-identity`, `RELEASE_NAME-etcd-policy` | HA subchart identities | `postgresHA.enabled: true` |
| Secret | `RELEASE_NAME-patroni-startup`, `RELEASE_NAME-postgres-proxy-startup`, `RELEASE_NAME-etcd-startup`, `RELEASE_NAME-wal-g-backup-script` | Opaque startup scripts for the HA subchart | `postgresHA.enabled: true` |
| Workload | `RELEASE_NAME-postgres-ha-backup` | Scheduled logical backup cron job | `postgresHA.backup.enabled: true` with `mode: logical` |
| Secret | `RELEASE_NAME-postgres-config` | Non-sensitive backup settings (bucket, region or endpoint) | `postgresHA.backup.enabled: true` |

In HA mode with `postgresHA.backup.mode: wal-g`, no backup cron workload is created; a `wal-g-backup` sidecar container runs inside `RELEASE_NAME-postgres-ha` instead. The Polaris server itself has no volume set.

## Prerequisites

**Two secrets must exist before you install.** They are referenced by name and never pass through Helm values. Secrets are org-level, so no GVC flag is involved.

<Steps>
  <Step title="Create the root credentials secret">
    A [dictionary secret](/guides/create-secret/dictionary) holding `CLIENT_ID` and `CLIENT_SECRET`. These become the realm's root principal at bootstrap, and they are what Trino, Spark and any other Iceberg REST client authenticate with. **Neither value may contain a comma.**

    ```bash theme={null}
    cpln secret create-dictionary --name ROOT_CREDENTIALS_SECRET_NAME \
      --entry CLIENT_ID=root \
      --entry CLIENT_SECRET="$(openssl rand -hex 24)"
    ```

    Set `rootCredentials.secretName` to this name.
  </Step>

  <Step title="Create the token signing key secret">
    An [opaque secret](/guides/create-secret/opaque) whose payload is a random string of 32 or more characters. Every replica signs and validates access tokens with it, so tokens survive restarts and are accepted by every replica.

    ```bash theme={null}
    printf '%s' "$(openssl rand -hex 32)" | cpln secret create-opaque --name SIGNING_KEY_SECRET_NAME --encoding plain -f -
    ```

    Set `tokenSigningKey.secretName` to this name.
  </Step>
</Steps>

<Warning>
  **Create both secrets before installing, or the deployment wedges silently.** 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` — check `RELEASE_NAME-polaris` for the signing key and `RELEASE_NAME-polaris-bootstrap` for the root credentials:

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

  Use `get-deployments`; plain `cpln workload get` has no `versions` key. Creating the missing secret repairs the deployment on its own after several minutes, or force a redeployment to skip the wait:

  ```bash theme={null}
  cpln workload force-redeployment RELEASE_NAME-polaris --gvc GVC_NAME
  ```
</Warning>

The metastore password is **not** a prerequisite: this chart creates the metastore credentials secret from `postgres.credentials.*`. Change the password before installing.

Optional, and not needed for the install itself:

* **Object-storage credentials** for the bucket that holds your Iceberg tables. Polaris runs without them and catalogs can be added later — see [Building a Lakehouse](#building-a-lakehouse).
* **A bucket and Cloud Account** for metastore backups — see [Backing Up](#backing-up).

## Installation

Install with your two secret names and a strong metastore password:

```bash theme={null}
cpln helm install RELEASE_NAME oci://ghcr.io/controlplane-com/templates/polaris \
  --version 1.2.1 --gvc GVC_NAME \
  --set rootCredentials.secretName=ROOT_CREDENTIALS_SECRET_NAME \
  --set tokenSigningKey.secretName=SIGNING_KEY_SECRET_NAME \
  --set postgres.credentials.password='YOUR-STRONG-PASSWORD'
```

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

The first start takes a few minutes. While PostgreSQL is still coming up, the server may restart a few times and the bootstrap workload logs `attempt failed (metastore not ready yet?) - retrying in 10s`; both recover on their own once the metastore answers, so do not interrupt the install. The HA metastore takes noticeably longer than the single instance.

## Configuration

### Server

```yaml theme={null}
image: apache/polaris:1.7.0

# Stateless replicas behind one metastore. All replicas share the token signing
# key below, so a token minted by one is accepted by every other.
replicas: 1

resources:
  minCpu: 500m
  maxCpu: 1000m
  minMemory: 1Gi
  maxMemory: 2Gi # JVM heap is a percentage OF THIS — see jvm.maxRAMPercentage
```

* `replicas` — A fixed number of interchangeable Polaris replicas (minimum `1`; there is no request-based autoscaling). See [Scaling and Availability](#scaling-and-availability).
* `resources` — Per replica. `minMemory` may not exceed `maxMemory`, and `maxCpu` may not exceed four times `minCpu`; the chart refuses to render otherwise and names the value to fix.

### JVM

```yaml theme={null}
jvm:
  maxRAMPercentage: 70
```

The JVM heap is this percentage of `resources.maxMemory` (allowed range 40–80), so `maxMemory` is normally the only number you change.

### Realm

```yaml theme={null}
realm: POLARIS
```

Polaris isolates tenants by realm. This template ships exactly one and does **not** require the `Polaris-Realm` header: a request without it resolves to this realm, which is what Trino's Iceberg REST connector needs. A request naming a different realm is rejected with `404`. The name may contain only letters, digits, hyphens and underscores.

<Warning>
  **`realm` is permanent after the first install.** Renaming it bootstraps a new, empty realm and hides the existing catalogs. They are not deleted, and changing the name back makes them visible again, but nothing moves between realms.
</Warning>

### Credentials

```yaml theme={null}
rootCredentials:
  secretName: my-polaris-root-credentials

tokenSigningKey:
  secretName: my-polaris-signing-key
```

* `rootCredentials.secretName` — The prerequisite dictionary secret holding `CLIENT_ID` and `CLIENT_SECRET`. Required.
* `tokenSigningKey.secretName` — The prerequisite opaque secret whose payload is the shared token signing key. Required.

<Warning>
  **Root credentials are write-once.** They are applied when the realm is first bootstrapped, and changing the secret afterwards has no effect on the existing realm. To rotate, create a new principal through the Polaris management API. Rotating the **signing key** does take effect once the server is redeployed, and it invalidates every outstanding token, so clients must request a new one.
</Warning>

### Object Storage

```yaml theme={null}
storage:
  credentialsSecretName: "" # e.g. my-seaweedfs-s3-credentials
  region: us-east-1 # AWS_REGION; S3-compatible servers ignore the value but the SDK requires one
```

* `storage.credentialsSecretName` — Name of a dictionary secret holding `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, used for Polaris's **own** reads and writes of Iceberg metadata. Empty by default, which is a valid install: the server runs and answers, but catalogs on object storage cannot read or write. Per-provider setup is in [Building a Lakehouse](#building-a-lakehouse).
* `storage.region` — The bucket's region. S3-compatible servers ignore it, but the AWS SDK requires a value.

<Warning>
  **This template does not vend STS credentials.** Polaris does not hand short-lived, scoped credentials to query engines, so **each engine needs its own object-storage credentials** as well. Keep `iceberg.rest-catalog.vended-credentials-enabled=false` in Trino, and set `stsUnavailable: true` when you create a catalog on an S3-compatible server.
</Warning>

### Bootstrap

```yaml theme={null}
bootstrap:
  image: apache/polaris-admin-tool:1.7.0
  resources:
    minCpu: 100m
    maxCpu: 400m
    minMemory: 128Mi
    maxMemory: 1Gi
```

Keep `bootstrap.image` on the same tag as `image`. The bootstrap workload creates the realm schema and the root principal, then idles. The operation is idempotent: on a restart or upgrade it logs that the realm is already bootstrapped and idles again.

### Access

```yaml theme={null}
publicAccess:
  enabled: false # true = Iceberg REST + management API on the auto *.cpln.app HTTPS endpoint

internalAccess: # who may reach Polaris from inside Control Plane
  type: same-gvc # options: none, same-gvc, same-org, workload-list ('none' hides it from in-GVC engines)
  workloads: [] # used with workload-list
```

| Type | Description |
| - | - |
| `none` | No internal access. Query engines in the same GVC cannot reach Polaris. |
| `same-gvc` | Allow access from all workloads in the same GVC (default). |
| `same-org` | Allow access from all workloads in the same organization. |
| `workload-list` | Allow access only from the workloads listed in `internalAccess.workloads`, as `//gvc/GVC_NAME/workload/WORKLOAD_NAME`. |

With `publicAccess.enabled: true`, only port `8181` is served on the public endpoint; the health and metrics endpoints on `8182` stay reachable from inside the GVC only. A change to either access setting takes a few minutes to take effect, so re-test rather than trusting the first response.

### Single-Instance Metastore

The default metastore. Exactly one of `postgres` and `postgresHA` must be enabled; the chart refuses to render otherwise.

```yaml theme={null}
postgres:
  enabled: true
  credentials:
    username: polaris
    password: change-me-polaris-db # change before installing
    database: polaris
  config:
    credentialsSecretName: my-polaris-db-credentials
  resources:
    minCpu: 200m
    maxCpu: 500m
    minMemory: 256Mi
    maxMemory: 512Mi
  volumeset:
    capacity: 10 # initial capacity in GiB (minimum is 10)

  backup: # optional database backups (Postgres 17+) — see README storage setup
    enabled: false
    image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0
    schedule: "0 2 * * *"
    resources:
      cpu: 100m
      memory: 128Mi
    provider: aws # options: aws, gcp, minio
    aws:
      bucket: my-polaris-backup-bucket
      region: us-east-1
      cloudAccountName: my-s3-cloud-account
      policyName: my-polaris-backup-policy
      prefix: postgres/backups
    gcp:
      bucket: my-polaris-backup-bucket
      cloudAccountName: my-gcs-cloud-account
      prefix: postgres/backups
    minio:
      endpoint: http://my-minio-workload:9000
      bucket: my-polaris-backup-bucket
      credentialsSecretName: my-polaris-minio-credentials
      prefix: postgres/backups
```

* `postgres.credentials.*` — The metastore's username, password and database name. **Change the password before installing.** The chart writes these three values into the credentials secret in **both** metastore modes, even though they sit under the `postgres` block, and Polaris connects to the database named here.
* `postgres.config.credentialsSecretName` — Name of the dictionary secret this chart creates in single-instance mode. Secret names are organization-wide, so give each Polaris release its own: a second release left on the default name is refused at install and creates nothing, and the first release is unaffected.
* `postgres.backup.*` — Optional scheduled backups. See [Backing Up](#backing-up).

<Note>
  The credentials are applied when the metastore volume is first initialized. Changing `postgres.credentials.password` on an existing release updates the secret but not the password PostgreSQL enforces, so Polaris can no longer authenticate. To rotate, change the password inside PostgreSQL first, then upgrade the release with the matching value and force a redeployment of `RELEASE_NAME-polaris` and `RELEASE_NAME-polaris-bootstrap` so they pick up the new value.
</Note>

### Highly Available Metastore

```yaml theme={null}
postgresHA:
  enabled: false
  config:
    credentialsSecretName: my-polaris-db-credentials
  replicas: 3
  volumeset:
    capacity: 10 # initial capacity in GiB per replica (minimum is 10)

  backup: # optional database backups — see README storage setup
    enabled: false
    mode: logical # logical or wal-g
    resources:
      cpu: 100m
      memory: 128Mi
    logical:
      image: ghcr.io/controlplane-com/backup-images/postgres-backup:17.1.0
      schedule: "0 2 * * *"
    walg:
      intervalSeconds: 21600
    provider: aws # options: aws, gcp, minio
    aws:
      bucket: my-polaris-backup-bucket
      region: us-east-1
      cloudAccountName: my-s3-cloud-account
      policyName: my-polaris-backup-policy
      prefix: postgres/backups
    gcp:
      bucket: my-polaris-backup-bucket
      cloudAccountName: my-gcs-cloud-account
      prefix: postgres/backups
    minio:
      endpoint: http://my-minio-workload:9000
      bucket: my-polaris-backup-bucket
      credentialsSecretName: my-polaris-minio-credentials
      prefix: postgres/backups
```

Set `postgresHA.enabled: true` and `postgres.enabled: false` for near-zero-downtime upgrades and automatic database failover: 3 Patroni PostgreSQL replicas, 3 etcd replicas and an HAProxy leader endpoint that Polaris connects through.

* `postgresHA.config.credentialsSecretName` — Name of the dictionary secret this chart creates in HA mode, built from `postgres.credentials.*` above. Give each release its own name, as in single-instance mode.
* `postgresHA.replicas` / `postgresHA.volumeset.capacity` — PostgreSQL replica count and volume size per replica. The remaining cluster settings take the defaults of the [PostgreSQL Highly Available](/template-catalog/templates/postgres-highly-available) template; its HAProxy endpoint must stay enabled, because it is Polaris's database address.
* `postgresHA.backup.*` — Optional backups as scheduled logical dumps or continuous WAL-G archiving. See [Backing Up](#backing-up).

| | `postgres` (default) | `postgresHA` |
| - | - | - |
| What runs | One single-replica PostgreSQL workload | 3 Patroni replicas, 3 etcd replicas, an HAProxy leader endpoint |
| Database failover | None; a restart of the database is downtime for Polaris | Automatic (Patroni leader election) |
| First install | Faster | Slower, while the cluster forms |

Switching an existing release between modes is a new, empty metastore, not a migration.

## Connecting

| What | Address | Notes |
| - | - | - |
| Base URL (internal) | `http://RELEASE_NAME-polaris.GVC_NAME.cpln.local:8181` | Reachable from workloads in the `internalAccess` scope. Always use the full `.cpln.local` hostname. |
| Base URL (public) | `status.canonicalEndpoint` of `RELEASE_NAME-polaris` | Only with `publicAccess.enabled: true`. Read it with `cpln workload get RELEASE_NAME-polaris --gvc GVC_NAME -o yaml`. |
| Iceberg REST API | `BASE_URL/api/catalog` | The URI query engines are configured with. |
| Management API | `BASE_URL/api/management/v1` | Catalogs, principals, roles and grants. |
| OAuth2 token endpoint | `BASE_URL/api/catalog/v1/oauth/tokens` | Client-credentials grant; every API call needs the resulting bearer token. |
| Health and metrics | `http://RELEASE_NAME-polaris.GVC_NAME.cpln.local:8182/q/health` and `/q/metrics` | Inside the GVC only; never on the public endpoint. |
| Client credentials | `cpln secret reveal ROOT_CREDENTIALS_SECRET_NAME -o yaml` | `CLIENT_ID` and `CLIENT_SECRET`. |
| Metastore | `RELEASE_NAME-postgres.GVC_NAME.cpln.local:5432`, or `RELEASE_NAME-postgres-ha-proxy.GVC_NAME.cpln.local:5432` in HA mode | Credentials: `cpln secret reveal DB_CREDENTIALS_SECRET_NAME -o yaml`, where the name is your `config.credentialsSecretName` (default `my-polaris-db-credentials`). |

To get a token, request one with the root credentials. From your own machine, open a tunnel to the server first:

```bash theme={null}
cpln port-forward RELEASE_NAME-polaris 8181:8181 --gvc GVC_NAME
```

Then, in a second terminal:

```bash theme={null}
curl -X POST http://127.0.0.1:8181/api/catalog/v1/oauth/tokens \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d scope=PRINCIPAL_ROLE:ALL
```

The response carries the token in `access_token`; send it as `Authorization: Bearer TOKEN`. A missing token or a wrong client secret is rejected with `401`. The same request works against the public base URL when `publicAccess.enabled` is `true`. Reaching Polaris through `cpln port-forward` has not been exercised against this template; if the tunnel does not answer, run the same request from a workload inside the GVC against the internal base URL.

### Building a Lakehouse

Storage, catalog and query engine are three separate templates. This is the path from an empty bucket to queryable Iceberg tables.

<Steps>
  <Step title="Prepare a bucket and its credentials">
    Polaris reads and writes table metadata with static credentials, held in a dictionary secret with the keys `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Only S3 and S3-compatible storage are supported by this template.

    <Tabs>
      <Tab title="SeaweedFS">
        Install [SeaweedFS](/template-catalog/templates/seaweedfs) in the same GVC with a bucket for your warehouse (for example `s3.buckets: [lakehouse]`). Its `s3.credentialsSecretName` secret already holds `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, so set Polaris's `storage.credentialsSecretName` to that **same** secret. There is nothing else to create. Its S3 endpoint is `http://SEAWEEDFS_WORKLOAD_NAME.GVC_NAME.cpln.local:8333`.
      </Tab>

      <Tab title="MinIO">
        Install [MinIO](/template-catalog/templates/minio) and create a bucket. MinIO's admin secret uses the keys `username` and `password`, not the `AWS_*` names Polaris reads, so create a separate secret with the same values under the names Polaris expects:

        ```bash theme={null}
        cpln secret create-dictionary --name S3_CREDENTIALS_SECRET_NAME \
          --entry AWS_ACCESS_KEY_ID=MINIO_USERNAME \
          --entry AWS_SECRET_ACCESS_KEY='MINIO_PASSWORD'
        ```

        Set `storage.credentialsSecretName` to this name. Its S3 endpoint is `http://MINIO_WORKLOAD_NAME.GVC_NAME.cpln.local:9000`.
      </Tab>

      <Tab title="AWS S3">
        1. Create the bucket in the region you will use, keep public access blocked, and set `storage.region` to that region.
        2. Create an IAM user for Polaris and attach this bucket-scoped policy (replace `YOUR_BUCKET`). Polaris needs no other permission:

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

        3. Create an access key for that user and store both halves:

        ```bash theme={null}
        cpln secret create-dictionary --name S3_CREDENTIALS_SECRET_NAME \
          --entry AWS_ACCESS_KEY_ID=ACCESS_KEY_ID \
          --entry AWS_SECRET_ACCESS_KEY='SECRET_ACCESS_KEY'
        ```

        4. Set `storage.credentialsSecretName` to this name. Give your query engine the same key, or a similarly scoped one of its own.
      </Tab>
    </Tabs>

    Apply the setting to an existing release with `cpln helm upgrade`, using the same chart version and your other values.
  </Step>

  <Step title="Get an access token">
    Request a token as shown in [Connecting](#connecting) and export it as `TOKEN`.
  </Step>

  <Step title="Create a catalog">
    Catalogs are created through the management API after install, not through values. Replace `BASE_URL` with your internal or public base URL, `YOUR_BUCKET` with your bucket, and the endpoint with your storage's S3 endpoint:

    ```bash theme={null}
    curl -X POST BASE_URL/api/management/v1/catalogs \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{
      "catalog": {
        "name": "lakehouse",
        "type": "INTERNAL",
        "properties": { "default-base-location": "s3://YOUR_BUCKET/warehouse" },
        "storageConfigInfo": {
          "storageType": "S3",
          "allowedLocations": ["s3://YOUR_BUCKET/warehouse"],
          "endpoint": "http://SEAWEEDFS_WORKLOAD_NAME.GVC_NAME.cpln.local:8333",
          "endpointInternal": "http://SEAWEEDFS_WORKLOAD_NAME.GVC_NAME.cpln.local:8333",
          "pathStyleAccess": true,
          "stsUnavailable": true,
          "region": "us-east-1"
        }
      }
    }'

    # Let the root principal administer the new catalog
    curl -X PUT BASE_URL/api/management/v1/principal-roles/service_admin/catalog-roles/lakehouse \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{"catalogRole":{"name":"catalog_admin"}}'
    ```

    `pathStyleAccess: true` and `stsUnavailable: true` are what an S3-compatible server needs. For AWS S3 itself, drop `endpoint`, `endpointInternal` and `pathStyleAccess`, and set `region` to the bucket's region.
  </Step>

  <Step title="Point Trino at it">
    Add this entry to the [Trino](/template-catalog/templates/trino) template's `catalogs` value. Trino authenticates to Polaris with the root credentials and reaches the bucket with its own S3 keys, because credential vending is off:

    ```yaml theme={null}
    catalogs:
      - name: iceberg
        properties: |
          connector.name=iceberg
          iceberg.catalog.type=rest
          iceberg.rest-catalog.uri=http://POLARIS_WORKLOAD_NAME.GVC_NAME.cpln.local:8181/api/catalog
          iceberg.rest-catalog.security=OAUTH2
          iceberg.rest-catalog.oauth2.credential=${ENV:POLARIS_CLIENT_ID}:${ENV:POLARIS_CLIENT_SECRET}
          iceberg.rest-catalog.oauth2.scope=PRINCIPAL_ROLE:ALL
          iceberg.rest-catalog.warehouse=lakehouse
          iceberg.rest-catalog.vended-credentials-enabled=false
          fs.native-s3.enabled=true
          s3.endpoint=http://SEAWEEDFS_WORKLOAD_NAME.GVC_NAME.cpln.local:8333
          s3.region=us-east-1
          s3.path-style-access=true
          s3.aws-access-key=${ENV:S3_ACCESS_KEY}
          s3.aws-secret-key=${ENV:S3_SECRET_KEY}
        secrets:
          - env: POLARIS_CLIENT_ID
            secretName: ROOT_CREDENTIALS_SECRET_NAME
            secretKey: CLIENT_ID
          - env: POLARIS_CLIENT_SECRET
            secretName: ROOT_CREDENTIALS_SECRET_NAME
            secretKey: CLIENT_SECRET
          - env: S3_ACCESS_KEY
            secretName: S3_CREDENTIALS_SECRET_NAME
            secretKey: AWS_ACCESS_KEY_ID
          - env: S3_SECRET_KEY
            secretName: S3_CREDENTIALS_SECRET_NAME
            secretKey: AWS_SECRET_ACCESS_KEY
    ```

    `POLARIS_WORKLOAD_NAME` is your Polaris release's `RELEASE_NAME-polaris`. `iceberg.rest-catalog.warehouse` is the catalog name you created.
  </Step>

  <Step title="Query it">
    `CREATE SCHEMA`, `CREATE TABLE`, `INSERT` and `SELECT` now work against tables stored in the bucket:

    ```sql theme={null}
    CREATE SCHEMA iceberg.demo;
    CREATE TABLE iceberg.demo.t (a integer, b varchar);
    INSERT INTO iceberg.demo.t VALUES (1,'alpha'), (2,'beta'), (3,'gamma');
    SELECT count(*) FROM iceberg.demo.t;
    ```
  </Step>
</Steps>

### Other Iceberg Clients

Spark, PyIceberg, Flink and any other Iceberg REST client connect with the same four settings: the REST URI (`BASE_URL/api/catalog`), the OAuth2 client credential `CLIENT_ID:CLIENT_SECRET`, the scope `PRINCIPAL_ROLE:ALL`, and the catalog name as the warehouse. Each also needs its own object-storage credentials. Only the Trino path has been verified with this template. The [Spark](/template-catalog/templates/spark) template's image does not include the Iceberg Spark runtime, so follow the upstream [Iceberg Spark configuration](https://iceberg.apache.org/docs/latest/spark-configuration/) guide to add it.

## Operations

### Backing Up

Metastore backups are optional and disabled by default. They protect every catalog, namespace, table pointer, principal and grant; the Iceberg data files in your bucket are not part of them. Enable them with `postgres.backup.enabled` or `postgresHA.backup.enabled`, matching your metastore mode, and complete the setup for your provider **before** enabling. The keys below are written as `backup.*`; set them inside the enabled metastore block.

In HA mode, `postgresHA.backup.mode` selects `logical` (a scheduled dump from the `RELEASE_NAME-postgres-ha-backup` cron workload) or `wal-g` (continuous WAL archiving from a `wal-g-backup` sidecar inside `RELEASE_NAME-postgres-ha`, with a base backup every `walg.intervalSeconds`). Single-instance mode takes scheduled logical dumps only.

<Tabs>
  <Tab title="AWS S3">
    <Steps>
      <Step title="Create a bucket">
        Create an S3 bucket. Set `backup.aws.bucket` and `backup.aws.region` to match.
      </Step>

      <Step title="Set up a Cloud Account">
        If you do not have one, [create a Cloud Account](/guides/create-cloud-account) for your AWS account. Set `backup.aws.cloudAccountName` to its name.
      </Step>

      <Step title="Create a bucket-scoped IAM policy">
        Create an AWS IAM policy with the JSON below (replace `YOUR_BUCKET`), then set `backup.aws.policyName` to the policy's name:

        ```json theme={null}
        {
          "Version": "2012-10-17",
          "Statement": [{
            "Effect": "Allow",
            "Action": [
              "s3:ListBucket",
              "s3:GetBucketLocation",
              "s3:GetObject",
              "s3:PutObject",
              "s3:DeleteObject",
              "s3:AbortMultipartUpload"
            ],
            "Resource": [
              "arn:aws:s3:::YOUR_BUCKET",
              "arn:aws:s3:::YOUR_BUCKET/*"
            ]
          }]
        }
        ```
      </Step>
    </Steps>
  </Tab>

  <Tab title="Google Cloud Storage">
    <Steps>
      <Step title="Create a bucket">
        Create a GCS bucket. Set `backup.gcp.bucket` to its name.
      </Step>

      <Step title="Set up a Cloud Account">
        If you do not have one, [create a Cloud Account](/guides/create-cloud-account) for your GCP project and grant its service account **Storage Object Admin** (`roles/storage.objectAdmin`) on the bucket. Set `backup.gcp.cloudAccountName` to its name.
      </Step>
    </Steps>
  </Tab>

  <Tab title="S3-compatible (MinIO)">
    <Steps>
      <Step title="Create a bucket">
        Create the bucket on your server. Set `backup.minio.bucket` to its name.
      </Step>

      <Step title="Set the endpoint">
        Set `backup.minio.endpoint` to the S3 API address including the port. For the [MinIO](/template-catalog/templates/minio) template in the same GVC, this is `http://MINIO_WORKLOAD_NAME.GVC_NAME.cpln.local:9000`.
      </Step>

      <Step title="Create the credentials secret">
        Create a [dictionary secret](/guides/create-secret/dictionary) holding exactly the keys `accessKey` and `secretKey`, with credentials that can read and write the bucket:

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

        Set `backup.minio.credentialsSecretName` to this name. The same shape is used in both metastore modes.
      </Step>
    </Steps>

    <Warning>
      **Create the MinIO secret before enabling backups, or the workload that references it wedges silently** — the backup cron workload, or in `wal-g` mode the database workload `RELEASE_NAME-postgres-ha` itself. `cpln logs` returns nothing; read `status.versions[].message`:

      ```bash theme={null}
      cpln workload get-deployments RELEASE_NAME-postgres-ha-backup --gvc GVC_NAME -o yaml
      ```
    </Warning>
  </Tab>
</Tabs>

### Restoring a Backup

<Warning>
  **This restore path has not been exercised against a Polaris install.** It follows the restore procedure of the bundled database templates. Rehearse it on a throwaway release before you depend on it.
</Warning>

The metastore backups are produced by the bundled database templates, so restore them with those templates' procedures: [PostgreSQL](/template-catalog/templates/postgres#restoring-a-backup) for single-instance mode, and [PostgreSQL Highly Available](/template-catalog/templates/postgres-highly-available#restoring-a-backup) for HA mode, using the host from [Connecting](#connecting) and the credentials from your metastore credentials secret. Two Polaris-specific constraints apply:

1. **Keep `realm` identical** to the value the backup was taken under, or the restored catalogs stay invisible.
2. **Restore into a metastore that does not already hold a bootstrapped realm.** A new release bootstraps its realm on first start, so restoring over it collides with the objects the bootstrap created. After a restore, force a redeployment of `RELEASE_NAME-polaris` so every replica reconnects.

### Upgrading from 1.0.x

Apply every subsection from your version onward, in order, before running `cpln helm upgrade`. A removed key is rejected at render, so an upgrade that still carries one fails before anything is applied and the running release is left untouched.

Version `1.1.0` moved the single-instance metastore to the `postgres` 3.4.1 template, which no longer takes credentials or MinIO keys as values. This template creates the credentials secret itself, so there is **no new prerequisite**, only renames on the single-instance path:

| Removed key | Replacement |
| - | - |
| `postgres.config.username` | `postgres.credentials.username` |
| `postgres.config.password` | `postgres.credentials.password` |
| `postgres.config.database` | `postgres.credentials.database` |
| — | `postgres.config.credentialsSecretName` names the secret this chart creates (default `my-polaris-db-credentials`) |
| `postgres.backup.minio.accessKey`, `postgres.backup.minio.secretKey` | `postgres.backup.minio.credentialsSecretName`, a dictionary secret you create (MinIO backups only, see [Backing Up](#backing-up)) |

Carrying an old key forward fails with the bundled database template's message, which begins `config.username was REMOVED in postgres 3.4.0` and tells you to create a secret. Ignore that advice for the three credentials keys: this template creates that secret from `postgres.credentials.*`. Use the username and password your metastore was initialized with, not new ones.

<Warning>
  **Template version `1.0.0` did not compact the etcd cluster inside the HA metastore**, so its backend grew with time alone and eventually went read-only, taking PostgreSQL failover with it. Only HA-mode installs are affected. Upgrading to `1.0.1` or later turns compaction on, which stops further growth but cannot shrink a backend that has already grown; a cluster that has already raised a `NOSPACE` alarm needs operator recovery rather than an upgrade. See [etcd History Compaction](/template-catalog/templates/postgres-highly-available#etcd-history-compaction).
</Warning>

### Upgrading from 1.1.0

Version `1.2.0` moved the HA metastore to `postgres-highly-available` 2.5.0, which removed the `postgresHA.postgres` block. The HA cluster now reads the same chart-created credentials secret as the single-instance path:

| Removed key | Replacement |
| - | - |
| `postgresHA.postgres.username` | `postgres.credentials.username` |
| `postgresHA.postgres.password` | `postgres.credentials.password` |
| `postgresHA.postgres.database` | `postgres.credentials.database` |
| — | `postgresHA.config.credentialsSecretName` names the secret this chart creates (default `my-polaris-db-credentials`) |

<Warning>
  **Use the credentials your cluster already has.** The HA cluster was initialized with the `postgresHA.postgres.*` values you installed with, and PostgreSQL still enforces them. Copy that username, password and database name into `postgres.credentials.*` before upgrading. New values there update the secret but not the database, and Polaris can no longer authenticate.
</Warning>

An upgrade that still carries the `postgresHA.postgres` block fails with the HA template's message, which begins ``postgres-highly-available: the `postgres` block was REMOVED in 2.5.0`` and tells you to create a dictionary secret. Ignore that part: delete the block and set `postgres.credentials.*` instead.

### Upgrading from 1.2.0

HA MinIO backups only: version `1.2.1` replaced `postgresHA.backup.minio.accessKey` and `postgresHA.backup.minio.secretKey` with `postgresHA.backup.minio.credentialsSecretName`, a dictionary secret you create holding `accessKey` and `secretKey` — see [Backing Up](#backing-up). An upgrade that still carries the old keys fails with `backup.minio.accessKey and backup.minio.secretKey were REMOVED in 2.5.0`. Create the secret before upgrading, because in `wal-g` mode the database workload itself references it.

### Scaling and Availability

* **The server scales horizontally.** Replicas share only the metastore and the token signing key, so a token minted by one replica is accepted by every other and a catalog created through one is visible on all of them. At the default `replicas: 1`, an upgrade replaces the only replica and requests fail while it restarts; with `replicas: 2` or more, a rolling upgrade keeps serving.
* **Mind the metastore's connection limit.** Each Polaris replica opens up to 20 database connections, plus one for the bootstrap workload, against PostgreSQL's default limit of 100.
* **The bootstrap workload always runs one replica** and does nothing after its first run. Do not scale it.
* **The metastore is the other half of availability.** With the default single-instance `postgres`, a database restart takes Polaris down until PostgreSQL is back. `postgresHA` adds automatic failover behind a stable endpoint, at the cost of a larger footprint.
* **The first upgrade after an install can restart the bundled database** even when no database value changed. Expect a short outage on a single-instance metastore.
* **Uninstall deletes the metastore volume sets** and the chart-created credentials secret, taking every catalog definition with them. The Iceberg files in your bucket survive, but nothing indexes them any more. Your two prerequisite secrets are not deleted.

### Troubleshooting

<AccordionGroup>
  <Accordion title="Workloads never start and cpln logs returns nothing">
    **Symptom:** `RELEASE_NAME-polaris` or `RELEASE_NAME-polaris-bootstrap` never becomes ready and `cpln logs` returns zero lines.

    **Cause:** A prerequisite secret named by `tokenSigningKey.secretName`, `rootCredentials.secretName` or `storage.credentialsSecretName` does not exist.

    **Fix:** Read `status.versions[].message` with `cpln workload get-deployments RELEASE_NAME-polaris --gvc GVC_NAME -o yaml`, create the secret it names as shown in [Prerequisites](#prerequisites), then wait several minutes or force a redeployment.
  </Accordion>

  <Accordion title="Bootstrap logs that CLIENT_ID or CLIENT_SECRET may not contain a comma">
    **Symptom:** `RELEASE_NAME-polaris-bootstrap` restarts repeatedly, logging `FATAL: neither CLIENT_ID nor CLIENT_SECRET may contain a comma`.

    **Cause:** The admin tool takes the realm, client ID and client secret as one comma-separated argument.

    **Fix:** Recreate the root credentials secret without commas, for example with `openssl rand -hex 24` as in [Prerequisites](#prerequisites), then force a redeployment of `RELEASE_NAME-polaris-bootstrap`.
  </Accordion>

  <Accordion title="Install fails with enable exactly one metastore">
    **Symptom:** Render fails with `polaris: enable exactly one metastore`.

    **Cause:** Both `postgres.enabled` and `postgresHA.enabled` are `true`, or neither is.

    **Fix:** Set exactly one of them to `true`.
  </Accordion>

  <Accordion title="Second release fails with managed by a different release">
    **Symptom:** Installing a second Polaris release in the same org fails with `The resource 'my-polaris-db-credentials' cannot be updated because it is being managed by a different release`, and nothing is created.

    **Cause:** Secret names are organization-wide, and both releases try to create the metastore credentials secret under the default name.

    **Fix:** Give the new release its own `postgres.config.credentialsSecretName` (or `postgresHA.config.credentialsSecretName` in HA mode). The first release is unaffected.
  </Accordion>

  <Accordion title="Token request returns 401 after changing the root credentials">
    **Symptom:** The token endpoint rejects the `CLIENT_ID` and `CLIENT_SECRET` now in your root credentials secret.

    **Cause:** Root credentials are applied only when the realm is first bootstrapped. Changing the secret afterwards does not change the root principal.

    **Fix:** Use the credentials the realm was bootstrapped with, and create additional principals through the management API.
  </Accordion>

  <Accordion title="Catalogs disappeared after an upgrade">
    **Symptom:** The management API lists no catalogs after an upgrade.

    **Cause:** `realm` was changed, so Polaris bootstrapped and now serves a new, empty realm.

    **Fix:** Set `realm` back to its original value and upgrade again; the original catalogs reappear.
  </Accordion>

  <Accordion title="Catalog operations fail to read or write the bucket">
    **Symptom:** Creating a table or committing data fails with an object-storage access error, while the catalog itself was created.

    **Cause:** `storage.credentialsSecretName` is empty, the secret lacks the keys `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or an S3-compatible catalog was created without `pathStyleAccess` and `stsUnavailable`.

    **Fix:** Set `storage.credentialsSecretName` to a secret with those exact keys, as in [Building a Lakehouse](#building-a-lakehouse), and recreate the catalog with the S3-compatible settings.
  </Accordion>

  <Accordion title="Upgrade fails with a REMOVED key message">
    **Symptom:** `helm upgrade` stops at render with `config.username was REMOVED in postgres 3.4.0`, ``the `postgres` block was REMOVED in 2.5.0``, or a `backup.minio.accessKey and backup.minio.secretKey were REMOVED` message.

    **Cause:** Your values predate template version `1.1.0`, `1.2.0` or `1.2.1` respectively.

    **Fix:** Follow [Upgrading from 1.0.x](#upgrading-from-1-0-x), [Upgrading from 1.1.0](#upgrading-from-1-1-0) or [Upgrading from 1.2.0](#upgrading-from-1-2-0). Do not create a metastore credentials secret yourself; this chart creates it.
  </Accordion>
</AccordionGroup>

## Important Notes

* **Create both prerequisite secrets before installing**, or the workloads wedge silently waiting on a secret that does not exist.
* **Change `postgres.credentials.password` before installing.** The default is a published placeholder, and it is the metastore password in both modes.
* **`realm` is permanent.** Renaming it hides every existing catalog behind a new, empty realm.
* **Root credentials are write-once.** Rotate by creating a new principal through the management API.
* **Rotating the token signing key invalidates every outstanding token.** Clients must request a new one.
* **No credential vending.** Polaris and each query engine hold their own object-storage credentials; keep `iceberg.rest-catalog.vended-credentials-enabled=false` in Trino.
* **Polaris does not migrate its own database schema.** Treat a future Polaris version change as an explicit schema step, not something startup handles.
* **Uninstall deletes the metastore** and every catalog definition with it. Enable backups or use `postgresHA` for anything you care about.

## External References

<CardGroup cols={2}>
  <Card title="Apache Polaris Documentation" icon="book" href="https://polaris.apache.org/releases/1.7.0/">
    Official documentation for the shipped release
  </Card>

  <Card title="Configuration Reference" icon="sliders" href="https://polaris.apache.org/releases/1.7.0/configuration/configuration-reference/">
    Every server setting and its environment-variable name
  </Card>

  <Card title="Creating a Catalog on S3" icon="bucket" href="https://polaris.apache.org/releases/1.7.0/getting-started/creating-a-catalog/s3/">
    Storage configuration for S3 and S3-compatible servers
  </Card>

  <Card title="Access Control" icon="lock" href="https://polaris.apache.org/releases/1.7.0/managing-security/access-control/">
    Principals, roles, and grants beyond the root principal
  </Card>

  <Card title="Trino Iceberg REST Catalog" icon="plug" href="https://trino.io/docs/483/object-storage/metastores.html">
    The connector properties used to point Trino at Polaris
  </Card>
</CardGroup>
