Data Engineering Project

GDPR-Compliant Open Source
Local Data Stack

Replaced Google's cloud ecosystem with a fully self-hosted analytics pipeline โ€” Docker, Postgres, Metabase, and VS Code โ€” running entirely on local infrastructure.

Docker PostgreSQL 18 Metabase VS Code + SQLTools SQL GDPR Compliant Open Source
4
CSV datasets loaded
3
KPI dashboards built
21
Countries analysed
Background

Why replace the Google stack?

The GoExplore project originally relied on Google Sheets, BigQuery, and Looker Studio โ€” all US-hosted cloud services. For a European team processing retail sales data, this creates GDPR exposure: data leaves the EU, vendor lock-in is real, and costs scale unpredictably.

The goal was to rebuild the same analytical capability โ€” data storage, SQL querying, and KPI dashboards โ€” using only open source tools running locally, with zero dependency on any external cloud provider.

๐Ÿ“
CSV Files
GoExplore datasets
โ†’
๐Ÿ˜
PostgreSQL 18
Local Mac install
โ†’
๐Ÿณ
Metabase
Docker container
โ†’
๐Ÿ“Š
Dashboards
localhost:3000
Google ToolOpen Source ReplacementHosting
Google SheetsPostgreSQL 18 + CSV uploadLocal Mac
BigQueryPostgreSQL 18 (Postgres.app)Local Mac
Looker StudioMetabase Community EditionDocker
Cloud SQL editorVS Code + SQLToolsLocal
Docker Setup

Cleaning the environment & deploying Metabase

Before building anything, the local Docker environment was in a messy state โ€” a legacy Apache Superset stack left over from a previous attempt, plus several orphaned containers holding onto ports. Getting to a clean slate was the first real engineering challenge.

Problem 1 โ€” Port conflicts

The first nginx container launch failed immediately on port 8080. The second attempt on port 8081 failed too. Both ports were held by stopped (but not removed) containers โ€” a common Docker pitfall.

# Error on port 8080
docker run --name website -p 8080:80 nginx
โ†’ Bind for 0.0.0.0:8080 failed: port is already allocated

# Error on port 8081 too
docker run --name website -p 8081:80 nginx
โ†’ Bind for 0.0.0.0:8081 failed: port is already allocated

# Diagnosis: find what owns the port
lsof -i :8080
docker ps -a  # stopped containers still hold ports

Problem 2 โ€” Legacy Superset stack

A full Apache Superset installation was discovered: 11 containers (running and stopped), ~8GB of images across 14 image layers, and 11 named volumes. The decision was to remove everything and start clean.

Removed

11 containers

Including superset, worker, worker-beat, websocket, init, node, nginx, redis, postgres

Freed

~8 GB images

superset (1.48GB ร—4), superset-node (697MB), apache/superset (785MB), latest-dev (1.32GB)

Cleared

11 volumes

superset_db_home, superset_redis, superset_superset_data, superset_home + 7 anonymous volumes

# Stop and remove all containers
docker rm -f $(docker ps -aq)

# Remove Superset images
docker rmi apache/superset:latest \
  superset-superset:latest \
  superset-superset-worker:latest \
  superset-superset-init:latest \
  superset-superset-node:latest \
  superset-superset-websocket:latest

# Remove all Superset volumes
docker volume rm superset_db_home superset_redis \
  superset_superset_data superset_superset_home

# Final clean state: only nginx, postgres:17, redis:7 remain
docker images
Result: From 14 images and 11 containers down to 3 lightweight base images. ~8GB freed in under 2 minutes.

Deploying Metabase with docker-compose

With a clean environment, Metabase was deployed as a single container pointed at the native Postgres 18 instance. The critical networking detail: Docker containers on Mac cannot reach localhost โ€” they must use host.docker.internal instead.

# docker-compose.yml
services:
  metabase:
    image: metabase/metabase:latest
    container_name: metabase
    ports:
      - "3000:3000"
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabase
      MB_DB_PORT: 5432
      MB_DB_USER: postgres
      MB_DB_PASS: postgres123
      MB_DB_HOST: host.docker.internal  # Mac bridge โ€” not localhost!
    restart: unless-stopped
# Create the Metabase app database first
psql -h localhost -U postgres -c "CREATE DATABASE metabase;"

# Launch
docker compose up -d

# Watch for "Metabase Initialization COMPLETE"
docker logs -f metabase
Metabase initialized in 8 seconds and ran 1,125 database migrations against Postgres 18 on first boot. Available at http://localhost:3000 immediately after.
Data Setup

Loading the GoExplore dataset

The GoExplore dataset consists of four CSV files covering retail sales activity across 21 countries. Rather than uploading to a cloud warehouse, all files were loaded directly into a local Postgres 18 instance via Metabase's built-in CSV upload โ€” no ETL pipeline required.

1

Docker environment cleanup

Removed a legacy Apache Superset stack (~8GB of images) and 11 orphaned containers, leaving only nginx, postgres:17, and redis as base images.

2

Metabase deployment

Single docker-compose.yml with Metabase pointing at the native Postgres 18 instance via host.docker.internal โ€” the Docker bridge to the Mac's localhost.

Metabase deployment on Docker
3

CSV upload โ†’ Postgres tables

Four CSV files uploaded via Metabase's UI. Each becomes a Postgres table automatically โ€” no schema definition needed.

CSV files uploaded via Metabase's UI
4

VS Code SQL connection

SQLTools + PostgreSQL driver installed. Connected directly to the metabase database where the go_ tables live.

Local connection in VS Code to metabase postgres
-- Four tables created from CSV upload
SELECT table_name, COUNT(*) AS rows
FROM information_schema.tables t
JOIN (
  SELECT table_name, COUNT(*) FROM go_daily_sales_20260625183936 UNION ALL
  SELECT table_name, COUNT(*) FROM go_products_20260625183647 UNION ALL
  SELECT table_name, COUNT(*) FROM go_retailers_20260625183636 UNION ALL
  SELECT table_name, COUNT(*) FROM go_methods_20260625184054
) counts ON t.table_name = counts.table_name
WHERE t.table_name LIKE 'go_%';

The four tables join on three keys:

go_daily_sales  โ†’  go_products    (via product_number)
go_daily_sales  โ†’  go_retailers   (via retailer_code)
go_daily_sales  โ†’  go_methods     (via order_method_code)
Analysis

KPIs built for the GoExplore case

Three KPIs were selected to answer the two business questions: new market potential (European expansion) and channel performance (specialty vs. general retailers).

KPI 1

Avg Order Value by Country

Baseline metric for the expansion project. Shows which existing markets generate the most revenue per order โ€” a benchmark for the 4 new target countries.

AVG(quantity ร— unit_sale_price) GROUP BY country
KPI 2

Specialty vs General Retailer

Directly answers the Head of Retailer Connections' question. Compares AOV and order volume across all 7 store types.

AVG(quantity ร— unit_sale_price) GROUP BY type
KPI 3

Avg Product Price by Category

Explains why AOV differs across channels. Golf Equipment leads โ€” specialty stores carry premium products, which drives their higher order values.

AVG(unit_price) GROUP BY product__line
Key insight: Singapore and Mexico lead in average order value. Golf Equipment is the highest-priced category by a significant margin โ€” explaining why Golf Shops show the strongest AOV among specialty retailers.
SQL

Queries behind the dashboards

All three KPIs are powered by SQL queries written in VS Code and executed directly in Metabase. No ORM, no abstraction layer โ€” plain SQL against Postgres.

KPI 1 โ€” Average Order Value by Country

SELECT
  r.country,
  ROUND(AVG(s.quantity * s.unit_sale_price), 2) AS avg_order_value,
  COUNT(*) AS total_orders
FROM go_daily_sales_20260625183936 s
JOIN go_retailers_20260625183636 r
  ON s.retailer_code = r.retailer_code
GROUP BY r.country
ORDER BY avg_order_value DESC;

KPI 2 โ€” Specialty vs General Retailer Performance

SELECT
  r.type,
  ROUND(AVG(s.quantity * s.unit_sale_price), 2) AS avg_order_value,
  COUNT(*) AS total_orders
FROM go_daily_sales_20260625183936 s
JOIN go_retailers_20260625183636 r
  ON s.retailer_code = r.retailer_code
GROUP BY r.type
ORDER BY avg_order_value DESC;

KPI 3 โ€” Average Product Price by Category

SELECT
  p.product__line,
  ROUND(AVG(p.unit_price), 2) AS avg_price
FROM go_products_20260625183647 p
GROUP BY p.product__line
ORDER BY avg_price DESC;
VS Code Environments

Four ways to query data from one editor

Following the two-world approach from the team guide, VS Code became the single development environment for querying both the local Postgres stack and the shared Google BigQuery project. Four distinct query methods were explored โ€” each with different trade-offs for speed, reproducibility, and output format.

World 1 โ€” Local

๐Ÿ—„๏ธ .sql file + SQLTools

Plain SQL file connected to local Postgres 18 via SQLTools + PostgreSQL driver. The most direct approach โ€” write SQL, press Cmd+Enter, see results instantly in the results panel below.

queries.sql โ†’ Postgres 18 โ†’ Result panel
World 1 โ€” Local

๐Ÿ““ .sqlbook notebook

Cell-based SQL notebook format inside VS Code. Each query runs in its own block with results displayed inline โ€” similar to Jupyter but pure SQL. Good for structured, step-by-step analysis.

Untitled.sqlbook โ†’ Postgres 18 โ†’ Inline results
World 2 โ€” Team

๐Ÿ .py script + BigQuery

Python scripts using google-cloud-bigquery to query the shared team dataset. Results exported to .csv or .json files programmatically โ€” useful for reproducible data exports.

query_csv.py / query_jons.py โ†’ BigQuery โ†’ CSV/JSON
World 2 โ€” Team

๐Ÿ“Š .ipynb Jupyter notebook

Jupyter notebook with Python + BigQuery client. SQL runs inside bq() helper function, results render as pandas DataFrames. Best for exploratory analysis and sharing with teammates.

test_query.ipynb โ†’ BigQuery โ†’ DataFrame

World 1 โ€” Local Postgres query (SQLTools)

-- test2.sql โ€” runs directly against local Postgres 18
-- @connection: Metabase Postgres

-- KPI 1: Average Order Value by Country
SELECT r.country,
       ROUND(AVG(s.quantity * s.unit_sale_price), 2) AS avg_order_value,
       COUNT(*) AS total_orders
FROM go_daily_sales_20260625183936 s
JOIN go_retailers_20260625183636 r ON s.retailer_code = r.retailer_code
GROUP BY r.country
ORDER BY avg_order_value DESC;
-- Result: Singapore 1,179,853 | Mexico 1,007,605 | Brazil 968,333

World 2 โ€” BigQuery via Python script (with CSV export)

# query_csv.py โ€” queries BigQuery, saves result to CSV
from google.cloud import bigquery
import csv

client = bigquery.Client(project='mystical-proj-500509')

query = """
    SELECT Country, COUNT(*) AS stores
    FROM go_explore_project.retailers
    GROUP BY Country
    ORDER BY stores DESC
    LIMIT 5
"""

rows = client.query(query).result()

with open('bigquery_result.csv', 'w', newline='', encoding='utf-8') as f:
    writer = csv.writer(f)
    writer.writerow(['Country', 'stores'])
    for row in rows:
        writer.writerow([row['Country'], row['stores']])

World 2 โ€” BigQuery via Jupyter notebook

# Cell 1 โ€” run once to set up the connection
from google.cloud import bigquery

client = bigquery.Client(project='mystical-proj-500509')

def bq(sql):
    """Run a BigQuery SQL query and return a DataFrame."""
    return client.query(sql).to_dataframe()

# Cell 2 โ€” any subsequent cell is pure SQL
bq("""
    SELECT r.Country,
           SUM(s.Quantity * s.Unit_sale_price) AS Total_Revenue
    FROM go_explore_project.daily_sales AS s
    JOIN go_explore_project.retailers AS r
        ON s.Retailer_code = r.Retailer_code
    GROUP BY r.Country
    ORDER BY Total_Revenue DESC
""")
# โ†’ Renders as a formatted pandas DataFrame table in the notebook
Key comparison: Local Postgres queries return results in ~60ms with no internet required. BigQuery queries process on Google's infrastructure and return results via the authenticated gcloud connection โ€” slower to start but handles much larger datasets and enables team collaboration on shared data.
MethodTargetBest forExport
.sql + SQLToolsLocal PostgresFast iteration, KPI queriesVia terminal / Metabase
.sqlbookLocal PostgresStructured step-by-step analysisCopy from results panel
.py scriptBigQueryAutomated exports, reproducible runsCSV / JSON directly
.ipynb notebookBigQueryExploratory analysis, team sharingpandas DataFrame / CSV
Key Learnings

What this project taught

๐Ÿณ

Docker networking on Mac

Containers can't reach Mac's localhost directly. host.docker.internal is the required bridge โ€” not documented prominently but essential.

๐Ÿงน

Clean environment first

Removing 8GB of legacy Superset images before starting saved confusion. A clean Docker state makes debugging much faster.

๐Ÿ“Š

Metabase CSV upload

CSV โ†’ Postgres in seconds via Metabase's UI. No ETL pipeline needed for small datasets โ€” a practical shortcut for rapid prototyping.

๐Ÿ”’

GDPR by architecture

When data never leaves your machine, compliance becomes structural rather than procedural. No DPA, no data transfer agreements needed.

โšก

SQL over GUI tools

Writing KPI queries in VS Code then running them in Metabase is faster and more reproducible than building charts through a visual drag-and-drop interface.

๐Ÿ’ฐ

Zero cost, full capability

The entire stack โ€” Postgres, Metabase, VS Code โ€” is free and open source. No subscription, no query costs, no vendor lock-in.

ToolRoleLicense
PostgreSQL 18 (Postgres.app)Primary data store โ€” all GoExplore tablesPostgreSQL License (free)
Docker DesktopContainer runtime for MetabaseFree for personal use
Metabase Community EditionSQL editor + KPI dashboardsAGPL (free)
VS Code + SQLToolsSQL development environmentMIT (free)
SQLTools PG DriverPostgres connection from VS CodeMIT (free)
Certificate

Completion Certificate

Docker Certificate