Sales Analytics Dashboard

Build an interactive BI dashboard with Metabase and PostgreSQL using Docker.

Introduction

30 Second Summary

Every business runs on data. Whether it's tracking which products sell best or spotting a revenue dip before it becomes a crisis, the ability to turn raw numbers into clear visuals is one of the most in-demand skills in tech.

In this project, you will build a sales analytics dashboard using Metabase (an open-source BI tool) connected to a PostgreSQL database filled with realistic e-commerce data. You'll write SQL queries and create interactive visualizations that answer real business questions.

What You'll Build

A local sales analytics dashboard powered by Metabase and PostgreSQL, running entirely in Docker containers on your machine.

By the end of this project, you'll have:

  • A Docker Compose stack that launches your entire analytics environment (PostgreSQL database and Metabase dashboard) with a single command.
  • A SQL seed script that creates a relational sales database with customers, products, and orders spanning 12 months.
  • An interactive dashboard with bar charts, line charts, pie charts, and date filters that answer questions like "What are our top products by revenue?" and "Which regions are underperforming?"
  • Secret Mission: A Customer Lifetime Value analysis that segments customers into value tiers using advanced SQL.

Are there any prerequisites?

This project assumes you know basic SQL (SELECT, WHERE, GROUP BY). No prior experience with Docker or Metabase is needed.

Before We Start

Before We Start...

Install Docker Desktop and VS Code

Before you can build your sales analytics dashboard, you need two tools installed on your machine. VS Code is the code editor where you'll write your Docker Compose and SQL files.

Docker Desktop lets you run PostgreSQL and Metabase in containers without installing them directly on your system. Containers are lightweight, isolated environments that package an application with everything it needs to run.

In this step, get ready to:
  • Install VS Code as your code editor.
  • Install Docker Desktop to run your database and dashboard tools.
  • Verify both tools are working from the terminal.
Download and install VS Code

VS Code is a free, lightweight code editor from Microsoft. You'll use it to write your project files and run terminal commands.

  • Open your browser and go to https://code.visualstudio.com/download.
  • Click the Windows button to download the User Installer (x64).
  • Double-click the downloaded .exe file to run the installer.
  • Accept the license agreement.
  • Keep all the default settings and click Next through each screen.
  • Click Install to complete the setup.

Why the User Installer?

The User Installer does not require administrator permissions. It installs VS Code under your personal user folder, so you can install and update it without needing IT approval.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

VS Code opens to a Welcome tab. You're all set with your code editor.

VS Code not showing up in search?

If VS Code does not appear in your search results, try closing and re-opening search. Some systems need a moment to index newly installed apps.

If it still does not appear, open File Explorer and navigate to C:\Users\<YourName>\AppData\Local\Programs\Microsoft VS Code and double-click Code.exe.

Help me open VS Code on Windows after installing it.

Download and install Docker Desktop

Docker Desktop is the application that lets you run containers on your machine. It handles all the behind-the-scenes setup for running PostgreSQL and Metabase locally.

  • Open your browser and go to https://docs.docker.com/desktop/setup/install/windows-install/.
  • Click Docker Desktop for Windows - x86_64 to download the installer.
  • Double-click the downloaded Docker Desktop Installer.exe file to run it.
  • When the configuration screen appears, make sure Use WSL 2 instead of Hyper-V is checked.
  • Select the Per-user installation option (recommended, does not require admin rights).
  • Click Ok to begin the installation.

What is WSL 2?

WSL 2 (Windows Subsystem for Linux 2) is a feature that lets Windows run a lightweight Linux environment. Docker uses this to run containers efficiently on Windows without needing a full virtual machine.

If WSL 2 is not already enabled on your system, Docker Desktop will enable it on first launch. This requires a one-time admin elevation (you'll see a permissions prompt).

  • Wait for the installation to complete.
  • When prompted, restart your computer.

Save your work before restarting

Close any open documents or browser tabs before restarting. Docker Desktop needs the restart to finish configuring WSL 2.

Not seeing a restart prompt?

If the installer finishes without asking you to restart, your system likely already had WSL 2 enabled. In that case, just close the installer and continue to the next substep.

Help me figure out if I need to restart after Docker Desktop installation.

Verify Docker is running

After your computer restarts, you need to start Docker Desktop and confirm it can run containers.

  • Press the Windows key to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Wait for Docker Desktop to finish starting. The whale icon in your system tray will stop animating when it is ready (this can take 1-2 minutes on first launch).

How do I know Docker Desktop is ready?

Look at the whale icon in the bottom-right corner of your screen (the system tray). While Docker is starting, the whale animates. Once it stops and stays still, Docker is ready to accept commands.

  • If Docker Desktop shows you a subscription agreement, accept it to continue. Docker Desktop is free for personal use and education.
  • Switch to VS Code.
  • Open a terminal by clicking Terminal in the top menu bar, then clicking New Terminal.
  • Verify Docker is installed by running this command:
docker --version

✔️ I see a version number

You should see something like Docker version 28.x.x (your exact version will differ). Docker is installed and working.

ⓧ I see 'not recognized' or an error

This usually means Docker Desktop has not finished starting or your terminal was opened before Docker was ready.

  • Check that the Docker Desktop whale icon in your system tray has stopped animating.
  • Close the VS Code terminal by clicking the trash can icon on the terminal panel.
  • Open a new terminal (Terminal > New Terminal) and try the command again.

Help me troubleshoot Docker not being recognized in my terminal.

  • Verify Docker Compose is available by running this command:
docker compose version

✔️ I see a version number

You should see something like Docker Compose version v2.x.x. Docker Compose is included with Docker Desktop, so this confirms everything is set up correctly.

ⓧ I see an error

Docker Compose V2 is included with Docker Desktop. If this command fails, it usually means Docker Desktop is not fully running.

  • Make sure the Docker Desktop application is open and the whale icon has stopped animating.
  • Close and re-open your terminal, then try again.

Help me get docker compose version working.

Your development environment is ready. Next up, you'll create your project files and define the Docker Compose configuration that launches your entire analytics stack.

Create the Project and Docker Compose File

Your tools are installed and ready to go. Now it's time to build the actual project files that define your analytics stack.

In this step, you'll create a Docker Compose configuration that launches both PostgreSQL (your database) and Metabase (your dashboard tool) with a single command. You'll also write a SQL script that automatically creates your database tables and fills them with realistic sales data.

In this step, get ready to:
  • Create your project folder and Docker Compose file.
  • Define the database schema for your sales data.
  • Populate the database with realistic sample data.
Create your project folder and Docker Compose file

Docker Compose lets you define multiple services in a single file and launch them together. You need a dedicated project folder to hold this configuration.

  • Move to your Desktop by running this command in the VS Code terminal:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir sales-dashboard
cd sales-dashboard
  • Confirm the folder exists by running:
ls

You should see an empty directory with no output (the folder is new and has no files yet).

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the sales-dashboard folder, and click Select Folder.

Why open the folder in VS Code?

Opening the folder sets it as your workspace root. The terminal, file explorer, and all paths will be relative to this folder from now on.

Now you'll create the Docker Compose file that defines your two services: PostgreSQL for data storage and Metabase for visualization.

  • Open a new terminal by clicking Terminal in the top menu, then New Terminal.
  • Create the Docker Compose file by running:
New-Item -ItemType File -Name "docker-compose.yml"
  • In the VS Code Explorer sidebar on the left, click docker-compose.yml to open it.
  • Paste the following configuration into the file:
services:
  postgres:
    image: postgres:16
    container_name: sales-postgres
    hostname: postgres
    environment:
      POSTGRES_USER: analyst
      POSTGRES_PASSWORD: analyst123
      POSTGRES_DB: salesdb
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - analytics-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U analyst -d salesdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  metabase:
    image: metabase/metabase:latest
    container_name: sales-metabase
    hostname: metabase
    ports:
      - "3000:3000"
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabaseappdb
      MB_DB_PORT: 5432
      MB_DB_USER: analyst
      MB_DB_PASS: analyst123
      MB_DB_HOST: postgres
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - analytics-net

volumes:
  pgdata:

networks:
  analytics-net:
    driver: bridge

What does this configuration do?

This file defines two containers that will run together:

  • postgres runs PostgreSQL 16 with a database called salesdb. It mounts init.sql into a special directory that PostgreSQL executes on first startup.
  • metabase runs the Metabase BI tool. The MB_DB_* environment variables tell Metabase to store its own application data in the same PostgreSQL instance (in a separate database called metabaseappdb).
  • Both services share the analytics-net network, which lets Metabase connect to PostgreSQL using the hostname postgres.
  • The depends_on with condition: service_healthy ensures Metabase only starts after PostgreSQL is ready to accept connections.
  • Save docker-compose.yml by pressing Ctrl+S.

You won't see a visible change yet. You'll use this file to launch your containers in the next step.

Seeing red squiggles in the file?

YAML is sensitive to indentation. Make sure you're using spaces (not tabs) and that each indentation level is exactly 2 spaces. If you pasted the code and see errors, check that no extra spaces were added at the beginning of lines.

Help me fix my YAML formatting

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

services:
  postgres:
    image: postgres:16
    container_name: sales-postgres
    hostname: postgres
    environment:
      POSTGRES_USER: analyst
      POSTGRES_PASSWORD: analyst123
      POSTGRES_DB: salesdb
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - analytics-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U analyst -d salesdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  metabase:
    image: metabase/metabase:latest
    container_name: sales-metabase
    hostname: metabase
    ports:
      - "3000:3000"
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabaseappdb
      MB_DB_PORT: 5432
      MB_DB_USER: analyst
      MB_DB_PASS: analyst123
      MB_DB_HOST: postgres
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - analytics-net

volumes:
  pgdata:

networks:
  analytics-net:
    driver: bridge
Create the database schema

PostgreSQL automatically runs any SQL files placed in its /docker-entrypoint-initdb.d/ directory on first startup. You'll create an init.sql file that builds your entire database structure.

  • Create the SQL file by running:
New-Item -ItemType File -Name "init.sql"
  • Click init.sql in the Explorer sidebar to open it.
  • Paste the following schema code into the file:
-- Create the Metabase application database
CREATE DATABASE metabaseappdb;

-- Sales database schema
CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    region VARCHAR(30) NOT NULL,
    created_at DATE NOT NULL DEFAULT CURRENT_DATE
);

CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    category VARCHAR(50) NOT NULL,
    price DECIMAL(10, 2) NOT NULL
);

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(customer_id),
    order_date DATE NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL
);

CREATE TABLE order_items (
    item_id SERIAL PRIMARY KEY,
    order_id INTEGER REFERENCES orders(order_id),
    product_id INTEGER REFERENCES products(product_id),
    quantity INTEGER NOT NULL,
    unit_price DECIMAL(10, 2) NOT NULL
);

What does this schema do?

This creates four related tables that model an e-commerce business:

  • customers stores who is buying (with a region field for geographic analysis).
  • products stores what is being sold (with category for grouping).
  • orders tracks each purchase with a date and total amount.
  • order_items links individual products to orders (one order can have multiple items).

The REFERENCES keyword creates foreign keys, which enforce that every order belongs to a real customer and every order item references a real product.

  • Save init.sql by pressing Ctrl+S.

You won't see a change yet. Next, you'll fill these tables with sample data.

Add sample data to the database

An empty database isn't useful for building dashboards. You need realistic data to query and visualize.

  • In init.sql, place your cursor at the very end of the file (after the last ); of the order_items table).
  • Paste the following INSERT statements below the schema:
-- Insert sample customers (20 customers across 4 regions)
INSERT INTO customers (first_name, last_name, email, region, created_at) VALUES
('Alice', 'Johnson', 'alice.johnson@email.com', 'North', '2024-01-15'),
('Bob', 'Smith', 'bob.smith@email.com', 'South', '2024-02-20'),
('Carol', 'Williams', 'carol.williams@email.com', 'East', '2024-01-10'),
('David', 'Brown', 'david.brown@email.com', 'West', '2024-03-05'),
('Emma', 'Davis', 'emma.davis@email.com', 'North', '2024-04-12'),
('Frank', 'Miller', 'frank.miller@email.com', 'South', '2024-02-28'),
('Grace', 'Wilson', 'grace.wilson@email.com', 'East', '2024-05-01'),
('Henry', 'Moore', 'henry.moore@email.com', 'West', '2024-03-15'),
('Ivy', 'Taylor', 'ivy.taylor@email.com', 'North', '2024-06-20'),
('Jack', 'Anderson', 'jack.anderson@email.com', 'South', '2024-01-25'),
('Karen', 'Thomas', 'karen.thomas@email.com', 'East', '2024-07-10'),
('Leo', 'Jackson', 'leo.jackson@email.com', 'West', '2024-04-05'),
('Mia', 'White', 'mia.white@email.com', 'North', '2024-08-15'),
('Noah', 'Harris', 'noah.harris@email.com', 'South', '2024-05-22'),
('Olivia', 'Martin', 'olivia.martin@email.com', 'East', '2024-09-01'),
('Paul', 'Garcia', 'paul.garcia@email.com', 'West', '2024-06-18'),
('Quinn', 'Martinez', 'quinn.martinez@email.com', 'North', '2024-10-05'),
('Rachel', 'Robinson', 'rachel.robinson@email.com', 'South', '2024-07-30'),
('Sam', 'Clark', 'sam.clark@email.com', 'East', '2024-11-12'),
('Tina', 'Rodriguez', 'tina.rodriguez@email.com', 'West', '2024-08-25');

-- Insert sample products (15 products across 4 categories)
INSERT INTO products (product_name, category, price) VALUES
('Wireless Mouse', 'Electronics', 29.99),
('Mechanical Keyboard', 'Electronics', 89.99),
('USB-C Hub', 'Electronics', 49.99),
('Monitor Stand', 'Office', 39.99),
('Desk Lamp', 'Office', 34.99),
('Ergonomic Chair', 'Office', 299.99),
('Standing Desk', 'Office', 449.99),
('Notebook Pack', 'Stationery', 12.99),
('Pen Set', 'Stationery', 8.99),
('Planner', 'Stationery', 19.99),
('Webcam HD', 'Electronics', 69.99),
('Headphones', 'Electronics', 149.99),
('Laptop Bag', 'Accessories', 59.99),
('Phone Stand', 'Accessories', 19.99),
('Cable Organizer', 'Accessories', 14.99);

What does this data represent?

You now have 20 customers spread across four regions (North, South, East, West) and 15 products in four categories (Electronics, Office, Stationery, Accessories). This gives you enough variety to build meaningful charts that compare regions and product categories.

The customers and products are static seed data. For the orders, you'll use a procedural block that generates 120 orders with random products and quantities.

  • At the end of init.sql (after the last product INSERT), paste this code block that generates order data:
-- Insert sample orders and order items (100+ orders over 12 months)
DO $$
DECLARE
    v_order_id INTEGER;
    v_customer_id INTEGER;
    v_product_id INTEGER;
    v_quantity INTEGER;
    v_unit_price DECIMAL(10,2);
    v_order_date DATE;
    v_total DECIMAL(10,2);
    i INTEGER;
    j INTEGER;
    v_num_items INTEGER;
BEGIN
    FOR i IN 1..120 LOOP
        v_customer_id := (i % 20) + 1;
        v_order_date := DATE '2025-05-01' - (random() * 365)::INTEGER;
        v_total := 0;

        INSERT INTO orders (customer_id, order_date, total_amount)
        VALUES (v_customer_id, v_order_date, 0)
        RETURNING order_id INTO v_order_id;

        v_num_items := (random() * 3)::INTEGER + 1;

        FOR j IN 1..v_num_items LOOP
            v_product_id := (random() * 14)::INTEGER + 1;
            v_quantity := (random() * 3)::INTEGER + 1;

            SELECT price INTO v_unit_price FROM products WHERE product_id = v_product_id;

            INSERT INTO order_items (order_id, product_id, quantity, unit_price)
            VALUES (v_order_id, v_product_id, v_quantity, v_unit_price);

            v_total := v_total + (v_quantity * v_unit_price);
        END LOOP;

        UPDATE orders SET total_amount = v_total WHERE order_id = v_order_id;
    END LOOP;
END $$;

What does this DO block do?

This is a PL/pgSQL anonymous block that generates 120 orders programmatically:

  • It loops 120 times, assigning each order to a customer and a random date within the past year.
  • For each order, it adds 1 to 4 random products with random quantities.
  • It calculates the total amount for each order based on the items added.

This approach gives you realistic variation in your data. Every customer has multiple orders, and order dates are spread across 12 months so you can see meaningful trends in your dashboard.

  • Save init.sql by pressing Ctrl+S.
  • Confirm both files appear in the Explorer sidebar. You should see docker-compose.yml and init.sql listed under sales-dashboard.

You won't see any output from this SQL yet. It runs automatically when you start the PostgreSQL container in the next step.

Worried about SQL errors in the file?

You won't know if there are SQL errors until you run the containers. The most common cause of failure is a missing semicolon at the end of a statement or an extra comma after the last VALUES row.

Double-check that each INSERT ends with a semicolon and that there's no trailing comma before the semicolon.

Help me check my SQL for errors

✔️ Awesome, I've got everything!

Great. Make sure you've saved init.sql before moving on.

ⓧ I'd like to double check the full code

-- Create the Metabase application database
CREATE DATABASE metabaseappdb;

-- Sales database schema
CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    region VARCHAR(30) NOT NULL,
    created_at DATE NOT NULL DEFAULT CURRENT_DATE
);

CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    category VARCHAR(50) NOT NULL,
    price DECIMAL(10, 2) NOT NULL
);

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(customer_id),
    order_date DATE NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL
);

CREATE TABLE order_items (
    item_id SERIAL PRIMARY KEY,
    order_id INTEGER REFERENCES orders(order_id),
    product_id INTEGER REFERENCES products(product_id),
    quantity INTEGER NOT NULL,
    unit_price DECIMAL(10, 2) NOT NULL
);

-- Insert sample customers (20 customers across 4 regions)
INSERT INTO customers (first_name, last_name, email, region, created_at) VALUES
('Alice', 'Johnson', 'alice.johnson@email.com', 'North', '2024-01-15'),
('Bob', 'Smith', 'bob.smith@email.com', 'South', '2024-02-20'),
('Carol', 'Williams', 'carol.williams@email.com', 'East', '2024-01-10'),
('David', 'Brown', 'david.brown@email.com', 'West', '2024-03-05'),
('Emma', 'Davis', 'emma.davis@email.com', 'North', '2024-04-12'),
('Frank', 'Miller', 'frank.miller@email.com', 'South', '2024-02-28'),
('Grace', 'Wilson', 'grace.wilson@email.com', 'East', '2024-05-01'),
('Henry', 'Moore', 'henry.moore@email.com', 'West', '2024-03-15'),
('Ivy', 'Taylor', 'ivy.taylor@email.com', 'North', '2024-06-20'),
('Jack', 'Anderson', 'jack.anderson@email.com', 'South', '2024-01-25'),
('Karen', 'Thomas', 'karen.thomas@email.com', 'East', '2024-07-10'),
('Leo', 'Jackson', 'leo.jackson@email.com', 'West', '2024-04-05'),
('Mia', 'White', 'mia.white@email.com', 'North', '2024-08-15'),
('Noah', 'Harris', 'noah.harris@email.com', 'South', '2024-05-22'),
('Olivia', 'Martin', 'olivia.martin@email.com', 'East', '2024-09-01'),
('Paul', 'Garcia', 'paul.garcia@email.com', 'West', '2024-06-18'),
('Quinn', 'Martinez', 'quinn.martinez@email.com', 'North', '2024-10-05'),
('Rachel', 'Robinson', 'rachel.robinson@email.com', 'South', '2024-07-30'),
('Sam', 'Clark', 'sam.clark@email.com', 'East', '2024-11-12'),
('Tina', 'Rodriguez', 'tina.rodriguez@email.com', 'West', '2024-08-25');

-- Insert sample products (15 products across 4 categories)
INSERT INTO products (product_name, category, price) VALUES
('Wireless Mouse', 'Electronics', 29.99),
('Mechanical Keyboard', 'Electronics', 89.99),
('USB-C Hub', 'Electronics', 49.99),
('Monitor Stand', 'Office', 39.99),
('Desk Lamp', 'Office', 34.99),
('Ergonomic Chair', 'Office', 299.99),
('Standing Desk', 'Office', 449.99),
('Notebook Pack', 'Stationery', 12.99),
('Pen Set', 'Stationery', 8.99),
('Planner', 'Stationery', 19.99),
('Webcam HD', 'Electronics', 69.99),
('Headphones', 'Electronics', 149.99),
('Laptop Bag', 'Accessories', 59.99),
('Phone Stand', 'Accessories', 19.99),
('Cable Organizer', 'Accessories', 14.99);

-- Insert sample orders and order items (100+ orders over 12 months)
DO $$
DECLARE
    v_order_id INTEGER;
    v_customer_id INTEGER;
    v_product_id INTEGER;
    v_quantity INTEGER;
    v_unit_price DECIMAL(10,2);
    v_order_date DATE;
    v_total DECIMAL(10,2);
    i INTEGER;
    j INTEGER;
    v_num_items INTEGER;
BEGIN
    FOR i IN 1..120 LOOP
        v_customer_id := (i % 20) + 1;
        v_order_date := DATE '2025-05-01' - (random() * 365)::INTEGER;
        v_total := 0;

        INSERT INTO orders (customer_id, order_date, total_amount)
        VALUES (v_customer_id, v_order_date, 0)
        RETURNING order_id INTO v_order_id;

        v_num_items := (random() * 3)::INTEGER + 1;

        FOR j IN 1..v_num_items LOOP
            v_product_id := (random() * 14)::INTEGER + 1;
            v_quantity := (random() * 3)::INTEGER + 1;

            SELECT price INTO v_unit_price FROM products WHERE product_id = v_product_id;

            INSERT INTO order_items (order_id, product_id, quantity, unit_price)
            VALUES (v_order_id, v_product_id, v_quantity, v_unit_price);

            v_total := v_total + (v_quantity * v_unit_price);
        END LOOP;

        UPDATE orders SET total_amount = v_total WHERE order_id = v_order_id;
    END LOOP;
END $$;

Your project files are ready. Next up, you'll launch these containers and connect Metabase to your database to start building visualizations.

Launch the Stack and Connect Metabase

Your project files are ready. Now it is time to bring the analytics stack to life.

In this step, you will start both containers using Docker Compose, walk through the Metabase setup wizard, and connect Metabase to your PostgreSQL database so it can read your sales data.

In this step, get ready to:
  • Start both Docker containers and confirm they are healthy.
  • Complete the Metabase setup wizard and create an admin account.
  • Connect Metabase to your PostgreSQL database and verify all four tables are visible.
Start the containers with Docker Compose

Docker Compose reads your docker-compose.yml file and starts every service defined in it with a single command.

  • In the VS Code terminal, start both containers in detached mode by running this command:
docker compose up -d

What does this command do?

The docker compose up command builds and starts all services defined in your Compose file.

The -d flag runs the containers in detached mode, meaning they run in the background and return your terminal prompt.

Docker will pull the postgres:16 and metabase/metabase:latest images the first time you run this. This may take a few minutes depending on your internet speed.

  • Wait about 30 seconds, then check the status of your containers by running:
docker compose ps
  • Confirm that both sales-postgres and sales-metabase show a status of running.

Why does Metabase take longer to start?

Metabase is a Java application that needs to run database migrations on first launch. It takes 1-2 minutes to fully initialize. The depends_on configuration in your Compose file ensures PostgreSQL is healthy before Metabase attempts to connect.

Containers not starting?

  • Make sure Docker Desktop is running. Check your system tray for the whale icon.
  • If you see a port conflict error, another service may already be using port 3000 or 5432. Stop the conflicting service or change the port mapping in your Compose file.
  • If sales-metabase shows as restarting, wait another minute. Metabase needs time to initialize on first run.

Help me troubleshoot my Docker Compose containers not starting.

Complete the Metabase setup wizard

Metabase has a first-time setup wizard that creates your admin account and connects to your database. You will walk through it now.

  • Open your web browser and navigate to http://localhost:3000.
  • Wait for the Metabase welcome screen to appear. If you see a loading screen, give it another minute.
  • Click Let's get started.
  • Fill in your name, email, and choose a password to create your admin account.
  • Click Next to proceed.

What is this admin account?

This is your Metabase login. It controls who can add databases, create dashboards, and manage settings. For this local project, choose any credentials you will remember.

The wizard will now ask you to add your database connection details.

  • Select PostgreSQL as the database type.
  • Enter postgres in the Host field.
  • Enter 5432 in the Port field.
  • Enter salesdb in the Database name field.
  • Enter analyst in the Username field.
  • Enter analyst123 in the Password field.
  • Click Next to save the connection.

Why is the host postgres and not localhost?

Metabase runs inside a Docker container. From inside that container, localhost refers to the container itself, not your computer. The hostname postgres resolves to the PostgreSQL container because both services share the same Docker network (analytics-net).

  • On the usage data preferences screen, choose your preference and click Next.
  • Click Take me to Metabase to finish the setup wizard.

Seeing a connection error during setup?

  • Double-check that both containers are running by switching back to your VS Code terminal and running docker compose ps.
  • Make sure you typed postgres as the host, not localhost.
  • Confirm the database name is salesdb (not metabaseappdb). The metabaseappdb database stores Metabase's own data.

Help me fix a Metabase database connection error during setup.

Verify the connection and browse your data

With setup complete, you should now be on the Metabase home screen. Let's confirm Metabase can see all four tables in your sales database.

  • Click the + New button in the top-right area.
  • Select Question.
  • In the data picker, select the salesdb database.
  • Confirm you can see all four tables listed: customers, products, orders, and order_items.
  • Click on orders to open it.
  • Click Visualize to run the query and see a table of your sample data.

You should see a table with columns like order_id, customer_id, order_date, and total_amount. The data spans the past 12 months with 120 orders.

What is a question in Metabase?

In Metabase, a "question" is any query and its visualization. You can create questions using the graphical query builder (no SQL needed) or write raw SQL. In the next step, you will write SQL queries to answer specific business questions.

Not seeing your tables?

  • Metabase syncs your database schema when it first connects. If the tables are not showing yet, wait 30 seconds and refresh the page.
  • If you only see a Sample Database, you may have skipped the database setup step. Go to the gear icon in the top-right, then Admin > Databases > Add a database to add the connection manually.

Help me get my tables to show up in Metabase.

Your analytics stack is live. Metabase is connected to your PostgreSQL database and can read all four tables. Next up, you will write SQL queries to turn this raw data into business insights.

Write SQL Queries for Business Insights

Your database is loaded with 120 orders, 20 customers, and 15 products. Now it is time to turn that raw data into answers that a business team can act on.

In this step, you will write three SQL queries directly inside Metabase's SQL editor. Each query answers a specific business question using JOINs, GROUP BY, and aggregation functions.

In this step, get ready to:
  • Write a Top 10 Products by Revenue query using a JOIN and GROUP BY.
  • Write a Monthly Revenue Trend query using date functions.
  • Write a Regional Performance query calculating total and average revenue per region.
Find your top 10 products by revenue

The first business question to answer is: which products bring in the most money? To figure this out, you need to combine data from order_items (which tracks what was sold) and products (which has the product names).

  • In your browser at http://localhost:3000, click + New in the top navigation bar.
  • Select SQL query.
  • If prompted to choose a database, select salesdb.

You should now see the SQL editor with a blank query area.

  • Paste the following query into the editor:
-- Find the top 10 products by total revenue
SELECT
    p.product_name,
    SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_name
ORDER BY revenue DESC
LIMIT 10;

What does this query do?

The JOIN connects each line item to its product name. SUM(oi.quantity * oi.unit_price) calculates the total revenue generated by each product across all orders.

GROUP BY p.product_name groups all rows for the same product together so SUM can aggregate them. ORDER BY revenue DESC LIMIT 10 sorts from highest to lowest and keeps only the top 10.

  • Click the blue Run query button to execute the query. You can also press Ctrl+Enter (Windows).
  • You should see a table of 10 product names with their revenue figures, sorted from highest to lowest.
  • Click Save in the top right corner of the screen.
  • Name the question Top 10 Products by Revenue and click Save.

What is a saved question in Metabase?

In Metabase, a "question" is a query combined with its visualization. When you save a question, it becomes reusable. You can add it to dashboards, share it with teammates, or reference it in other queries.

Query returning no results?

Make sure you selected salesdb as the database (not metabaseappdb). You can check which database is selected in the dropdown above the editor.

If you see a syntax error, double-check that you copied the full query including the semicolon at the end.

Help me debug my SQL query in Metabase

Write a monthly revenue trend query

The next question to answer is: how does revenue change month over month? This helps a business spot seasonality and growth trends. You will use PostgreSQL's TO_CHAR function to extract the year and month from each order date.

  • Click + New in the top navigation bar, then select SQL query.
  • Select salesdb as the database if prompted.
  • Paste the following query into the editor:
-- Calculate total revenue per month
SELECT
    TO_CHAR(order_date, 'YYYY-MM') AS month,
    SUM(total_amount) AS monthly_revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

What does this query do?

TO_CHAR(order_date, 'YYYY-MM') converts each order date into a string like 2024-06. This groups all orders from the same month together regardless of the exact day.

SUM(total_amount) adds up revenue for each month. The ORDER BY month clause arranges results chronologically so you can see the trend over time.

  • Click the blue Run query button.
  • You should see a table with one row per month and its total revenue figure, sorted oldest to newest.
  • Click Save in the top right corner.
  • Name the question Monthly Revenue Trend and click Save.

Seeing duplicate months?

If your results show the same month appearing multiple times, check that both the SELECT and GROUP BY clauses use the exact same expression: TO_CHAR(order_date, 'YYYY-MM'). A mismatch between them will split rows incorrectly.

Help me fix my monthly revenue query

Write a regional performance query

The final business question for this step is: which regions are performing well and which are underperforming? This query needs data from two tables. The customers table has the region, and the orders table has the revenue.

  • Click + New in the top navigation bar, then select SQL query.
  • Select salesdb as the database if prompted.
  • Paste the following query into the editor:
-- Compare revenue and average order value across regions
SELECT
    c.region,
    SUM(o.total_amount) AS total_revenue,
    ROUND(AVG(o.total_amount), 2) AS avg_order_value
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.region
ORDER BY total_revenue DESC;

What does this query do?

The JOIN connects each order to the customer who placed it, pulling in the customer's region. GROUP BY c.region collapses all orders from the same region into a single row.

SUM(o.total_amount) gives total revenue per region, while ROUND(AVG(o.total_amount), 2) calculates the average order value rounded to two decimal places. Comparing both metrics reveals whether a region has high volume or high-value customers.

  • Click the blue Run query button.
  • You should see four rows (North, South, East, West) with their total revenue and average order value.
  • Click Save in the top right corner.
  • Name the question Regional Performance and click Save.

Only seeing some regions?

If fewer than four regions appear, make sure the JOIN condition uses c.customer_id = o.customer_id. A wrong join column will exclude customers who placed orders.

Help me fix my regional performance query

You now have three saved questions answering key business questions. Next up, you will turn these queries into a visual dashboard with charts and filters.

Build Your Interactive Dashboard

You have three powerful SQL queries saved as questions in Metabase. Individually, each one answers a specific business question.

A dashboard combines multiple questions into a single view so stakeholders can spot patterns across charts at a glance. In this step, you will assemble your questions into a professional dashboard with proper visualizations and an interactive date filter.

In this step, get ready to:
  • Create a dashboard and add your saved questions as visual cards.
  • Add a Total Revenue summary number card.
  • Add a Date Range filter that updates all charts interactively.
Create a new dashboard and add your saved questions

Dashboards in Metabase are pages where you arrange charts and text cards in a grid. Each saved question becomes a card that you can resize and reposition.

  • In Metabase, click + New in the top-right corner.
  • Select Dashboard.
  • Name your dashboard Sales Analytics Dashboard and click Create.
  • Metabase opens your new empty dashboard in edit mode. You should see a blank canvas with editing controls at the top.

Now add your three saved questions as cards.

  • Click the pencil icon if you are not already in edit mode.
  • Click anywhere on the dashboard canvas or use the + button to add a card.
  • Search for and select your Top 10 Products by Revenue question.
  • Repeat the process to add Monthly Revenue Trend and Regional Performance.

You should now see three cards on your dashboard canvas, each displaying data from your saved queries.

Next, set the right visualization type for each card.

  • Hover over the Top 10 Products by Revenue card and click its pencil icon to edit the card's visualization.
  • Change the visualization type to Bar chart if it is not already displayed as one.
  • Hover over the Monthly Revenue Trend card, click its pencil icon, and set it to a Line chart.
  • Hover over the Regional Performance card, click its pencil icon, and set it to a Pie chart.

Why match chart types to data?

Bar charts are ideal for comparing discrete categories (like product names). Line charts reveal trends over time. Pie charts show proportional breakdowns of a whole (like revenue split by region).

Choosing the right chart type makes patterns immediately visible to anyone viewing the dashboard.

Click Save at the top-right of the dashboard to save your progress so far.

Don't see your saved questions when adding cards?

Make sure you saved each question in the previous step. If a question was run but not saved, it will not appear when searching for cards to add.

Also check that you are searching by the exact name you gave each question (e.g. Top 10 Products by Revenue).

Help me find my saved questions in Metabase.

Add a Total Revenue summary number

A single headline number at the top of a dashboard gives instant context. Stakeholders can see total revenue before diving into the breakdown charts below.

  • Click the pencil icon to enter edit mode on your dashboard.
  • Click + New in the top navigation, then select SQL query.
  • Make sure salesdb is selected as the database.
  • Write the Total Revenue query by entering the following SQL:
SELECT SUM(total_amount) FROM orders;

What does this query do?

This sums every total_amount value in the orders table to produce a single number representing all-time revenue across all orders.

  • Click the blue Run button (or press Ctrl+Enter) to execute the query.
  • You should see a single number result representing your total revenue.
  • Click Visualization and select the Number display type to show it as a large headline number.
  • Click Save in the top-right corner.
  • Name it Total Revenue and save it to your collection.
  • When prompted, choose to add it to your Sales Analytics Dashboard.

Now arrange your dashboard layout.

  • Click the pencil icon on your dashboard to enter edit mode.
  • Drag the Total Revenue number card to the top of the dashboard.
  • Drag the Monthly Revenue Trend line chart below the Total Revenue card.
  • Drag Top 10 Products by Revenue and Regional Performance side-by-side underneath the trend line.
  • Click Save to preserve your layout.

Your dashboard should now show the summary number at the top, the trend line in the middle, and the two breakdown charts side-by-side at the bottom.

Cards overlapping or too small?

In edit mode, you can resize cards by dragging their edges. Make sure you are in edit mode (pencil icon) before trying to move or resize cards.

If the layout looks cramped, try making the Monthly Revenue Trend card span the full width of the dashboard.

Help me arrange my Metabase dashboard cards.

Add a Date Range filter to make your dashboard interactive

A static dashboard shows all-time data. An interactive dashboard lets viewers slice the data by different time periods without editing any queries.

Dashboard filters in Metabase work by connecting a filter widget to columns in your cards. When a viewer selects a date range, every connected card updates automatically.

  • Click the pencil icon to enter edit mode on your dashboard.
  • Click the filter icon in the top toolbar to add a filter widget.
  • Select Date picker as the filter type.
  • Choose Date Range as the operator.
  • Metabase will ask you to connect the filter to each card. For each card that has an order_date column, select that column as the connection.
  • Click Done once you have connected the filter to your cards.
  • Click Save to save the dashboard with the filter.

What is a dashboard filter?

A dashboard filter adds a widget at the top of your dashboard. Viewers can select values (like a date range) and every connected card re-queries the database with that constraint applied.

This is how real business intelligence dashboards work. Stakeholders explore data interactively without writing SQL themselves.

Now test your filter to make sure it works.

  • Click the Date Range filter widget at the top of your dashboard.
  • Select a date range (for example, the last 3 months).
  • Watch all connected charts update to show only data within that range.
  • Try selecting a different range to confirm the charts respond.
  • Click the blue X on the filter to clear it and return to the all-time view.

All four charts should update when you apply a date range. The Total Revenue number changes, the trend line shortens, and the breakdowns adjust to reflect only the selected period.

Not all cards may connect to the filter

If a card's underlying SQL query does not reference a date column that Metabase can map, the filter may not connect to it. For this project, your Monthly Revenue Trend and Total Revenue queries reference order_date directly, so they should connect. The other queries may connect through their JOINs to the orders table.

Filter not updating your charts?

For SQL-based questions, Metabase may require a field filter variable in the query's WHERE clause to connect with dashboard filters. If a card is not responding to the filter, re-open the question, add a WHERE clause with a Metabase variable, and re-save it.

Alternatively, try selecting All Options instead of Date Range when choosing the filter operator. This gives Metabase more flexibility in connecting to your queries.

Help me connect a dashboard filter to my SQL questions.

Your interactive sales analytics dashboard is complete. You have a professional layout with a headline metric, trend analysis, and categorical breakdowns that stakeholders can filter by date. Next up, take on the Secret Mission to add a Customer Lifetime Value analysis.

Secret mission

Customer Lifetime Value Analysis

Your dashboard answers questions about products and regions. But which customers are your most valuable? Build a Customer Lifetime Value (CLV) analysis that segments customers into High, Medium, and Low value tiers using advanced SQL techniques like CTEs and window functions.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. The containers do use disk space while running.

Resources you used:

  • Docker container: sales-postgres (PostgreSQL database with your sales data).
  • Docker container: sales-metabase (Metabase BI dashboard with your saved questions and dashboard).
  • Docker volume: pgdata (stores your database data persistently between restarts).
  • Local project files: docker-compose.yml and init.sql in your project folder on the Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep exploring your dashboard.

  • Leave Docker Desktop running in the background.
  • Your dashboard remains accessible at http://localhost:3000 anytime Docker Desktop is running.
  • Your saved questions, dashboard layout, and database data all persist automatically.

Pause - I'll come back to this later

Stop the containers to free up memory and CPU, but keep all your data and dashboard intact for later.

  • In your VS Code terminal, make sure you are in the folder containing your docker-compose.yml file.
  • Stop the containers by running:
docker compose stop
  • When you want to resume, open a terminal in the same folder and run:
docker compose start
  • Your dashboard, saved questions, and all database data will be exactly as you left them.

Delete - I don't want to use this again

Remove all containers, volumes, and project files to free up disk space completely.

  • In your VS Code terminal, make sure you are in the folder containing your docker-compose.yml file.
  • Remove the containers, network, and volume by running:
docker compose down -v

What does this command do?

The -v flag tells Docker to also remove the named volumes (pgdata). Without it, your database data would persist even after the containers are gone.

  • Navigate up to your Desktop by running:
cd ~/Desktop
  • Delete the project folder by running:

macOS

rm -rf sales-analytics

Windows

rmdir /s /q sales-analytics
  • This deletes your docker-compose.yml, init.sql, your Metabase dashboard, and all database data. This action cannot be undone.

That's a Wrap!

That's a Wrap!

Nice work! You just built a professional-grade sales analytics dashboard powered by PostgreSQL and Metabase, all running locally through Docker Compose.

This is the exact workflow data analysts use daily to turn raw data into visual business insights.

You've learned how to:

  • Set up a containerized analytics stack with Docker Compose, launching PostgreSQL and Metabase from a single configuration file.
  • Written SQL queries using JOINs, GROUP BY, aggregations, and date functions to extract business insights from relational data.
  • Built an interactive dashboard with bar, line, and pie chart visualizations plus date range filters in Metabase.
  • Built a Customer Lifetime Value analysis using CTEs and window functions to segment customers into value tiers.

Ready to quiz yourself?