Learn how to build portable, testable data pipelines by containerizing your ETL logic and using Airflow purely as a scheduler, keeping your code independent from any specific orchestration tool.
By John Wassilak
If you’ve been doing data engineering for a while, you’ve probably seen it happen: a company goes all-in on something like Airflow, writes hundreds of DAGs with tons of business logic baked directly into Python operators, and then… Airflow 3.0 drops, or the team decides to migrate to another tool like Prefect, or suddenly you need to run the same pipeline in a different environment. Feels like job security until you try to explain your time to the boss.
There’s a better way. Instead of marrying your data pipelines to Airflow (or any orchestrator), you can keep things loosely coupled by treating Airflow as what it really is: a scheduler and coordinator, not your entire data platform.
Here’s the pattern:
Instead of this (tightly coupled):
from airflow.operators.python import PythonOperator
def extract_from_api():
# 200 lines of extraction logic here
pass
def transform_data():
# 300 lines of transformation logic here
pass
dag = DAG('my_pipeline')
extract = PythonOperator(task_id='extract', python_callable=extract_from_api, dag=dag)
transform = PythonOperator(task_id='transform', python_callable=transform_data, dag=dag)
You do this:
from airflow.providers.docker.operators.docker import DockerOperator
dag = DAG('my_pipeline')
extract = DockerOperator(
task_id='extract',
image='my-company/data-extractor:v1.2',
command='--source=api --target=s3://raw-data',
dag=dag
)
transform = DockerOperator(
task_id='transform',
image='my-company/dbt-runner:latest',
command='run --models staging',
dag=dag
)
Your DAG file is now just configuration. All the actual work happens in containers that you can test, version, and run anywhere.
You can test your pipelines locally. No need to spin up a full Airflow environment.
Just docker run your container with the same arguments Airflow would use.
Found a bug? Fix it in the container, rebuild, done. No Airflow involved.
Your pipelines are portable. Need to run the same transformation in a Kubernetes CronJob? Go for it. Want to trigger it manually from your laptop? Easy. Migrating to a different orchestrator? Your DAGs change, but your actual pipeline code doesn’t need to touch.
Version control becomes cleaner. Your pipeline logic and your orchestration logic live in different repos (or at least different parts of the same repo). Each can evolve independently. Tag your container images properly and you have a clear version history of what code was running at any given time.
Dependency hell is isolated. That pipeline that needs Python 3.8 and another one that needs Python 3.11? Not a problem when they’re in separate containers. No more trying to find a Python version that makes everyone happy.
This pattern really shines when:
It’s probably overkill if:
Keep images small. Use multi-stage builds and Alpine-based images where possible. Nobody wants to wait 3 minutes for a 2GB image to pull.
Tag everything properly. Use semantic versioning or commit SHAs for your container tags.
latest is convenient until you need to debug what changed between yesterday and today.
Handle secrets carefully. Don’t bake secrets into images. Use Airflow connections, environment variables, or a proper secrets manager.
Set up good logging. Make sure your containers write logs to stdout/stderr so Airflow can capture them. Structured logging (JSON) makes your future self very happy.
Monitor your container registry. Old images pile up fast. Have a cleanup policy.
Consider passing arguments as environment variables. Stylistic choice, but it makes things cleaner once you need to pass dozens of arguments to the container. Example:
from airflow import DAG
from airflow.providers.docker.operators.docker import DockerOperator
# variables set at airflow instance level (encrypted)
default_env = {
'DEST_HOST': '{{ var.value.DEST_HOST }}',
'DEST_DB': '{{ var.value.DEST_DB }}',
'DEST_USER': '{{ var.value.DEST_USER }}',
'DEST_PW': '{{ var.value.DEST_PW }}',
'SOURCE_HOST': '{{ var.value.SOURCE_HOST }}',
'SOURCE_DB': '{{ var.value.SOURCE_DBO }}',
'SOURCE_USER': '{{ var.value.SOURCE_USER }}',
'SOURCE_PW': '{{ var.value.SOURCE_PW }}'
}
with DAG(
dag_id='your-pipeline',
schedule='0 5 * * *',
...
default_args={
'docker_url': 'unix:///var/run/docker.sock',
'environment': default_env,
},
catchup=False
) as dag:
transform = DockerOperator(task_id='transform', image='my-company/dbt-runner:latest', command='run --models staging')
...
Decoupling your data pipelines from Airflow isn’t about being anti-Airflow. Airflow is great at what it does. But your data transformation logic, your API integrations, your ML models? Those are your IP, and they shouldn’t be locked into any single tool.
By containerizing your pipeline components and using Airflow purely for orchestration, you’re building more maintainable, testable, and portable data infrastructure. Yeah, there’s some extra complexity upfront. But when you can swap orchestrators, run pipelines locally, or let a different team reuse your pipeline in their own context without copying hundreds of lines of code? That’s when you realize the investment was worth it.
Your future self (or the poor soul who inherits your codebase) will thank you.