ADR 023 — Config Storage: Env Vars, Airflow Variables, Airflow Connections¶
Date: 2026-05-07 Status: Accepted Deciders: Data Engineering
Context¶
Today, every piece of configuration in our Airflow deployment — credentials, API base URLs, S3 bucket names, environment flags, debug toggles — is stored as an Airflow Variable. This conflates four distinct kinds of config into a single bucket and creates concrete problems:
- Security: Airflow Variables are stored as plain text in the metadata Postgres database.
The UI masks values whose names match certain patterns (
*_password,*_secret,*_api_key), but the underlying storage is unencrypted. Astronomer environment Secrets are encrypted at rest with a separate key. - Performance:
Variable.get()issues aSELECTquery against the metadata DB on every call. For values that never change at runtime (S3 bucket, environment name, base URLs), this is unnecessary overhead. - Signal-to-noise: When everything lives in Airflow Variables, the Variables UI becomes a junk drawer. After cleanup, anything in Variables is — by definition — meant to be flipped at runtime, which is operationally useful.
- Auditability: Variable changes via UI leave a thin audit trail. Astronomer environment variables and Connections defined via deploy config go through the same review/deploy gate as code.
We need a clear rule for where each kind of config belongs.
Decision¶
Use the following rule to choose where a config value lives:
If it's toggled at runtime via the UI → Airflow Variable. If it's a connection with host + auth + port shape → Airflow Connection. Otherwise → Environment variable (Astronomer Secret if it's a credential, regular env var if it's static infrastructure config).
Storage matrix¶
| Storage | Use for | Read with | Editable from UI |
|---|---|---|---|
| Astronomer env var (Secret) | Credentials, API tokens, service account keys | os.getenv("FOO") |
No — requires deploy |
| Astronomer env var (regular) | Static infrastructure config that doesn't need encryption | os.getenv("FOO") |
No — requires deploy |
| Airflow Connection | External system connections with host/login/password/port shape | BaseHook.get_connection("conn_id") or hooks |
Yes |
| Airflow Variable | Runtime tunables genuinely meant to be flipped via UI | Variable.get("FOO") |
Yes |
Why each tier exists¶
- Env Secrets are encrypted at rest, never visible in plain text in the UI, and never written to logs. They are the correct home for any credential.
- Env vars (regular) are deploy-time-static infrastructure config. No DB roundtrip per task, and the value is locked to the deployment.
- Airflow Connections exist specifically for the host+auth+port pattern. Hooks and operators expect them. Snowflake, Postgres, S3, MongoDB — these belong here.
- Airflow Variables are the only mechanism that lets ops flip a value via UI without a redeploy. Reserve them for that purpose only.
Classification of Current Variables¶
This is the canonical mapping for the migration tracked in #1014.
Values are migrated from Variable.get() to the destination listed below.
→ Astronomer env var (Secret) — credentials & tokens¶
WARBYPARKER_GLADLY_API_TOKEN, WARBYPARKER_GLADLY_EMAIL
CONDENAST_GLADLY_API_TOKEN, CONDENAST_GLADLY_EMAIL
CONDENAST_MEDALLIA_API_KEY, CONDENAST_MEDALLIA_API_TOKEN
CONDENAST_SPROUT_API_KEY, CONDENAST_SPROUT_CLIENT_ID
DATA_ENGINEERING_SERVICE_ACCOUNT
FLARE_VONAGE_CLIENT_ID, FLARE_VONAGE_SECRET
XOOS_API_TOKEN, XOOS_WBP_CLIENT_ID
bamboohr_api_key
→ Astronomer env var (regular) — static infrastructure¶
XO_ENVIRONMENT
S3_BASE_BUCKET
SNOWFLAKE_STAGE_NAME
WARBYPARKER_GLADLY_BASE_URL
CONDENAST_GLADLY_BASE_URL
XOOS_API_BASE_URL
XOOS_WBP_API_BASE_URL
→ Airflow Connection — host+auth shape¶
sf_account / sf_user / sf_pass / sf_database → legacy OPERATIONS Snowflake connection
mongo_host / mongo_user / mongo_pass / mongo_db → mongo_default connection
The dbt pipelines already use the snowflake_dbt Connection. The legacy sf_* Variables
predate that convention and should consolidate to a Connection.
Stays as Airflow Variable — actual runtime tunables¶
This is currently the only value in the deployment that is legitimately meant to be flipped at runtime without a redeploy.
Nuance: HTTP API credentials (Gladly, Medallia, Sprout)¶
These have a host + auth shape and could be modeled as Airflow Connections. We chose env vars instead because:
- Our extractor code does not use Airflow hooks — it builds requests with
httpx/requestsdirectly, so a Connection object provides no ergonomics gain - Env var access is faster (no DB call) and simpler
- The day we wrap an HTTP client in a Hook, we revisit and migrate
Treating these as env Secrets is the correct default until that day.
Consequences¶
- Security improves: Credentials move from plain-text Postgres rows to encrypted Astronomer Secrets. The GCP service account JSON is the highest-priority migration target.
- Performance improves: Tasks that previously made N metadata DB calls per run for static values now do N dict lookups in process memory.
- The Airflow Variables page becomes meaningful: anything you see there is a runtime knob.
- Local dev gets a clear contract:
.env.exampledocuments required env vars. Runtime tunables stay inairflow_settings.yaml. - A dependency on Astronomer's Secrets feature: We accept this — Astronomer is our deployment target and any equivalent platform offers the same primitive.
Options Considered¶
| Option | Decision |
|---|---|
| Keep everything as Airflow Variables, mark secrets with sensitive name patterns | Rejected — UI masking ≠ encryption at rest |
| Move everything (incl. runtime tunables) to env vars | Rejected — loses the ability to flip values via UI without redeploy |
| Model all HTTP API creds as Airflow Connections | Rejected for now — no hook adoption means no ergonomics gain |
Use a dedicated secrets backend (Vault, AWS Secrets Manager) via Airflow's secrets_backend |
Rejected for now — Astronomer Secrets cover the use case; revisit if multi-environment secret rotation becomes a need |