I've run Airflow 3 in Docker Compose across 8 data pipeline projects: a call center analytics pipeline, a loan risk model, an ecommerce pipeline, and more. Each time I set up a fresh environment, I hit at least one of these five mistakes. Some crashed loudly. Others failed silently for hours before I found the cause.
This isn't a getting-started guide. The official docs cover that. This is the list of things the docs assume you already know, plus the exact config that fixes each one.
Mistake 1: Running pip install as root in your Dockerfile
This is the most common setup mistake, and also the hardest to debug because it doesn't fail at build time. Your image builds cleanly. Containers start. Then your DAGs crash with ModuleNotFoundError even though the package is right there in requirements.txt.
Here's what happened:
# WRONG: installs to /root/.local/lib/ (root-only path)
FROM apache/airflow:3.0.0
RUN pip install --no-cache-dir pandas==2.2.3
The Airflow image runs as the airflow user at runtime. When you install as root, packages land in /root/.local/lib/, which the airflow user can't reach. The build succeeds, the packages exist, but they're invisible to the process that actually runs your tasks.
# CORRECT: USER airflow must come before pip install
FROM apache/airflow:3.0.0
USER airflow
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
Same problem with uv pip install --system inside the Airflow image. It installs to a system path that the airflow user's Python doesn't see. Stick to plain pip install as USER airflow.
Mistake 2: Using airflow db init or airflow db upgrade
If you've used Airflow 2, these are pure muscle memory:
airflow db init # Airflow 2
airflow db upgrade # Airflow 2
Both commands are gone in Airflow 3. The replacement is airflow db migrate, which handles both initial setup and upgrades in one shot:
# In your docker-compose.yml airflow-init service
airflow-init:
<<: *airflow-common
command: >
bash -c "airflow db migrate &&
airflow users create
--username admin
--firstname Admin
--lastname User
--role Admin
--email your@email.com
--password ${AIRFLOW_ADMIN_PASSWORD}"
restart: "no"
Run this as a one-shot init service with restart: "no". Run it again every time you bump the Airflow image tag.
Two other things that catch Airflow 2 migrants off guard: Airflow 3 requires SQLAlchemy 2.x (not 1.4) and Python 3.9+ (3.11 recommended). If you're copying a requirements.txt from an older project, update those pins before you build.
Mistake 3: Leaving out the triggerer service
In Airflow 2, the triggerer was optional. You only needed it for deferrable operators. In Airflow 3, it is required, even when you think you're not using any deferred tasks.
Leave it out and tasks that use sensors or any deferrable operator will enter a deferred state and sit there indefinitely. No error message. No log output. The task just never moves.
Add it to your docker-compose.yml:
airflow-triggerer:
<<: *airflow-common
command: triggerer
restart: unless-stopped
It uses the same x-airflow-common anchor as your other services. The triggerer is lightweight. It runs an async event loop, not heavy task execution. There's no reason to leave it out.
Mistake 4: Writing passwords.json as a list
Airflow 3 replaces Flask-AppBuilder (FAB) with SimpleAuthManager as the default authentication backend. Instead of a database-backed user table, credentials live in a JSON file you manage yourself.
The catch: the file must be a JSON object (dict), not an array. I wrote it as a list the first time because it felt natural for a user record:
// WRONG: Airflow crashes on startup with a cryptic auth error
[
{"username": "admin", "password": "your_secure_password"}
]
// CORRECT: username as key, password as value
{
"admin": "your_secure_password"
}
When the format is wrong, the startup error doesn't say "wrong format." It surfaces as an auth manager initialization failure deep in the stack. You can easily spend 30 minutes looking in the wrong place.
Enable SimpleAuthManager and mount the file in your docker-compose.yml:
environment:
AIRFLOW__CORE__AUTH_MANAGER: airflow.auth.managers.simple.simple_auth_manager.SimpleAuthManager
AIRFLOW__SIMPLE_AUTH_MANAGER__PASSWORDS_FILE: /opt/airflow/config/passwords.json
volumes:
- ./config/passwords.json:/opt/airflow/config/passwords.json:ro
One more thing: the _AIRFLOW_WWW_USER_CREATE environment variable that created admin users in Airflow 2 is silently ignored in Airflow 3 with SimpleAuthManager. If you copied a Compose file from an Airflow 2 project, that env var does nothing. Your user won't be created and login will fail even when the credentials are correct.
Mistake 5: Using localhost for service connections inside Docker
When all your services run inside the same Docker Compose network, localhost doesn't work as a hostname. Each container is its own isolated network namespace. Inside the airflow-scheduler container, localhost resolves to that container itself, not to Postgres or any other service.
Use the service name from docker-compose.yml as the hostname instead:
# WRONG: localhost doesn't resolve to Postgres from inside the container
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow_user:${POSTGRES_PASSWORD}@localhost/airflow_db
# CORRECT: use the service name as the host
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow_user:${POSTGRES_PASSWORD}@postgres/airflow_db
The same rule applies to every connection your tasks make: SQLAlchemy URLs, Airflow connections, any API endpoint pointing to another service in the stack. If the target is defined in docker-compose.yml, use its name as the host.
This also applies to Airflow 3's Task Execution API. Airflow 3 introduced AIP-72, which routes task-to-scheduler communication through an authenticated HTTP API instead of direct database access. If tasks are silently failing with auth errors across multiple services, check whether AIRFLOW__CORE__EXECUTION_API_SERVER_URL is still pointing at localhost:
# In your x-airflow-common environment block
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-webserver:8080/execution/
The Full docker-compose.yml
Here's the complete config that has worked reliably across all 8 of my projects:
x-airflow-common: &airflow-common
build: .
env_file: .env
environment:
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow_user:${POSTGRES_PASSWORD}@postgres/airflow_db
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_SECRET_KEY}
volumes:
- ./dags:/opt/airflow/dags
- airflow_logs:/opt/airflow/logs
- airflow_plugins:/opt/airflow/plugins
depends_on:
postgres:
condition: service_healthy
networks:
- pipeline_net
services:
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: airflow_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: airflow_db
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U airflow_user -d airflow_db"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- pipeline_net
airflow-init:
<<: *airflow-common
command: >
bash -c "airflow db migrate &&
airflow users create --username admin --firstname Admin
--lastname User --role Admin --email your@email.com
--password ${AIRFLOW_ADMIN_PASSWORD}"
restart: "no"
airflow-webserver:
<<: *airflow-common
command: webserver
ports:
- "8080:8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
airflow-scheduler:
<<: *airflow-common
command: scheduler
restart: unless-stopped
airflow-triggerer:
<<: *airflow-common
command: triggerer
restart: unless-stopped
volumes:
postgres_data:
airflow_logs:
airflow_plugins:
networks:
pipeline_net:
driver: bridge
Your .env (never commit this):
POSTGRES_PASSWORD=yourpassword
AIRFLOW_ADMIN_PASSWORD=yourpassword
AIRFLOW_SECRET_KEY=your-long-random-secret
DATABASE_URL=postgresql+psycopg2://airflow_user:yourpassword@postgres/airflow_db
Quick Reference
| Mistake | What breaks | Fix |
|---|---|---|
pip install as root |
ModuleNotFoundError at runtime |
USER airflow before install in Dockerfile |
airflow db init / db upgrade
|
Command not found | Use airflow db migrate
|
| No triggerer service | Deferred tasks hang silently | Add triggerer to docker-compose |
passwords.json as list |
Cryptic auth startup crash | Use dict: {"user": "pass"}
|
localhost for service URLs |
Connection refused in tasks | Use Docker service names as hostnames |
Starting a fresh Airflow 3 project? This config skips every one of these. Migrating from Airflow 2? The Dockerfile user order and db migrate rename will catch you first. The rest are additive changes specific to Airflow 3.
Hit a different gotcha not on this list? Drop it in the comments.
Follow me on dev.to for more data engineering content, or check out the full pipeline code at github.com/declerke.
Top comments (0)