How to Deploy Airflow 3 on Kubernetes with the Helm Chart (2026)
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:
knownHostsis 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_keysfield).subPathis the folder inside the repo. If Dags do not appear, this is wrong more often than anything else.period: 60sis 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.
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
How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
Step-by-step ARC 0.15.1 setup for GitHub Actions self-hosted runners on Kubernetes: GitHub App auth, pinned Helm charts, runner scale sets, runner groups, Docker-in-Docker vs kubernetes container mode, scale to zero, metrics, and fixes for jobs stuck in queued.
13 minNov 10, 2026How to Build an Azure Pipelines YAML Pipeline: Step-by-Step (2026)
Build an azure-pipelines.yml from scratch in 2026: stages, jobs and steps, reusable templates, variable groups linked to Key Vault, a workload identity federation service connection, environments with approvals, and deployment to AKS or App Service, plus fixes for the common errors.
13 minNov 10, 2026How to Build a Kubernetes Operator With Kubebuilder 4.16 (2026)
Step-by-step guide to building a Kubernetes operator with Kubebuilder v4.16.0 and controller-runtime v0.25: init, create an API, CRD markers and CEL rules, a Reconcile loop with status conditions and finalizers, envtest, kind deploy and fixes for common errors.
12 min