DEV Community

Cover image for I built a free Data Engineering pipeline with dbt, DuckDB and GitHub Actions (no AWS)
DAYAN ELVIS JAHUIRA PILCO
DAYAN ELVIS JAHUIRA PILCO

Posted on Edited on

I built a free Data Engineering pipeline with dbt, DuckDB and GitHub Actions (no AWS)

Can you build a "real" data pipeline (ingestion, warehouse, transformations, data quality tests, orchestration, deployment and a public dashboard) without paying a single dollar and without touching AWS?

Yes. In this article I'll show you how I did it with Python, DuckDB, dbt, GitHub Actions and GitHub Pages. The result is a website that updates itself every day with the weather of 10 Latin American capitals.


The problem

I wanted to answer a few simple but interesting questions:

  • Which Latin American capital got the most rain in the last 60 days?
  • How much does altitude affect temperature? (spoiler: La Paz sits at 3,600 m)
  • Will the next 7 days be warmer or colder than the recent average?

To answer them I need data that arrives every day, that must be cleaned and validated, and that has to be published for anyone to see. In other words: a classic Data Engineering problem.

The architecture

Pipeline architecture

Open-Meteo API ─▢ Python (extract + load) ─▢ DuckDB ─▢ dbt (staging β†’ marts + tests) ─▢ GitHub Pages
                         β–²                                                                   β”‚
                         └─────────── GitHub Actions: daily cron + every push β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode
Stage Tool Why?
Source Open-Meteo Free weather API, no API key
Ingestion Python + requests Simple, and a single call returns all 10 cities
Warehouse DuckDB Analytical database in a single file; zero servers
Transformation dbt Version-controlled SQL, data tests, documentation and lineage
Orchestration + CI/CD GitHub Actions Free daily cron and automated deployment
Serving GitHub Pages Free public hosting for the dashboard

The key idea: each piece does one thing. Python moves data, dbt transforms and validates it, Actions orchestrates, Pages publishes.


Step 1 Β· Extraction: one call, ten cities

The cities live in a CSV file (dbt/seeds/cities.csv). It is the single source of truth: the extractor reads it to know which coordinates to request, and dbt loads it as a table for joins.

city_id,city_name,country,country_code,latitude,longitude
lima,Lima,Peru,PE,-12.0464,-77.0428
bogota,BogotΓ‘,Colombia,CO,4.7110,-74.0721
la_paz,La Paz,Bolivia,BO,-16.4897,-68.1193
...
Enter fullscreen mode Exit fullscreen mode

Open-Meteo accepts several comma-separated coordinates, so a single request is enough. I also added retries with exponential backoff, because APIs fail:

def fetch_weather(cities, retries=3):
    params = {
        "latitude": ",".join(c["latitude"] for c in cities),
        "longitude": ",".join(c["longitude"] for c in cities),
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,"
                 "wind_speed_10m_max,weather_code",
        "past_days": 60,
        "forecast_days": 7,
        "timezone": "auto",
    }
    for attempt in range(1, retries + 1):
        try:
            resp = requests.get(API_URL, params=params, timeout=30)
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException:
            if attempt == retries:
                raise
            time.sleep(2 ** attempt)
Enter fullscreen mode Exit fullscreen mode

The response is stored as is in data/raw/open_meteo_<timestamp>.json, together with the extraction time and the source URL. That's our landing zone: if I change a transformation tomorrow, I can reprocess without calling the API again.

Step 2 Β· Loading into DuckDB (idempotent)

The JSON contains arrays per city (time[], temperature_2m_max[]…). The loader "flattens" them into one row per city per day and inserts them into raw.weather_daily:

def flatten(document):
    rows = []
    for city_id, resp in zip(document["city_ids"], document["responses"]):
        daily = resp["daily"]
        for i, day in enumerate(daily["time"]):
            rows.append((city_id, day, *(daily[v][i] for v in DAILY_VARS),
                         resp["latitude"], resp["longitude"], resp.get("elevation"),
                         resp.get("timezone"), document["extracted_at"]))
    return rows
Enter fullscreen mode Exit fullscreen mode

One important detail: I use create or replace table. Since every extraction brings the full window (60 days + 7 days of forecast), running the pipeline twice never duplicates data. That's called idempotency, and it has its own pytest test:

def test_load_is_idempotent(tmp_path):
    load(raw, db)
    load(raw, db)  # twice
    assert con.execute("select count(*) from raw.weather_daily").fetchone()[0] == 4
Enter fullscreen mode Exit fullscreen mode

Note: the loader has no business logic. No rounding, no renaming. That's dbt's job.

Step 3 Β· Transformation with dbt

dbt lets you write transformations as SQL SELECTs, with dependencies, tests and documentation. With the dbt-duckdb adapter it runs locally, with no server.

I organized the project in layers:

Schema Contents Materialization
raw Data exactly as it arrives (Python) table
seeds cities.csv and weather_codes.csv (WMO codes β†’ "Light drizzle", "Thunderstorm"…) table
staging Cleaning: types, names, rounding, nulls view
marts Business-ready models table

Staging only cleans:

select
    city_id,
    cast(weather_date as date)               as weather_date,
    round(temperature_2m_max, 1)             as temp_max_c,
    round(temperature_2m_min, 1)             as temp_min_c,
    round(coalesce(precipitation_sum, 0), 1) as precipitation_mm,
    cast(weather_code as integer)            as weather_code,
    cast(elevation as integer)               as elevation_m,
    cast(extracted_at as timestamp)          as extracted_at
from {{ source('open_meteo', 'weather_daily') }}
where temperature_2m_max is not null
Enter fullscreen mode Exit fullscreen mode

The fact table adds derived metrics and joins each WMO code with its description:

select
    weather.city_id || '_' || strftime(weather.weather_date, '%Y%m%d') as weather_day_id,
    weather.city_id,
    weather.weather_date,
    round((weather.temp_max_c + weather.temp_min_c) / 2, 1)  as temp_avg_c,
    round(weather.temp_max_c - weather.temp_min_c, 1)         as temp_range_c,
    coalesce(codes.description, 'Unknown')                    as weather_description,
    weather.precipitation_mm >= 1.0                           as is_rainy_day,
    weather.weather_date >= cast(weather.extracted_at as date) as is_forecast
from {{ ref('stg_open_meteo__weather_daily') }} as weather
left join {{ ref('weather_codes') }} as codes using (weather_code)
Enter fullscreen mode Exit fullscreen mode

The mart mart_city_summary summarizes each city and compares the observed period with the forecast. DuckDB ships very handy functions such as arg_max (which day was the hottest?) and mode (what weather was most common?):

select
    city_id,
    round(avg(temp_avg_c), 1)              as avg_temp_c,
    round(sum(precipitation_mm), 1)        as total_precipitation_mm,
    count(*) filter (where is_rainy_day)   as rainy_days,
    arg_max(weather_date, temp_max_c)      as hottest_day,
    mode(weather_category)                 as dominant_category
from {{ ref('fct_weather_daily') }}
where not is_forecast
group by city_id
Enter fullscreen mode Exit fullscreen mode

Because I use ref() instead of table names, dbt builds the dependency graph by itself. And with dbt docs generate that graph is published as browsable lineage:

Lineage in dbt docs

Step 4 Β· Data quality: if a test fails, nothing gets published

This is my favorite part. dbt build builds the models and runs 23 tests in the right order. If one fails, the pipeline stops and the previous version of the dashboard stays online. Broken data is never published.

- name: weather_day_id
  data_tests: [unique, not_null]          # one row per city per day
- name: city_id
  data_tests:
    - relationships:                       # every city exists in the dimension
        arguments: {to: ref('dim_cities'), field: city_id}
- name: weather_category
  data_tests:
    - accepted_values:
        arguments:
          values: [clear, cloudy, fog, rain, snow, thunderstorm, other]
Enter fullscreen mode Exit fullscreen mode

I also wrote a custom generic test for physically possible ranges:

{% test value_between(model, column_name, min_value, max_value) %}
select {{ column_name }}
from {{ model }}
where {{ column_name }} < {{ min_value }} or {{ column_name }} > {{ max_value }}
{% endtest %}
Enter fullscreen mode Exit fullscreen mode

…and a singular test for an obvious business rule that is still worth checking:

-- the daily maximum can never be lower than the minimum
select * from {{ ref('fct_weather_daily') }} where temp_max_c < temp_min_c
Enter fullscreen mode Exit fullscreen mode

Result:

Finished running 2 seeds, 3 table models, 23 data tests, 1 view model in 1.01s
Done. PASS=29 WARN=0 ERROR=0 SKIP=0
Enter fullscreen mode Exit fullscreen mode

Step 5 Β· Orchestration and deployment with GitHub Actions

The whole pipeline runs with a single command: python -m pipeline.run. GitHub Actions triggers it in three cases: every day at 11:00 UTC (cron), on every push to main, and manually with a button.

on:
  push:
    branches: [main]
  schedule:
    - cron: "0 11 * * *"
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v6
        with: { python-version: "3.12", cache: pip }
      - run: pip install -r requirements.txt
      - run: pytest -q                 # Python unit tests
      - run: python -m pipeline.run    # extract β†’ load β†’ dbt build β†’ publish
      - uses: actions/upload-pages-artifact@v4
        with: { path: public }

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: github-pages
    steps:
      - uses: actions/deploy-pages@v4
Enter fullscreen mode Exit fullscreen mode

Notice needs: build: deployment only happens if everything before it passed. That's the pipeline's "quality gate". Pull requests run the tests but don't deploy.

Workflow in GitHub Actions

The only manual setup is a one-time step: Settings β†’ Pages β†’ Source: GitHub Actions.

Step 6 Β· Serving: dashboard, docs and open data

The last step reads the marts and generates the public/ folder:

  • index.html β†’ dashboard built with Chart.js (temperature per city, comparisons and a summary table)
  • docs/ β†’ dbt documentation with the lineage graph
  • data/*.csv and data/*.parquet β†’ the data, so anyone can download it

Exporting to Parquet with DuckDB is a one-liner:

con.execute("copy marts.fct_weather_daily to 'public/data/fct_weather_daily.parquet' (format parquet)")
Enter fullscreen mode Exit fullscreen mode

Dashboard

What the data says

Run of November 10, 2026 (last 60 days):

City Elevation Avg. temp Total rain Rainy days
Quito 2,920 m 14.4 Β°C 535.8 mm 49
Mexico City 2,230 m 18.3 Β°C 375.8 mm 43
La Paz 3,627 m 11.1 Β°C 108.8 mm 17
Lima 139 m 22.7 Β°C 20.6 mm 6
  • Altitude rules: La Paz is the coldest capital (11.1 Β°C average and a βˆ’3 Β°C minimum), even though it is closer to the equator than Buenos Aires.
  • Quito is the rainiest: almost 536 mm in 60 days.
  • Lima barely gets any rain, yet its "dominant" weather comes out as rain. That's the famous garΓΊa: a drizzle so fine it doesn't reach 1 mm (22 of its 60 days were "light drizzle"). Data has its own stories.

Lessons learned

  1. dbt docs generate overwrites run_results.json. If you want to show test results, copy that file right after dbt build.
  2. DuckDB doesn't allow two connections with different configurations to the same file within the same process. When you invoke dbt from Python (dbtRunner), open your own connection with the same configuration (I was opening it as read_only and it failed).
  3. Windows paths in YAML: C:\Users\... inside double quotes is parsed as escape sequences. Use Path.as_posix().
  4. Idempotency first. Designing the load so it can be repeated without side effects makes everything else simpler.

Next steps

  • Incremental dbt models to keep history beyond 60 days.
  • Move the warehouse to MotherDuck (DuckDB in the cloud) to query it from anywhere.
  • Orchestrate with Airflow or Dagster if the pipeline grows to more sources.
  • Alerts (for example, to Telegram) when a data quality test fails.

Try it yourself

git clone https://github.com/Dayan-18/clima-latam-pipeline
cd clima-latam-pipeline
python -m venv .venv && source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install -r requirements.txt
python -m pipeline.run
python -m http.server 8000 --directory public
Enter fullscreen mode Exit fullscreen mode

If you found this useful, leave a ❀️ or a comment. Which public data source would you plug into this pipeline?

Top comments (0)