How to Deploy Airflow 3 on Kubernetes with the Helm Chart (2026)

Nov 10, 2026 · 14 min · Ajay Kumar

This guide is for platform and data engineers who want a production-shaped Apache Airflow 3 deployment on Kubernetes rather than the demo you get from a bare helm install. By the end you will have Airflow 3.4.0 running from the official chart 1.22.0 with the KubernetesExecutor, Dags pulled from Git over SSH, an external Postgres behind PgBouncer, secrets coming from Vault, task logs in S3, a working KubernetesPodOperator task, and a checklist for upgrading an existing Airflow 2 install. Every values key below was rendered with helm template against chart 1.22.0 before I wrote it down. You need a Kubernetes cluster (the chart docs ask for v1.30.13 or newer), Helm 3.19.0 or newer, a Postgres 14 to 18 database, and a container registry you can push to.

If you are still deciding whether Airflow is the right tool at all, read my comparison of Airflow, Kubeflow, Prefect, Dagster, Flyte and Metaflow first.

Step 1: Check versions before you start

Three versions have to line up: the chart, the Airflow image, and your tools. Chart 1.22.0 was released in June 2026 with a default image of Airflow 3.2.2, while Airflow 3.4.0 shipped on 6 November 2026. The chart supports Airflow 2.11 and later (its check-values.yaml fails the render below 2.11.0), so pointing it at 3.4.0 is fine, but you must tell it the version with airflowVersion because templates branch on it.

# Confirm the chart version and its default Airflow tag
helm repo add apache-airflow https://airflow.apache.org
helm repo update apache-airflow
helm search repo apache-airflow/airflow --versions | head -3
# NAME                  	CHART VERSION	APP VERSION	DESCRIPTION
# apache-airflow/airflow	1.22.0       	3.2.2      	The official Helm chart to deploy Apache Airflo...
# apache-airflow/airflow	1.21.0       	3.2.0      	The official Helm chart to deploy Apache Airflo...

helm version --short        # chart 1.22.0 documents Helm v3.19.0+
kubectl version             # server should be 1.30 to 1.35, the range Airflow 3.4.0 is tested on

The Airflow 3.4.0 README lists the tested matrix: Python 3.11 to 3.14, Kubernetes 1.30 to 1.35, Postgres 14 to 18. If you want a release that already has a patch on top of it, 3.3.2 (September 2026) is the conservative choice; the values file below is the same apart from the two version fields.

Step 2: Build a pinned Airflow image

Do not run apache/airflow:latest in production, and do not install packages at container start with _PIP_ADDITIONAL_REQUIREMENTS. Build an image, tag it immutably, and install extra packages against the official constraints file so pip cannot drag a provider to an incompatible version.

The 3.4.0 image already includes the cncf-kubernetes, hashicorp, amazon, postgres and fab providers (see AIRFLOW_EXTRAS in the 3.4.0 Dockerfile), so you only add what your Dags need.

# Dockerfile
FROM apache/airflow:3.4.0-python3.12

# Your own Dag dependencies, pinned (example)
COPY requirements.txt /requirements.txt

# Install against the 3.4.0 constraints so providers stay on tested versions
RUN pip install --no-cache-dir \
      "apache-airflow==3.4.0" \
      -r /requirements.txt \
      --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-3.4.0/constraints-3.12.txt"
TAG="3.4.0-$(git rev-parse --short HEAD)"
docker build -t registry.example.com/data/airflow:"$TAG" .
docker push registry.example.com/data/airflow:"$TAG"

For multi-arch clusters, build amd64 and arm64 with Buildx as in my multi-arch image guide; the official image publishes both.

Step 3: Pick an executor

Airflow 3 dropped the hybrid CeleryKubernetesExecutor and LocalKubernetesExecutor. Instead you list one or more executors in priority order, and tasks can choose one with the executor argument. The chart value is a plain string:

Executor How tasks run Good for Cost of choosing it
KubernetesExecutor One pod per task instance Spiky or heavy workloads, per-task resources, isolation Pod start latency on every task; needs remote logging
CeleryExecutor Long-lived worker pods pull from Redis Many short tasks, low latency You run Redis and size a worker fleet
CeleryExecutor,KubernetesExecutor Celery by default, Kubernetes per task Mixed fleets Two systems to operate
LocalExecutor Inside the scheduler pod Small installs and dev Scheduler and tasks share resources

This guide uses KubernetesExecutor. It removes the Redis and worker StatefulSet entirely, which is less to run and less to patch. If most of your tasks take a few seconds, measure pod start time on your cluster before committing; Celery may be the better trade.

Step 4: Create the namespace and secrets

The chart can generate a Fernet key, API secret key and JWT secret for you, but generated values change on upgrade, which logs users out and can fail running tasks. The production guide says to set static values. Create them once as Kubernetes Secrets with the exact key names the chart reads.

kubectl create namespace airflow

# Fernet key encrypts connection passwords in the metadata DB. Key name: fernet-key
kubectl -n airflow create secret generic airflow-fernet-key \
  --from-literal=fernet-key="$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"

# [api] secret_key for the Airflow 3 API server. Key name: api-secret-key
kubectl -n airflow create secret generic airflow-api-secret-key \
  --from-literal=api-secret-key="$(openssl rand -hex 32)"

# [api_auth] jwt_secret used for task and UI tokens. Key name: jwt-secret
kubectl -n airflow create secret generic airflow-jwt-secret \
  --from-literal=jwt-secret="$(openssl rand -hex 32)"

# Read-only deploy key for the Dags repo. Key name: gitSshKey
kubectl -n airflow create secret generic airflow-git-ssh \
  --from-file=gitSshKey="$HOME/.ssh/airflow_dags_deploy_key"

If you manage secrets with External Secrets Operator, create the same four Secrets from Vault instead; my ESO with Vault guide shows the ExternalSecret shape. Keep the key names identical.

Step 5: Write the production values file

Here is the whole file. The sections after this explain the parts that bite.

# values-prod.yaml for apache-airflow/airflow chart 1.22.0
defaultAirflowRepository: registry.example.com/data/airflow
defaultAirflowTag: "3.4.0-2026-11-10"     # your immutable tag from Step 2
airflowVersion: "3.4.0"                   # templates branch on this

executor: "KubernetesExecutor"
useStandardNaming: true                   # resources named airflow-<component>

# External Postgres instead of the bundled bitnamilegacy image
postgresql:
  enabled: false

data:
  metadataConnection:
    user: airflow
    pass: ""                              # set with --set at install time, never commit it
    protocol: postgresql
    host: airflow-db.example.internal
    port: 5432
    db: airflow
    sslmode: require

pgbouncer:
  enabled: true
  maxClientConn: 200
  metadataPoolSize: 20

fernetKeySecretName: airflow-fernet-key
apiSecretKeySecretName: airflow-api-secret-key
jwtSecretName: airflow-jwt-secret

# Plain Jobs instead of Helm hooks (needed for Argo CD; harmless with plain Helm)
createUserJob:
  useHelmHooks: false
  applyCustomEnv: false
migrateDatabaseJob:
  useHelmHooks: false
  applyCustomEnv: false

dags:
  gitSync:
    enabled: true
    repo: git@github.com:example-org/airflow-dags.git
    branch: main
    rev: HEAD
    depth: 1
    subPath: "dags"                       # folder inside the repo that holds Dag files
    period: 60s
    sshKeySecret: airflow-git-ssh
    knownHosts: |
      github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

apiServer:
  replicas: 2
  resources:
    requests: {cpu: 500m, memory: 1Gi}
    limits: {memory: 2Gi}

scheduler:
  replicas: 2
  resources:
    requests: {cpu: "1", memory: 2Gi}
    limits: {memory: 4Gi}

triggerer:
  replicas: 1

dagProcessor:
  replicas: 1

config:
  logging:
    remote_logging: "True"
    remote_base_log_folder: "s3://example-airflow-logs/prod"
    remote_log_conn_id: "aws_logs"
  secrets:
    backend: "airflow.providers.hashicorp.secrets.vault.VaultBackend"
    backend_kwargs: '{"connections_path": "connections", "variables_path": "variables", "mount_point": "airflow", "url": "https://vault.example.com:8200", "auth_type": "kubernetes", "kubernetes_role": "airflow"}'

Database and PgBouncer. I use data.metadataConnection rather than data.metadataSecretName on purpose. With PgBouncer enabled, the chart builds pgbouncer.ini from metadataConnection; if you supply only a secret, the production guide notes that the default connection string will not work and you must also provide pgbouncer.configSecretName and a stats secret. Rendering this file shows the chart rewrites the Airflow connection to point at PgBouncer:

KUBECONFIG=/dev/null helm template airflow apache-airflow/airflow --version 1.22.0 \
  -n airflow -f values-prod.yaml --set data.metadataConnection.pass=dummy \
  | grep -A3 'name: airflow-metadata$' >/dev/null && echo rendered
# The decoded 'connection' key in the airflow-metadata Secret is:
# postgresql://airflow:dummy@airflow-pgbouncer.airflow:6543/airflow-metadata?sslmode=require
# and pgbouncer.ini contains: pool_mode = transaction

Transaction pooling is exactly what Airflow 3.4 expects: its release notes say psycopg3 is now the default driver and statement preparation is disabled so it works behind transaction-mode PgBouncer. If you hand-wrote a postgres+psycopg2:// URL elsewhere, 3.4 rewrites it to postgresql+psycopg://.

Helm hooks. By default the migration and create-user Jobs are Helm hooks (pre-install and similar). Argo CD handles hooks differently from Helm, and the comment in the chart's own values.yaml says to disable them if you are using Argo CD. I set it everywhere so behaviour does not depend on the tool doing the deploy.

Remote logs. With KubernetesExecutor the task pod is deleted when the task finishes, and its logs go with it unless remote logging is on. Create an aws_logs connection (in Vault, below) and use IRSA or EKS Pod Identity for the service account rather than static keys.

Step 6: Store Dags in Git with git-sync

Git-sync runs as a sidecar in the dag-processor and triggerer and (with KubernetesExecutor) as an init container in each task pod, so every component that reads Dag files sees the same commit. The Airflow 3 scheduler does not get one: it works from the Dags the dag-processor serialises. Three settings matter:

  • knownHosts is not optional for SSH. Without it you are trusting whatever answers on port 22. The line above is GitHub's ed25519 key, which you can confirm from GitHub's meta API (ssh_keys field).
  • subPath is the folder inside the repo. If Dags do not appear, this is wrong more often than anything else.
  • period: 60s is plenty. The chart default of 5s means every git-sync container polls GitHub every five seconds.

The alternative is to bake Dags into the image, which the manage Dag files page also documents. That gives you a single immutable artifact per release and is my preference for regulated environments; git-sync is faster for teams shipping Dags many times a day.

Step 7: Point Airflow at Vault for connections and variables

The config.secrets block above renders into airflow.cfg as a [secrets] section. The Vault backend looks up connections at <mount_point>/<connections_path>/<conn_id>, so a connection called aws_logs lives at airflow/connections/aws_logs. The kubernetes auth type and kubernetes_role argument come from the hashicorp provider source; it reads the service account token from /var/run/secrets/kubernetes.io/serviceaccount/token by default.

# On the Vault side (run against Vault, not the cluster)
vault secrets enable -path=airflow kv-v2
vault kv put airflow/connections/aws_logs conn_uri='aws://?region_name=eu-west-1'
vault kv put airflow/variables/feature_store_bucket value='s3://ml-features-prod'

# Kubernetes auth role bound to the Airflow service accounts in the airflow namespace
vault write auth/kubernetes/role/airflow \
  bound_service_account_names=airflow-scheduler,airflow-worker,airflow-triggerer,airflow-dag-processor,airflow-api-server \
  bound_service_account_namespaces=airflow \
  policies=airflow-read ttl=1h      # airflow-read: a policy granting read on airflow/data/*

Store conn_uri or the individual fields (conn_type, login, password, host, port) per the Vault backend docs. Airflow checks the secrets backend first, then environment variables, then the metadata database.

Step 8: Install and verify

Render first, then install with the chart version pinned. Pass the database password from your shell or CI secret store, not from the values file.

# Render locally with no cluster access to catch values errors
KUBECONFIG=/dev/null helm template airflow apache-airflow/airflow --version 1.22.0 \
  -n airflow -f values-prod.yaml --set data.metadataConnection.pass=dummy > /tmp/airflow.yaml
grep -E '^kind:' /tmp/airflow.yaml | sort | uniq -c
#   2 kind: ConfigMap
#   5 kind: Deployment
#   2 kind: Job
#   2 kind: Role
#   2 kind: RoleBinding
#   5 kind: Secret
#   4 kind: Service
#   9 kind: ServiceAccount
#   1 kind: StatefulSet

# Install for real
helm upgrade --install airflow apache-airflow/airflow \
  --version 1.22.0 --namespace airflow \
  -f values-prod.yaml \
  --set data.metadataConnection.pass="$AIRFLOW_DB_PASSWORD" \
  --set createUserJob.defaultUser.password="$AIRFLOW_ADMIN_PASSWORD"

kubectl -n airflow get pods
kubectl -n airflow logs job/airflow-run-airflow-migrations
kubectl -n airflow port-forward svc/airflow-api-server 8080:8080

The Deployments are airflow-api-server, airflow-scheduler, airflow-dag-processor, airflow-pgbouncer and airflow-statsd, with the triggerer as a StatefulSet. Log in at http://localhost:8080 with the admin user, then change that password or wire SSO; the chart's default auth manager is the FAB auth manager.

Git repo Dag files, deploy key git-sync sidecar, period 60s dag-processor parses Dags api-server UI and REST, port 8080 scheduler x2 KubernetesExecutor Kubernetes API creates task pods task pod one per task instance Vault connections, variables PgBouncer port 6543, transaction Postgres metadata DB, 14 to 18 S3 remote task logs Arrows show the main request paths. The api-server and dag-processor also use PgBouncer.
The deployment built in this guide, as rendered from chart 1.22.0 with values-prod.yaml. Component names match the rendered Deployments and StatefulSet.

Step 9: Run a KubernetesPodOperator task

KubernetesExecutor already gives each task its own pod, running your Airflow image. KubernetesPodOperator goes further and runs an arbitrary image, which is how you run a training job in a CUDA image without installing PyTorch into Airflow. The chart's default allowPodLaunching: true gives the Airflow service accounts the RBAC to create pods in the release namespace.

# dags/train_model.py  (apache-airflow 3.4.0, providers-cncf-kubernetes 10.25.0 from the 3.4.0 constraints)
from datetime import datetime

from airflow.sdk import dag
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
from kubernetes.client import models as k8s


@dag(schedule="@daily", start_date=datetime(2026, 11, 1), catchup=False, tags=["ml"])
def train_model():
    KubernetesPodOperator(
        task_id="train",
        name="train-model",
        namespace="airflow",
        image="registry.example.com/ml/trainer:2026-11-10",
        cmds=["python", "train.py"],
        arguments=["--date", "{{ ds }}"],
        container_resources=k8s.V1ResourceRequirements(
            requests={"cpu": "2", "memory": "8Gi"},
            limits={"memory": "8Gi", "nvidia.com/gpu": "1"},
        ),
        get_logs=True,
        on_finish_action="delete_pod",   # "delete_succeeded_pod" keeps failed pods for debugging
        deferrable=True,                 # frees the worker slot; the triggerer watches the pod
    )


train_model()

Note {{ ds }} rather than {{ execution_date }}: Airflow 3 removed execution_date and its siblings from the template context. For GPU scheduling details, see my NVIDIA GPU Operator guide.

Step 10: Upgrade an existing Airflow 2 install to Airflow 3

Airflow 2 reached end of life on 22 April 2026, so this is no longer optional. The order matters, per the upgrade guide:

# 1. Be on Airflow 2.7 or later first (2.11.2 is the last 2.x release), on chart 1.22.0
# 2. Lint Dags for removed APIs (ruff 0.13.1 or newer)
pip install "ruff==0.17.4"
ruff check dags/ --select AIR301,AIR302
ruff check dags/ --select AIR301 --fix

# 3. Check airflow.cfg for removed and renamed options (run inside a 2.11 scheduler pod)
airflow config lint
airflow config update            # shows changes; add --fix to apply them

# 4. Trim old rows so the migration is fast, after a database backup
airflow db clean --clean-before-timestamp '2026-08-01' --skip-archive --yes

# 5. Move values: webserver.* keys become apiServer.*, set airflowVersion and the new tag
# 6. Upgrade; the migration Job runs 'airflow db migrate'
helm upgrade airflow apache-airflow/airflow --version 1.22.0 -n airflow -f values-prod.yaml \
  --set data.metadataConnection.pass="$AIRFLOW_DB_PASSWORD"

The usual breakages: tasks that opened a SQLAlchemy session on the metadata DB (use the Airflow Python client or REST API), SubDAGs, SLA callbacks, and Dags that silently depended on catchup=True being the default. Going from 3.3 to 3.4, also check asset-triggered Dags: they now get one run per event unless you set batch_asset_events=True. Downgrading from 3.4 is slow because new XComs live in an xcom_v2 table, so take a snapshot before you start.

Troubleshooting

Symptom Likely cause Fix
helm template fails with only supports Apache Airflow version 2.11.0 and above airflowVersion below 2.11.0 Set airflowVersion to the image's real version
Migration Job never runs under Argo CD Helm hooks not executed as expected Set migrateDatabaseJob.useHelmHooks: false and createUserJob.useHelmHooks: false
Pods in Init with git-sync Host key verification failed knownHosts missing or wrong Add the host key from the provider's published list
No Dags in the UI, git-sync logs are clean Wrong dags.gitSync.subPath Point it at the folder inside the repo that holds the .py files
password authentication failed from PgBouncer metadataSecretName used without pgbouncer.configSecretName Use data.metadataConnection or supply both PgBouncer secrets
Users logged out and tasks fail after every upgrade Generated API secret key or JWT secret rotated Set apiSecretKeySecretName and jwtSecretName to static Secrets
Task logs empty or unavailable once the task pod exits KubernetesExecutor pods deleted with local logs Enable remote_logging to S3, GCS or Azure Blob
KubernetesPodOperator returns 403 creating pods RBAC disabled or wrong namespace Keep allowPodLaunching: true, or add a Role for the target namespace
Dags fail with execution_date undefined after upgrade Removed template variables in Airflow 3 Replace with logical_date, ds or data_interval_start
Too many Postgres connections PgBouncer disabled or pool too large Enable PgBouncer and size metadataPoolSize below the DB limit

For broader Helm failure modes (another operation is in progress, stuck releases), see how to fix Helm UPGRADE FAILED errors.

FAQ

Which executor should I use for Airflow on Kubernetes?

Start with KubernetesExecutor if your tasks run for minutes rather than seconds, because it removes Redis and the worker fleet and gives each task its own resources. Choose CeleryExecutor if you run thousands of short tasks and pod start time dominates. Airflow 3 lets you list both and pick per task.

Does the Airflow Helm chart 1.22.0 support Airflow 3.4?

The chart supports Airflow 2.11 and later and defaults to 3.2.2. Setting defaultAirflowTag to your 3.4.0 image and airflowVersion: "3.4.0" renders cleanly; I verified the render with helm template, not a long-running production cluster, so test upgrades in staging first.

Should I use git-sync or bake Dags into the image?

Git-sync is better when Dags change many times a day and you want deploys without image builds. Baking Dags into the image gives one immutable, scannable artifact per release, which is easier to audit and roll back. Both are documented by the chart.

Do I still need the bundled Postgres?

No, and you should not use it in production. The bundled chart uses a bitnamilegacy/postgresql image that no longer receives updates. Use a managed Postgres or an operator such as CloudNativePG, with PgBouncer in front.

When I take over an Airflow cluster, I check four things before anything else: that the chart and image versions are pinned, that the Fernet, API and JWT secrets are static, that PgBouncer is in front of the database, and that task logs survive pod deletion. If any of those is missing, I fix it before touching Dags. The official references are the Helm chart docs, the chart production guide, the Airflow 3 upgrade guide and the 3.4.0 release notes.

Related: Airflow vs Kubeflow vs Prefect vs Dagster (2026), How to Use External Secrets Operator with Vault on Kubernetes and How to Run Postgres on Kubernetes with CloudNativePG.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related