| name | data-engineering-medallion-pipeline |
| description | End-to-end data engineering pipeline using MinIO, Airbyte, PostgreSQL, DBT, and Airflow with medallion architecture (Bronze/Silver/Gold layers) |
| triggers | ["set up a medallion architecture data pipeline","configure airbyte with minio and postgres","create dbt bronze silver gold models","orchestrate data pipeline with airflow","implement data quality tests in dbt","build an elt pipeline with docker compose","create a data lakehouse with medallion layers","deploy data engineering stack locally"] |
Data Engineering Medallion Pipeline Skill
Skill by ara.so — Data Skills collection.
This skill enables AI agents to work with a complete data engineering pipeline implementing the Medallion Architecture (Bronze → Silver → Gold) using modern open-source tools: MinIO (S3-compatible storage), Airbyte (data ingestion), PostgreSQL (data warehouse), DBT (transformations), Apache Airflow (orchestration), and Grafana (monitoring).
What This Project Does
The data-engineering-medallion project provides a complete end-to-end data pipeline that:
- Ingests raw data from MinIO object storage into PostgreSQL using Airbyte
- Transforms data through three layers (Bronze/Silver/Gold) using DBT
- Orchestrates the entire pipeline with Apache Airflow DAGs
- Validates data quality with automated DBT tests
- Monitors infrastructure health with Prometheus and Grafana
- Visualizes business metrics in Power BI dashboards
The architecture follows ELT (Extract-Load-Transform) pattern with clear separation of concerns:
- Bronze: Raw immutable data from sources (JSONB format)
- Silver: Cleaned, validated, and typed data
- Gold: Business-ready aggregated metrics and KPIs
Installation & Setup
Prerequisites
docker --version
docker-compose --version
Clone and Initialize
git clone https://github.com/LucasGoulartCouto/data-engineering-medallion.git
cd data-engineering-medallion
make setup
make start
make status
Service URLs
After startup, access these interfaces:
Key Commands (Makefile)
make setup
make start
make stop
make restart
make status
make logs SERVICE=airflow
make dbt-run
make dbt-test
make dbt-docs
make dbt-snapshot
make dbt-clean
make upload-data
make trigger-dag DAG_ID=bronze_ingestion_dag
make lint
make format
make validate
make clean
make clean-all
Project Structure
data-engineering-medallion/
├── airflow/
│ └── dags/
│ ├── bronze_ingestion_dag.py # Triggers Airbyte sync
│ ├── silver_transformation_dag.py # Runs DBT silver models
│ └── gold_aggregation_dag.py # Runs DBT gold models
├── dbt/
│ ├── models/
│ │ ├── bronze/ # Extract JSONB → columnar
│ │ ├── silver/ # Clean, validate, dedupe
│ │ └── gold/ # Business metrics
│ ├── macros/ # Reusable SQL functions
│ ├── snapshots/ # SCD Type 2 history
│ └── tests/ # Custom data quality tests
├── scripts/
│ ├── upload_to_minio.py # Upload CSV/JSON to MinIO
│ └── test_connections.py # Verify service connectivity
├── postgres/init/ # Database initialization SQL
├── monitoring/
│ ├── grafana/provisioning/
│ └── prometheus/
├── docker-compose.yml
├── Makefile
└── .env # Configuration (create from .env.example)
DBT Model Development
Bronze Layer (Raw Extraction)
Bronze models extract JSONB data from Airbyte into typed columns:
{{ config(
materialized='view',
schema='bronze'
) }}
SELECT
_airbyte_ab_id,
_airbyte_emitted_at,
_airbyte_data->>'order_id' AS order_id,
_airbyte_data->>'customer_id' AS customer_id,
_airbyte_data->>'order_date' AS order_date,
_airbyte_data->>'total_amount' AS total_amount,
_airbyte_data->>'status' AS status,
_airbyte_data AS raw_data
FROM {{ source('airbyte_raw', '_airbyte_raw_orders') }}
Silver Layer (Cleaned & Validated)
Silver models clean, cast types, deduplicate, and add calculated fields:
{{ config(
materialized='table',
schema='silver',
unique_key='order_id'
) }}
WITH deduplicated AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY _airbyte_emitted_at DESC
) AS rn
FROM {{ ref('bronze_orders') }}
)
SELECT
order_id::INTEGER,
customer_id::INTEGER,
order_date::DATE,
total_amount::DECIMAL(10,2),
UPPER(TRIM(status)) AS status,
CASE
WHEN total_amount::DECIMAL > 1000 THEN 'high_value'
WHEN total_amount::DECIMAL > 500 THEN 'medium_value'
ELSE 'low_value'
END AS order_value_segment,
_airbyte_emitted_at AS ingested_at,
CURRENT_TIMESTAMP transformed_at
deduplicated
rn
order_id
order_date::
Gold Layer (Business Metrics)
Gold models aggregate data for business consumption:
{{ config(
materialized='table',
schema='gold'
) }}
SELECT
p.product_id,
p.product_name,
p.category,
p.supplier,
COUNT(DISTINCT oi.order_id) AS total_orders,
SUM(oi.quantity) AS total_quantity_sold,
SUM(oi.line_total) AS total_revenue,
SUM(oi.quantity * p.cost) AS total_cost,
SUM(oi.line_total) - SUM(oi.quantity * p.cost) AS total_profit,
ROUND(
(SUM(oi.line_total) - SUM(oi.quantity * p.cost)) /
NULLIF(SUM(oi.line_total), 0) * 100,
2
) AS profit_margin_percentage,
AVG(oi.unit_price) AS avg_unit_price,
MAX(o.order_date) AS last_sale_date
FROM {{ ref('silver_products') }} p
INNER JOIN {{ ref('silver_order_items') }} oi
ON p.product_id = oi.product_id
INNER JOIN {{ ref() }} o
oi.order_id o.order_id
o.status
p.product_id, p.product_name, p.category, p.supplier
DBT Testing & Data Quality
Schema Tests (schema.yml)
version: 2
models:
- name: silver_orders
description: "Cleaned and validated orders"
columns:
- name: order_id
description: "Primary key"
tests:
- unique
- not_null
- name: customer_id
description: "Foreign key to customers"
tests:
- not_null
- relationships:
to: ref('silver_customers')
field: customer_id
- name: status
tests:
- accepted_values:
values: ['PENDING', 'COMPLETED', 'CANCELLED', 'REFUNDED']
- name: total_amount
tests:
- dbt_utils.accepted_range:
min_value: 0
Custom Tests
SELECT order_id, order_date
FROM {{ ref('silver_orders') }}
WHERE order_date > CURRENT_DATE
SELECT product_id, total_profit
FROM {{ ref('gold_product_performance') }}
WHERE total_profit < 0
Running Tests
make dbt-test
docker-compose exec dbt dbt test --select silver_orders
docker-compose exec dbt dbt test --select test_type:generic
docker-compose exec dbt dbt test --select test_type:singular
Airflow DAG Development
Bronze Ingestion DAG
from datetime import datetime, timedelta
from airflow import DAG
from airflow.providers.airbyte.operators.airbyte import AirbyteTriggerSyncOperator
from airflow.operators.python import PythonOperator
default_args = {
'owner': 'data-engineering',
'depends_on_past': False,
'email_on_failure': True,
'email_on_retry': False,
'retries': 3,
'retry_delay': timedelta(minutes=5),
}
with DAG(
'bronze_ingestion_dag',
default_args=default_args,
description='Ingest data from MinIO to PostgreSQL Bronze layer',
schedule_interval='@daily',
start_date=datetime(2024, 1, 1),
catchup=False,
tags=['bronze', 'ingestion', 'airbyte'],
) as dag:
trigger_airbyte_sync = AirbyteTriggerSyncOperator(
task_id='trigger_airbyte_orders_sync',
airbyte_conn_id='airbyte_default',
connection_id='{{ var.value.airbyte_connection_id }}',
asynchronous=False,
timeout=3600,
)
def validate_ingestion(**context):
from airflow.providers.postgres.hooks.postgres import PostgresHook
pg_hook = PostgresHook(postgres_conn_id=)
result = pg_hook.get_first(
)
result[] == :
ValueError()
()
validate_task = PythonOperator(
task_id=,
python_callable=validate_ingestion,
)
trigger_airbyte_sync >> validate_task
Silver Transformation DAG
from datetime import datetime, timedelta
from airflow import DAG
from airflow.providers.dbt.cloud.operators.dbt import DbtRunOperator
from airflow.operators.bash import BashOperator
default_args = {
'owner': 'data-engineering',
'retries': 2,
'retry_delay': timedelta(minutes=3),
}
with DAG(
'silver_transformation_dag',
default_args=default_args,
description='Transform bronze to silver layer with DBT',
schedule_interval='@daily',
start_date=datetime(2024, 1, 1),
catchup=False,
tags=['silver', 'transformation', 'dbt'],
) as dag:
run_silver_models = BashOperator(
task_id='run_dbt_silver_models',
bash_command='cd /opt/dbt && dbt run --select silver.*',
)
test_silver_models = BashOperator(
task_id='test_dbt_silver_models',
bash_command='cd /opt/dbt && dbt test --select silver.*',
)
run_silver_models >> test_silver_models
Gold Aggregation DAG
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import BranchPythonOperator
default_args = {
'owner': 'data-engineering',
'retries': 2,
'retry_delay': timedelta(minutes=3),
}
with DAG(
'gold_aggregation_dag',
default_args=default_args,
description='Build gold layer business metrics',
schedule_interval='@daily',
start_date=datetime(2024, 1, 1),
catchup=False,
tags=['gold', 'aggregation', 'metrics'],
) as dag:
run_gold_models = BashOperator(
task_id='run_dbt_gold_models',
bash_command='cd /opt/dbt && dbt run --select gold.*',
)
test_gold_models = BashOperator(
task_id='test_dbt_gold_models',
bash_command='cd /opt/dbt && dbt test --select gold.*',
)
snapshot_gold = BashOperator(
task_id='snapshot_gold_metrics',
bash_command='cd /opt/dbt && dbt snapshot',
)
run_gold_models >> test_gold_models >> snapshot_gold
Data Upload to MinIO
import os
from minio import Minio
from minio.error import S3Error
import pandas as pd
from pathlib import Path
def upload_data_to_minio():
"""Upload sample CSV data to MinIO bucket"""
client = Minio(
os.getenv('MINIO_ENDPOINT', 'localhost:9000'),
access_key=os.getenv('MINIO_ACCESS_KEY', 'minioadmin'),
secret_key=os.getenv('MINIO_SECRET_KEY'),
secure=False
)
bucket_name = os.getenv('MINIO_BUCKET', 'raw-data')
try:
if not client.bucket_exists(bucket_name):
client.make_bucket(bucket_name)
print(f"Created bucket: {bucket_name}")
except S3Error as e:
print(f"Error creating bucket: {e}")
return
data_dir = Path('data/raw')
for file_path in data_dir.glob('*.csv'):
try:
client.fput_object(
bucket_name,
file_path.name,
str(file_path),
content_type='text/csv'
)
()
S3Error e:
()
__name__ == :
upload_data_to_minio()
Run the upload:
make upload-data
python scripts/upload_to_minio.py
Configuration
Environment Variables (.env)
POSTGRES_USER=dataeng
POSTGRES_PASSWORD=<your-secure-password>
POSTGRES_DB=datawarehouse
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=<your-secure-password>
MINIO_ENDPOINT=minio:9000
MINIO_BUCKET=raw-data
AIRFLOW_UID=50000
AIRFLOW_GID=0
AIRFLOW__CORE__EXECUTOR=LocalExecutor
AIRFLOW__CORE__SQL_ALCHEMY_CONN=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/airflow
AIRFLOW__CORE__FERNET_KEY=<generate-with-python-cryptography>
AIRBYTE_VERSION=0.50.0
DBT_PROFILES_DIR=/opt/dbt
DBT_PROJECT_DIR=/opt/dbt
GF_SECURITY_ADMIN_PASSWORD=<your-secure-password>
DBT Profile Configuration
datawarehouse:
target: dev
outputs:
dev:
type: postgres
host: "{{ env_var('POSTGRES_HOST') }}"
port: "{{ env_var('POSTGRES_PORT') | int }}"
user: "{{ env_var('POSTGRES_USER') }}"
password: "{{ env_var('POSTGRES_PASSWORD') }}"
dbname: "{{ env_var('POSTGRES_DB') }}"
schema: public
threads: 4
keepalives_idle: 0
Airbyte Connection Setup
-
Access Airbyte UI: http://localhost:8000
-
Create MinIO source:
- Connector: S3
- Endpoint: http://minio:9000
- Bucket: raw-data
- Access Key: ${MINIO_ROOT_USER}
- Secret Key: ${MINIO_ROOT_PASSWORD}
-
Create PostgreSQL destination:
- Host: postgres
- Port: 5432
- Database: datawarehouse
- Schema: bronze
- Username: ${POSTGRES_USER}
- Password: ${POSTGRES_PASSWORD}
-
Create connection with sync mode: Full Refresh | Overwrite
Common Patterns
Incremental Model Pattern
{{ config(
materialized='incremental',
unique_key='order_id',
schema='silver',
on_schema_change='append_new_columns'
) }}
SELECT
order_id,
customer_id,
order_date,
total_amount,
status,
_airbyte_emitted_at AS ingested_at
FROM {{ ref('bronze_orders') }}
{% if is_incremental() %}
WHERE _airbyte_emitted_at > (SELECT MAX(ingested_at) FROM {{ this }})
{% endif %}
Macro for Common Transformations
{% macro clean_string(column_name) %}
UPPER(TRIM(REGEXP_REPLACE({{ column_name }}, '\s+', ' ', 'g')))
{% endmacro %}
SELECT {{ clean_string('customer_name') }} AS customer_name
FROM {{ ref('bronze_customers') }}
Snapshot (SCD Type 2)
{% snapshot snapshot_customer_segments %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at',
)
}}
SELECT * FROM {{ ref('gold_customer_metrics') }}
{% endsnapshot %}
Custom Test Macro
{% test no_orphan_records(model, column_name, parent_model, parent_column) %}
SELECT {{ column_name }}
FROM {{ model }}
WHERE {{ column_name }} IS NOT NULL
AND {{ column_name }} NOT IN (
SELECT {{ parent_column }}
FROM {{ parent_model }}
)
{% endtest %}
Querying the Data Warehouse
Bronze Layer Query
SELECT
_airbyte_ab_id,
_airbyte_emitted_at,
_airbyte_data->>'order_id' AS order_id,
_airbyte_data
FROM airbyte_raw._airbyte_raw_orders
LIMIT 10;
Silver Layer Query
SELECT
order_id,
customer_id,
order_date,
total_amount,
status,
order_value_segment
FROM silver.silver_orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY order_date DESC;
Gold Layer Query
SELECT
product_name,
category,
total_revenue,
total_profit,
profit_margin_percentage,
total_quantity_sold
FROM gold.gold_product_performance
WHERE profit_margin_percentage > 20
ORDER BY total_revenue DESC
LIMIT 10;
SELECT
customer_segment,
COUNT(*) AS customer_count,
AVG(total_spent) AS avg_lifetime_value,
AVG(recency_days) AS avg_recency
FROM gold.gold_customer_metrics
GROUP BY customer_segment
ORDER BY avg_lifetime_value DESC;
Troubleshooting
Container Won't Start
make logs SERVICE=postgres
make logs SERVICE=airflow-webserver
docker system df
docker system prune
docker-compose restart postgres
Airbyte Connection Failing
docker-compose exec airbyte-worker curl http://minio:9000/minio/health/live
docker-compose exec airbyte-worker \
psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c "SELECT 1"
docker-compose logs airbyte-worker | grep ERROR
DBT Model Failing
docker-compose exec dbt dbt run --select silver_orders --debug
cat dbt/target/compiled/datawarehouse/models/silver/silver_orders.sql
docker-compose exec dbt dbt run --select silver_orders --full-refresh
docker-compose exec dbt dbt parse
Airflow DAG Not Appearing
docker-compose exec airflow-webserver airflow dags list-import-errors
python -m py_compile airflow/dags/bronze_ingestion_dag.py
docker-compose exec airflow-webserver airflow dags trigger <dag_id>
PostgreSQL Connection Refused
docker-compose exec postgres pg_isready -U ${POSTGRES_USER}
docker-compose exec dbt psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB}
grep POSTGRES .env
Data Quality Test Failures
docker-compose exec dbt dbt test --select silver_orders --store-failures
SELECT * FROM silver.test_failures
WHERE test_name = 'unique_order_id';
docker-compose exec dbt dbt test --select test_name:unique_order_id
MinIO Upload Failing
docker-compose ps minio
docker-compose exec minio mc alias set local http://localhost:9000 \
${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}
docker-compose exec minio mc ls local/${MINIO_BUCKET}
docker-compose exec minio mc cp /tmp/test.csv local/${MINIO_BUCKET}/
Monitoring & Observability
Check Pipeline Health
docker-compose exec airflow-webserver \
airflow tasks state bronze_ingestion_dag trigger_airbyte_orders_sync 2024-01-01
docker-compose exec dbt dbt run-operation log_run_results
docker-compose exec postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c \
"SELECT query, calls, total_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;"
Grafana Dashboard Access
- Access http://localhost:3000
- Default dashboard: PostgreSQL Overview
- Key metrics:
- Cache hit rate (should be >95%)
- Active connections
- TPS (transactions per second)
- Query duration percentiles
Custom Alerts
Add to monitoring/prometheus/alerts.yml:
groups:
- name: dbt_pipeline
rules:
- alert: DBTModelFailed
expr: dbt_model_run_status{status="error"} > 0
for: 5m
labels:
severity: critical
annotations:
summary: "DBT model {{ $labels.model }} failed"
This skill covers the complete data-engineering-medallion pipeline from setup through production operations, enabling AI agents to assist with implementation, troubleshooting, and extension of medallion architecture data pipelines.