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

# Airflow

> Deploy Apache Airflow on Control Plane using the Template Catalog. Covers the prerequisite auth secret, the Fernet key that cannot be rotated, the single location the release runs in, the closed-by-default web UI reached over a port-forward, Celery workers with optional KEDA autoscaling, git-sync DAG delivery, and migrating from template version 1.x.

<Warning>
  **Template version 2.0.0 is a breaking change, and one of the changes is a data-loss hazard.**

  * **The template no longer creates a GVC.** It deploys into the GVC you install into. The `gvc.name` and `gvc.locations` values are gone; Airflow runs in a single top-level `location` string.
  * **Never upgrade a 1.x release onto 2.0.0 in place.** A 1.x release owns the GVC it created, and Helm deletes what a chart stops declaring — the upgrade destroys that GVC and **every workload, volume set and identity inside it**, including your metadata database and your DAGs. Migrate to a new release instead: [Migrating from 1.x](#migrating-from-1-x).
  * **KEDA autoscaling is now opt-in and defaults to `false`.** KEDA is a GVC-level setting a chart cannot turn on for you, and turning it on against a GVC that does not have it is accepted in silence. See [KEDA Autoscaling](#keda-autoscaling).
</Warning>

## Overview

Apache Airflow is an open-source platform for programmatically authoring, scheduling, and monitoring workflows. This template deploys a full Airflow 3.x stack into an existing GVC using the CeleryExecutor, with Redis as the task broker, PostgreSQL as the metadata database, Celery workers at a fixed replica count or scaled by KEDA, and optional git-sync DAG delivery.

The admin login and both signing keys are not template values. They come from a dictionary secret you create before installing, so they never pass through Helm and never land in the release. The web UI ships **closed to the internet**, because it can trigger arbitrary code and decrypt every credential stored in an Airflow Connection.

<Warning>
  **Template version 1.5.0 was also a breaking change.** `airflow.auth.jwtSecret`, `airflow.auth.fernetKey`, `airflow.admin.password` and `gitSync.auth.token` were removed, `airflow.auth.jwtExpirationDelta` and `airflow.auth.jwtRefreshThreshold` were removed because Airflow 3.x ignores them, and the public web UI became closed by default. An install that still sets any removed key fails at render. If you are coming from 1.4 or earlier, read [Earlier Breaking Changes](#earlier-breaking-changes) as well — the Fernet key change is a data migration, not an upgrade.
</Warning>

### What Gets Created

* **Airflow Webserver** — (`RELEASE_NAME-airflow-webserver`): a stateful, single-replica workload running the API server, scheduler, dag-processor and triggerer in one container, serving the UI and REST API on port `8080`. It is the only tier with external inbound access, and the only one that runs the boot-time GVC checks.
* **Celery Workers** — (`RELEASE_NAME-airflow-celery-worker`): a stateful workload executing DAG tasks, at a fixed `airflow.celeryWorker.replicas` or scaled by KEDA on the Redis queue length.
* **Redis** — (`RELEASE_NAME-airflow-redis`): the Celery broker, on its own persistent volume set.
* **PostgreSQL** — (`RELEASE_NAME-airflow-postgres`): the Airflow metadata database, on its own persistent volume set.
* **Airflow Volume Set** — (`RELEASE_NAME-airflow-vs`): a shared-filesystem volume mounted as the Airflow home by the webserver and every worker, so they all see the same DAGs and logs.
* **Config Secret** — (`RELEASE_NAME-airflow-config`): a dictionary secret holding the metadata database username and password. It carries no key material.
* **Webserver Startup Secret** — (`RELEASE_NAME-airflow-webserver-startup`): an opaque secret holding the webserver's startup script, which runs the topology checks before Airflow's own boot sequence.
* **Identity & Two Policies** — An identity bound to the workloads, with `reveal` on exactly the secrets they mount (the database config secret, the startup script, your auth secret, and your git token secret when git-sync uses one), plus `view` on the one GVC you install into so the webserver can check its own topology at boot.
* **git-sync Sidecar** *(optional)* — Polls a Git repository and syncs DAGs onto the shared volume.

<Note>
  **This template does not create a GVC.** Every resource lands in the GVC you install into, pinned to the single `location` you configure, so `cpln workload exec`, `cpln logs` and uninstalling all work against that GVC, and uninstalling can never delete it. Nothing Airflow-related runs in the GVC's other locations.
</Note>

## Migrating from 1.x

Template versions through 1.5.0 created their own GVC — KEDA is a GVC-level setting, and the chart wanted to own it. Version 2.0.0 does not declare a GVC at all, and Helm deletes what a chart stops declaring.

<Warning>
  **An in-place upgrade from 1.x to 2.0.0 destroys the deployment.** Measured on a sibling template with the guard removed: the upgrade **deleted the GVC and every workload, volume set and identity inside it in 6 seconds, and reported that the release had been upgraded successfully.** Reading the GVC back afterwards returned `404`. Here that would take your metadata database — every Connection, Variable, DAG run and task history — and the shared volume holding your DAGs and logs.

  The chart ships a render-time refusal so this cannot happen by accident: any leftover `gvc` key in your values aborts the upgrade before Helm computes a diff or contacts the API, leaving your deployment untouched and running.

  ```text theme={null}
  Error: execution error at (airflow/templates/workload-webserver.yaml:1:4): airflow 2.0.0: the `gvc`
  values key was REMOVED. This chart no longer creates a GVC — it deploys into the GVC you install
  into, and `gvc.locations` became the single top-level `location`. DO NOT `helm upgrade` a 1.x
  release onto 2.0.0: the upgrade drops `kind: gvc` from the manifest and Helm deletes what a chart no
  longer declares, which DESTROYS that GVC and every workload, volumeset and identity inside it —
  including your Airflow metadata database and your DAGs.
  ```

  **Two things this guard cannot do.** An upgrade run with no values at all sees only the new chart's defaults, has no `gvc` key to catch, and proceeds — so the rule matters more than the guard. And when the refusal does fire, `cpln helm upgrade` **exits 0**: nothing is destroyed, but a script chaining on success would carry on regardless. Check the output, not the exit code.
</Warning>

Migrate to a **new release** instead:

<Steps>
  <Step title="Choose the GVC for the new release">
    Create or pick a GVC that already contains the location you intend to set as `location`. See [Prerequisites](#prerequisites). If you plan to use KEDA, enable it on that GVC now.
  </Step>

  <Step title="Install 2.0.0 as a NEW release, pointing at the SAME auth secret">
    Use a **different release name** — secret names are org-wide, so a same-named release collides with the 1.x one even in another GVC. Point `airflow.auth.secretName` at the **same auth secret** the 1.x release used: the `fernetKey` must be identical, or every Connection and Variable you restore becomes unreadable.
  </Step>

  <Step title="Move the metadata database">
    Substitute the `postgres.config.username` and `postgres.config.database` values each release actually uses:

    ```bash theme={null}
    cpln workload exec OLD_RELEASE-airflow-postgres --gvc OLD_GVC --container postgresql -- \
      pg_dump -U username -d airflow -Fc > airflow.dump
    ```

    ```bash theme={null}
    cpln workload exec NEW_RELEASE-airflow-postgres --gvc NEW_GVC --container postgresql --stdin -- \
      pg_restore -U username -d airflow --clean --if-exists < airflow.dump
    ```
  </Step>

  <Step title="Move the DAGs">
    With `gitSync.enabled: true` there is nothing to do — the sidecar re-clones onto the new volume. Otherwise copy them off the old shared volume:

    ```bash theme={null}
    cpln workload exec OLD_RELEASE-airflow-webserver --gvc OLD_GVC --container airflow -- \
      tar -cf - -C /opt/airflow/dags . > dags.tar
    ```

    ```bash theme={null}
    cpln workload exec NEW_RELEASE-airflow-webserver --gvc NEW_GVC --container airflow --stdin -- \
      tar -xf - -C /opt/airflow/dags < dags.tar
    ```
  </Step>

  <Step title="Verify before you uninstall anything">
    Force a redeployment of the new webserver so the scheduler re-reads the restored database, then confirm in the UI that your DAGs, Connections and Variables are all present. Only then uninstall the old release — that deletes the GVC 1.x created, and everything still in it.

    Uninstall it **against the GVC you installed it into**, not the one it created: that is where Helm tracks the release, and uninstalling from there takes the created GVC with it.
  </Step>
</Steps>

Values that moved or were removed in 2.0.0. Each one is named at render time rather than ignored, so an old values file produces an error instead of a silent misconfiguration:

| 1.x                             | 2.0.0                                                                         |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `gvc.name`                      | Gone. The chart deploys into the GVC you install into                         |
| `gvc.locations`                 | `location`, a single top-level location **name**                              |
| `keda.enabled: true` by default | `false` by default, and it now requires KEDA enabled on your GVC              |
| —                               | `airflow.celeryWorker.replicas`, the fixed worker count used when KEDA is off |
| —                               | `internalAccess.type` / `internalAccess.workloads`                            |

### Earlier Breaking Changes

Template version 1.4 and earlier carried the JWT signing secret, the Fernet key, the admin password and the git token as plain Helm values, and shipped a working `CHANGE_ME` default for the first three. All four were removed in 1.5.0 with no compatibility fallback; a values file that still sets one fails at render with a message naming its replacement.

| 1.4.x and earlier                                           | 1.5.0                                                   |
| ----------------------------------------------------------- | ------------------------------------------------------- |
| `airflow.auth.jwtSecret`                                    | `jwtSecret` key in the prerequisite auth secret         |
| `airflow.auth.fernetKey`                                    | `fernetKey` key in the same secret                      |
| `airflow.admin.password`                                    | `adminPassword` key in the same secret                  |
| `gitSync.auth.token`                                        | `gitSync.auth.secretName` — an opaque secret you create |
| `airflow.auth.jwtExpirationDelta`                           | `airflow.auth.jwtExpirationTime` (seconds)              |
| `airflow.auth.jwtRefreshThreshold`                          | removed — Airflow 3.x has no equivalent                 |
| `firewallConfig.inboundAllowCIDR` defaulting to `0.0.0.0/0` | `firewallConfig.inboundAllowCIDR: []`, closed           |

<Warning>
  **The Fernet key cannot be rotated, so this is a migration rather than an upgrade.** That key encrypts every Airflow Connection and Variable in the metadata database — the database passwords, cloud keys and API tokens your DAGs use. Changing it does not re-encrypt anything: it makes all of them permanently unreadable.

  If your 1.4.x install ran on the shipped `CHANGE_ME` default, every one of those credentials is encrypted under a value published in a public repository. There is no in-place fix. Export what you need, install 1.5.0 with a freshly generated key, re-enter the Connections and Variables by hand, and **rotate every credential they held at its source** — treat them as compromised.
</Warning>

* **The metadata database password cannot be changed in place either.** `POSTGRES_PASSWORD` is read only when the data directory is initialized, so pointing `postgres.config.password` at a new value on an existing volume leaves the database on the old one and Airflow can no longer connect. Set it at first install.
* **The web UI now ships closed.** If you were relying on the old `0.0.0.0/0` default, put your own CIDRs in `firewallConfig.inboundAllowCIDR`.
* **Two JWT knobs were removed because they did nothing.** `jwtExpirationDelta` and `jwtRefreshThreshold` rendered `AIRFLOW__API_AUTH__JWT_EXPIRATION_DELTA` and `AIRFLOW__API_AUTH__JWT_REFRESH_THRESHOLD`, neither of which is an option in Airflow 3.x — tokens were issued with Airflow's own 86400-second lifetime regardless of what those values said. The live knob is `airflow.auth.jwtExpirationTime`; there is no refresh-threshold equivalent.

## Prerequisites

### A GVC Containing the Location You Configure

**A GVC must already exist, and `location` must be one of its locations.** Extra locations in the GVC are fine — nothing Airflow-related runs in them. Check what a GVC has before installing:

```bash theme={null}
cpln gvc get GVC_NAME -o json
```

The locations are under `spec.staticPlacement.locationLinks`. To add a missing one:

```bash theme={null}
cpln gvc add-location GVC_NAME --location aws-us-east-1
```

Every workload in a GVC runs in every location that GVC has, so add locations to a shared GVC deliberately.

<Warning>
  **A `location` the GVC does not have is not caught at install time, and it starts nothing.** The platform stores the placement without validating it, so the install reports every resource created and succeeds — and then all four workloads sit at zero replicas, in every location. `cpln logs` returns **zero lines**, because no container ever runs.

  The signal is the per-location deployment message, on any of the four workloads:

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

  ```text theme={null}
  This workload location is deactivated because maxScale is set to 0.
  ```

  That message is also what a *correctly* configured release shows for the GVC's other locations, so read it together with the location you configured: if **every** location reports it, `location` names somewhere the GVC does not have. Symptom in one line: the release deployed and nothing started.
</Warning>

<Note>
  If a location is removed from the GVC while Airflow is already running, the webserver's boot check reports it in the log on the next restart and keeps serving rather than taking a live install down.
</Note>

### One Secret Must Exist Before You Install

Its values never pass through Helm, so they never land in the release. Secrets are org-level, so no GVC flag is involved.

<Steps>
  <Step title="Create the auth secret">
    A [dictionary secret](/guides/create-secret/dictionary) holding exactly three keys — `jwtSecret`, `fernetKey` and `adminPassword`:

    ```bash theme={null}
    cpln secret create-dictionary --name my-airflow-auth \
      --entry jwtSecret="$(openssl rand -base64 48)" \
      --entry fernetKey="$(openssl rand -base64 32 | tr '+/' '-_')" \
      --entry adminPassword="$(openssl rand -base64 18)"
    ```

    Set `airflow.auth.secretName` to the name you used.

    | Key             | What it is                                                                                                                                                                                                                                |
    | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `jwtSecret`     | Signs Airflow API access tokens — anyone holding it can mint one.                                                                                                                                                                         |
    | `fernetKey`     | Encrypts the Connections and Variables your DAGs use: database passwords, cloud keys, API tokens. Must be 32 random bytes in URL-safe base64, which is what the command above produces. **It cannot be rotated** — see the warning below. |
    | `adminPassword` | The web UI and API password for the `airflow.admin.username` login. Record it; it is not displayed anywhere.                                                                                                                              |
  </Step>

  <Step title="Read the secret back later">
    The `-o yaml` is required — plain `cpln secret reveal` prints only a summary table, not the values:

    ```bash theme={null}
    cpln secret reveal my-airflow-auth -o yaml
    ```
  </Step>

  <Step title="Optional: create a git token secret for a private DAG repository">
    Only needed when `gitSync.enabled` is `true` and the repository is private. Create an [opaque secret](/guides/create-secret/opaque) with encoding `plain` whose payload is a personal access token, and set `gitSync.auth.secretName` to its name — the sidecar receives the payload as its Git password.

    ```bash theme={null}
    printf '%s' "MY_GIT_TOKEN" | cpln secret create-opaque --name my-airflow-git-token --encoding plain -f -
    ```

    Leave `gitSync.auth.secretName` empty for a public repository or when git-sync is off. Empty turns the credential off entirely, including its policy grant, and setting it while git-sync is disabled is rejected at render.
  </Step>
</Steps>

<Warning>
  **Generate the Fernet key once and keep it for the life of the install.** It encrypts every Connection and Variable Airflow stores. A different key does not fail loudly — it simply makes all of them unreadable, and there is no way back short of re-entering each one by hand.
</Warning>

<Warning>
  **Create the auth secret before installing.** A name pointing at a secret that does not exist installs "successfully" and then wedges: every resource reports created, the workload never becomes ready, and **`cpln logs` returns zero lines** because no container ever starts. The only diagnostic is `status.versions[].message`:

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

  It names the missing secret: `The secret <name> no longer exists. Workload updates are paused until the secret is added or the reference to the secret removed.` The command is `get-deployments` — plain `cpln workload get` has no `versions` key at all. Creating the secret recovers the workload on its own, but slowly: **poll for 5.5 to 10.5 minutes rather than time-boxing it** (10 minutes 17 seconds measured here). `cpln workload force-redeployment RELEASE_NAME-airflow-webserver --gvc GVC_NAME` cuts that to roughly 90 seconds.
</Warning>

### KEDA on the GVC

Only needed when you set `keda.enabled: true`. KEDA is a **GVC-level** setting, so this template cannot turn it on for you. Enable it on the GVC first, then install with `keda.enabled: true`. Edit the file the first command writes rather than composing one:

```bash theme={null}
cpln gvc get MY_GVC -o yaml-slim > gvc.yaml
```

Set `spec.keda.enabled: true` in that file, apply it, and confirm:

```bash theme={null}
cpln apply -f gvc.yaml
```

```bash theme={null}
cpln gvc get MY_GVC -o yaml | grep -A1 'keda:'
```

<Warning>
  **Never hand-write a partial GVC file for `cpln apply`.** It replaces a GVC's `spec` rather than merging into it, so a file carrying only `spec.keda` wipes `spec.staticPlacement.locationLinks` — and every workload in that GVC loses its placement. Always start from `cpln gvc get ... -o yaml-slim`.
</Warning>

## 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}
# ─── Location ─────────────────────────────────────────────────────────────────
# This chart deploys into the GVC you install into — it does NOT create one.
# Airflow runs in exactly ONE location, and this names it. It MUST already be a
# location of that GVC; the webserver reads the GVC at boot and refuses a fresh
# install if it is not (see Important Notes in the README).
# Extra locations in the GVC are fine: nothing Airflow-related runs in them.
#
# One location is not a simplification. A `shared` volumeset provisions ONE
# VOLUME PER LOCATION, so the Airflow home — DAGs and task logs — written in one
# location is invisible in another, and the metadata database and Celery broker
# are each a single volume bound to a single replica.
location: aws-us-east-1

# ─── Postgres (Airflow metadata database) ─────────────────────────────────────
# Bundled plumbing: it serves Airflow only, is unreachable from outside the GVC,
# and nobody ever types this password into a client — so it stays a value.
postgres:
  image: postgres:18
  resources:
    minCpu: 250m
    maxCpu: 500m
    minMemory: 512Mi
    maxMemory: 1024Mi
  config:
    username: username
    password: change-me-airflow-db # set at FIRST install; see Migrating in the README
    database: airflow
  volumeset:
    capacity: 10 # initial capacity in GiB (minimum is 10)

# ─── Redis (Celery broker) ────────────────────────────────────────────────────
redis:
  image: redis:7.4
  resources:
    cpu: 250m
    memory: 512Mi
  volumeset:
    capacity: 10 # initial capacity in GiB (minimum is 10)

# ─── Apache Airflow ───────────────────────────────────────────────────────────
airflow:
  webserver:
    image: apache/airflow:3.0.3
    resources:
      cpu: 2000m
      memory: 3Gi
  celeryWorker:
    image: controlplanecorporation/celery:v1
    replicas: 1 # fixed worker count; ignored when keda.enabled is true
    resources:
      cpu: 256m
      memory: 512Mi
  webPort: 8080 # port serving the Airflow UI and REST API

  auth:
    # REQUIRED PREREQUISITE SECRET — CREATE IT BEFORE YOU INSTALL. If it does not
    # exist at install time the deployment WEDGES silently and `cpln logs` returns
    # nothing at all. A `dictionary` secret holding exactly three keys:
    #   jwtSecret     — signs API access tokens
    #   fernetKey     — encrypts the Connections and Variables your DAGs use
    #                   (database passwords, cloud keys, API tokens). It CANNOT be
    #                   rotated without making every stored one unreadable.
    #   adminPassword — the Airflow UI login for `admin.username` below
    # See Prerequisites in the README for the exact command.
    secretName: my-airflow-auth
    jwtExpirationTime: 86400 # API token lifetime in seconds (Airflow's own default)

  admin:
    username: admin # its password is `adminPassword` in the secret above

  scheduler:
    dagDirListInterval: 10 # how often to rescan the DAG folder (seconds)
    minFileProcessInterval: 10 # minimum interval between processing a DAG file (seconds)

  celery:
    workerConcurrency: 1 # tasks each worker runs concurrently

volumeset:
  airflow:
    capacity: 10 # shared Airflow home (DAGs + logs), GiB (minimum is 10)

# ─── Access ───────────────────────────────────────────────────────────────────
# The Airflow UI can trigger arbitrary code and decrypt every stored Connection,
# so it ships CLOSED to the internet. Reach it with `cpln port-forward`, or add
# your own CIDRs here (e.g. - 203.0.113.0/24) to expose it. A firewall change
# takes up to a couple of minutes to propagate.
firewallConfig:
  inboundAllowCIDR: []

# Who may reach Airflow's workloads from inside the org. Every workload this
# release creates is ALWAYS allowed, whatever is set here — the webserver and the
# workers have to reach the metadata database, the broker and each other.
internalAccess:
  type: same-gvc # options: same-gvc, same-org, workload-list
  workloads: [] # only with workload-list, e.g. - //gvc/GVC_NAME/workload/WORKLOAD_NAME

# ─── DAG delivery (git-sync) ──────────────────────────────────────────────────
gitSync:
  enabled: false
  repo: "" # git repository URL (e.g. https://github.com/YOUR_ORG/dags)
  branch: main
  period: 60s # how often to sync
  subPath: "" # optional subfolder within the repo containing DAGs
  auth:
    # OPTIONAL PREREQUISITE SECRET, private repos only. An `opaque` secret
    # (encoding `plain`) whose payload is a personal access token, e.g.
    # `my-airflow-git-token`. Empty = public repo, no credentials sent.
    secretName: ""

# ─── KEDA autoscaling (Celery workers) ────────────────────────────────────────
# OFF by default, because KEDA is a GVC-LEVEL setting this chart can no longer
# turn on for you: enabling it means editing the GVC you install into (see
# Prerequisites in the README). Leave it off and workers run at a fixed
# `airflow.celeryWorker.replicas`.
#
# Turning it on WITHOUT enabling KEDA on the GVC fails SILENTLY: the workload is
# accepted, never autoscales, and simply sits at `minScale` forever — at
# minScale 0 that is zero workers and every task queues with no error anywhere
# (measured 2026-08-28). The webserver checks the GVC at boot and says so.
keda:
  enabled: false
  minScale: 1 # minimum number of Celery workers; 0 = scale to zero when idle
  maxScale: 3 # maximum number of Celery workers
  scaleToZeroDelay: 300 # idle time before scaling to zero (seconds)
  listLength: 3 # Redis queue length that triggers a scale-up
  cooldownPeriod: 1 # cooldown between scaling events (seconds)
  initialCooldownPeriod: 1 # cooldown after startup before scaling begins (seconds)
  pollingInterval: 4 # how often KEDA queries Redis (seconds)
```

### Location

* `location` — the single location name every workload in the release is pinned to. It must already be a location of the GVC you install into.

Airflow runs in exactly **one** location, and that is structural rather than a simplification. The Airflow home is a `shared` volume set, and a shared volume set provisions **one volume per location** — so two locations would mean two Airflow homes that cannot see each other's DAGs or task logs. The metadata database and the Celery broker are each a single volume bound to a single replica on top of that. There is no multi-location shape to configure.

<Note>
  GVC locations you did not name run nothing. Their deployment reads `This workload location is deactivated because maxScale is set to 0.` That is the mechanism that keeps a shared GVC safe — it is what a healthy install looks like, not a fault. It is also why a `location` the GVC lacks starts nothing anywhere; see [Prerequisites](#prerequisites).
</Note>

<Note>
  **Version 2.0.0 fixes a placement bug.** In 1.5.0 the webserver and the Redis broker carried no per-location options at all, so on a multi-location GVC the platform's defaults applied and the release would have run **one scheduler and one broker per location** — split queues, and more than one scheduler against the same metadata database. Every workload in 2.0.0 declares `0` scale by default with a single complete entry for `location`.
</Note>

### PostgreSQL Metadata Database

* `postgres.image` — The PostgreSQL image.
* `postgres.resources` — Reservation and limit for the database container (`minCpu` / `maxCpu` / `minMemory` / `maxMemory`).
* `postgres.config.username` / `postgres.config.password` / `postgres.config.database` — The metadata database credentials. This is bundled plumbing: it serves Airflow only, is unreachable from outside the GVC, and no human ever types it, so it stays a value. **Set the password at first install** — the shipped `change-me-airflow-db` is a published placeholder, and PostgreSQL only reads it when the data directory is initialized.
* `postgres.volumeset.capacity` — Database volume size in GiB (minimum 10).

### Redis Broker

* `redis.image` — The Redis image.
* `redis.resources` — CPU and memory limit for the broker (this block exposes only a limit).
* `redis.volumeset.capacity` — Broker volume size in GiB (minimum 10).

### Airflow

* `airflow.webserver.image` / `airflow.celeryWorker.image` — Images for the webserver and the Celery workers.
* `airflow.webserver.resources` / `airflow.celeryWorker.resources` — CPU and memory limits for each tier.
* `airflow.celeryWorker.replicas` — The fixed number of Celery workers. Ignored when `keda.enabled` is `true`, which hands the replica count to KEDA instead.
* `airflow.webPort` — The port serving the UI and the REST API.
* `airflow.auth.secretName` — Name of the prerequisite dictionary secret holding `jwtSecret`, `fernetKey` and `adminPassword`. It must exist before you install.
* `airflow.auth.jwtExpirationTime` — API access-token lifetime in seconds, applied as `AIRFLOW__API_AUTH__JWT_EXPIRATION_TIME`. The shipped `86400` is Airflow's own default.
* `airflow.admin.username` — The admin login paired with the `adminPassword` key of your auth secret.
* `airflow.scheduler.dagDirListInterval` — How often the DAG folder is rescanned, in seconds.
* `airflow.scheduler.minFileProcessInterval` — Minimum interval between processing the same DAG file, in seconds.
* `airflow.celery.workerConcurrency` — Tasks each Celery worker runs concurrently.

<Note>
  The webserver runs the API server, scheduler, dag-processor and triggerer in one container and is pinned to a single replica, so `airflow.celeryWorker.replicas` (or KEDA) is the only horizontal scaling knob here — you scale task execution, not scheduling.
</Note>

The admin account is provisioned by Airflow's `SimpleAuthManager`: the container writes a password file from the secret on every start, so the login always matches the current secret contents.

<Note>
  `SimpleAuthManager` is Airflow 3.x's default auth manager and has no SSO or LDAP support. Front the UI with an OAuth/OIDC auth manager if you need one.
</Note>

### Storage

* `volumeset.airflow.capacity` — Size in GiB of the shared Airflow home holding DAGs and logs (minimum 10).

The database and broker volumes are configured separately under `postgres.volumeset.capacity` and `redis.volumeset.capacity`.

<Note>
  The Airflow home uses a shared (NFS-style) filesystem so the webserver and every Celery worker read the same DAGs and write to the same log directory.
</Note>

### Access

* `firewallConfig.inboundAllowCIDR` — CIDR ranges allowed to reach the webserver from the internet. The shipped `[]` means closed: the canonical endpoint returns `403` and the UI is reachable only over a port-forward. Add your own ranges (for example `203.0.113.0/24`) to expose it.

<Warning>
  **The Airflow UI is effectively remote code execution.** It triggers DAGs and can decrypt every stored Connection, and its only authentication is a single `SimpleAuthManager` password. That is why the closed default exists — prefer `cpln port-forward` over `0.0.0.0/0`.
</Warning>

<Note>
  A firewall change takes roughly **30 seconds to 5 minutes** to propagate (measured 20–85 seconds here). During the transition the endpoint can return `503` before it settles on `200` or `403`, so re-poll rather than trusting the first response.
</Note>

`internalAccess` controls which workloads inside the org may reach Airflow's own workloads:

| Type            | Description                                                                                                                                           |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `same-gvc`      | Allow access from all workloads in the same GVC                                                                                                       |
| `same-org`      | Allow access from all workloads in the org                                                                                                            |
| `workload-list` | Allow access only from the workloads listed in `internalAccess.workloads`, **plus this release's own four workloads**, which the template always adds |

<Note>
  This list governs traffic between the release's own tiers — the webserver and the workers reaching the metadata database, the broker and each other — not just traffic from your applications. The template merges its own four workloads in for you, and when `keda.enabled` is `true` it also allows the KEDA operator onto the Redis tier, whose queue length KEDA polls. So you only need to list your own clients.
</Note>

### DAG Delivery With git-sync

git-sync runs as a sidecar on the webserver and worker workloads, pulling a Git repository onto the shared Airflow volume and pointing `AIRFLOW__CORE__DAGS_FOLDER` at the synced checkout on both tiers.

| Property                  | Description                                                                                                                                                         |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gitSync.enabled`         | Enable the git-sync sidecar. Required together with `gitSync.repo` and `gitSync.branch`.                                                                            |
| `gitSync.repo`            | Git repository URL. Required when git-sync is enabled.                                                                                                              |
| `gitSync.branch`          | Branch to sync. Required when git-sync is enabled.                                                                                                                  |
| `gitSync.period`          | How often to sync.                                                                                                                                                  |
| `gitSync.subPath`         | Subfolder within the repository containing the DAGs. It is appended to the DAGs folder on both tiers.                                                               |
| `gitSync.auth.secretName` | Opaque secret holding a personal access token, for a private repository. Empty means no credential is sent; setting it while git-sync is off is rejected at render. |

With git-sync off, put DAG files directly in `/opt/airflow/dags` on the shared volume — the dag-processor picks up a new file within seconds at the shipped `dagDirListInterval: 10`.

### KEDA Autoscaling

KEDA scales the Celery workers on the Redis queue length, including down to zero between runs. It is **off by default** as of 2.0.0. With it off, workers run at the fixed `airflow.celeryWorker.replicas`.

| Property                     | Default | Description                                                                      |
| ---------------------------- | ------- | -------------------------------------------------------------------------------- |
| `keda.enabled`               | `false` | Enable KEDA autoscaling of the Celery workers. Requires KEDA enabled on the GVC. |
| `keda.minScale`              | `1`     | Minimum number of workers. `0` scales to zero when idle.                         |
| `keda.maxScale`              | `3`     | Maximum number of workers.                                                       |
| `keda.scaleToZeroDelay`      | `300`   | Idle time in seconds before scaling to zero.                                     |
| `keda.listLength`            | `3`     | Redis queue length that triggers a scale-up.                                     |
| `keda.cooldownPeriod`        | `1`     | Seconds between scaling events.                                                  |
| `keda.initialCooldownPeriod` | `1`     | Seconds after startup before scaling begins.                                     |
| `keda.pollingInterval`       | `4`     | How often KEDA queries Redis, in seconds.                                        |

<Warning>
  **Enable KEDA on the GVC before you set `keda.enabled: true`, in that order.** KEDA is a GVC-level setting this template cannot turn on for you, and getting it wrong is accepted in silence: the workload is created without any error, reports `ready: true`, and simply **never autoscales** — it sits at `keda.minScale` forever. At `minScale: 0` that is zero workers, and every task queues with nothing reporting a problem anywhere.

  The webserver checks the GVC at boot and is the only thing that surfaces it. On a **fresh** install it refuses to start and says so:

  ```text theme={null}
  [airflow] FATAL: keda.enabled is true in your values, but GVC 'my-gvc' has spec.keda.enabled = false.
  KEDA is a GVC-LEVEL setting this chart cannot turn on for you.
  ```

  On an **established** install the same condition is a warning and Airflow keeps scheduling, so a GVC change can never take a running deployment down. The refusal also clears itself: enable KEDA on the GVC and the crash-looping webserver passes the check on its next restart, with no redeploy or reinstall needed.

  See [KEDA on the GVC](#keda-on-the-gvc) for the commands.
</Warning>

## Wide Fan Outs Can Silently Lose Tasks

A DAG that dispatches many tasks at the same instant loses roughly a third of them, and **nothing surfaces in the UI as an error** — the tasks are simply marked failed, with no hostname, no start time and an empty log. A measured 24-task burst finished 7 failed and 9 succeeded, with the failures never starting at all.

The cause is upstream, not anything this template configures: the Celery executor's Redis transport is imported lazily and concurrently by several task-sending threads, and one of them observes a half-initialized module — `module 'redis' has no attribute 'client'` from `kombu/transport/redis.py`. The scheduler then marks those queued tasks failed.

Until it is fixed upstream, **stagger task submission** rather than fanning out wide simultaneously, and count completed task instances rather than trusting the UI to flag a problem. A single task, and tasks arriving at a normal rate, are unaffected.

## Connecting to Airflow

| What                              | Where                                                                                                                                                                           | Credentials         |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| UI and REST API, no public access | `cpln port-forward RELEASE_NAME-airflow-webserver 8080:8080 --gvc GVC_NAME`, then `http://localhost:8080`                                                                       | admin login         |
| UI and REST API, public           | `status.canonicalEndpoint` of `RELEASE_NAME-airflow-webserver` — `cpln workload get RELEASE_NAME-airflow-webserver --gvc GVC_NAME -o yaml`. Returns `403` until you add a CIDR. | admin login         |
| Metadata database (in-GVC)        | `RELEASE_NAME-airflow-postgres.GVC_NAME.cpln.local:5432`                                                                                                                        | `postgres.config.*` |
| Redis broker (in-GVC)             | `RELEASE_NAME-airflow-redis.GVC_NAME.cpln.local:6379`                                                                                                                           | none                |
| Admin login                       | `airflow.admin.username` plus the `adminPassword` key of your auth secret                                                                                                       | —                   |

The port-forward tunnel goes through Control Plane infrastructure and is independent of the firewall, so it works while the canonical endpoint is returning `403`.

### First Run Sequence

<Steps>
  <Step title="Install with the UI closed">
    Keep the shipped `firewallConfig.inboundAllowCIDR: []`.
  </Step>

  <Step title="Tunnel to the UI and sign in">
    ```bash theme={null}
    cpln port-forward RELEASE_NAME-airflow-webserver 8080:8080 --gvc GVC_NAME
    ```

    Open `http://localhost:8080` and log in with `airflow.admin.username` and the `adminPassword` from your auth secret.
  </Step>

  <Step title="Expose it only if you need to">
    Run a Helm upgrade with your own CIDR ranges in `firewallConfig.inboundAllowCIDR`, then allow up to a few minutes for the firewall to propagate before testing.
  </Step>
</Steps>

### API Access

Airflow 3.x issues JWT access tokens signed with your `jwtSecret`. Request one with the same admin credentials:

```bash theme={null}
curl -X POST http://localhost:8080/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "ADMIN_PASSWORD"}'
```

Then pass it on the v2 API:

```bash theme={null}
curl http://localhost:8080/api/v2/dags \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

## Important Notes

* The auth secret must exist **before** you install. Without it the deployment wedges with no log output at all; see [Prerequisites](#prerequisites) for the one diagnostic that names it, and expect recovery to take up to about ten and a half minutes after you create it.
* **The Fernet key can never be rotated** — every stored Connection and Variable is encrypted under it. Generate it once, keep it, and back it up somewhere you will still have it later.
* **A wide simultaneous fan-out silently loses roughly a third of its tasks** — see [Wide Fan Outs Can Silently Lose Tasks](#wide-fan-outs-can-silently-lose-tasks).
* **Never upgrade a 1.x release onto 2.0.0 in place** — it deletes the GVC the 1.x chart created and everything in it, including your metadata database and DAGs. Migrate to a new release: [Migrating from 1.x](#migrating-from-1-x).
* **`location` must be a location of your GVC.** If it is not, the install still succeeds and then nothing starts anywhere, with no container to produce a log line. Check `cpln workload get-deployments` for `This workload location is deactivated because maxScale is set to 0.` on every location.
* **Airflow runs in a single location by construction** — the shared Airflow home provisions one volume per location, so a second location would be a second, disconnected set of DAGs and logs.
* **Enable KEDA on the GVC before setting `keda.enabled: true`.** Getting the order wrong is accepted with no error and leaves the workers stuck at `keda.minScale` forever — zero workers, and silently queuing tasks, at `minScale: 0`.
* Set `postgres.config.password` at first install. It is bundled plumbing, but the shipped placeholder is published and the value cannot be changed once the data directory exists.
* The UI is closed by default and a firewall change takes up to a few minutes to apply, so an endpoint still returning `403` right after an upgrade is not necessarily broken.
* The first Helm upgrade after an install re-applies resources even with identical values, restarting the webserver and the database — the UI was unavailable for about three minutes in testing. Later upgrades are clean.
* Changing the admin password means editing the auth secret and restarting the webserver; the password file is rewritten from the secret on every container start.
* **Uninstalling deletes all three volume sets**, including the DAGs and logs on the shared Airflow home. Export anything you need first.

## External References

<CardGroup cols={2}>
  <Card title="Apache Airflow Documentation" icon="book" href="https://airflow.apache.org/docs/">
    Official Apache Airflow documentation
  </Card>

  <Card title="Fernet Key and Encryption at Rest" icon="lock" href="https://airflow.apache.org/docs/apache-airflow/stable/security/secrets/fernet.html">
    How Airflow encrypts Connections and Variables
  </Card>

  <Card title="Airflow Security Model" icon="shield" href="https://airflow.apache.org/docs/apache-airflow/stable/security/index.html">
    What the Airflow UI and API can do, and who should reach them
  </Card>

  <Card title="CeleryExecutor" icon="layer-group" href="https://airflow.apache.org/docs/apache-airflow-providers-celery/stable/celery_executor.html">
    The CeleryExecutor and distributed task execution
  </Card>

  <Card title="KEDA Documentation" icon="chart-line" href="https://keda.sh/docs/">
    Event-driven autoscaling documentation
  </Card>

  <Card title="git-sync" icon="github" href="https://github.com/kubernetes/git-sync">
    The git-sync sidecar
  </Card>

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