Photo by Joshua Hoehne on Unsplash

As someone passionate about data visualization, I decided to work on my interest into a small home-lab project: building a COVID-19 data visualization dashboard. The goal? To create a real-time, visually engaging display of Malaysia’s COVID-19 statistics using open data from the Ministry of Health Malaysia (MOH).

Architecture

Component Role
Grafana Front-end visualization dashboard
MySQL Stores structured COVID-19 datasets
NGINX Web server/reverse proxy for Grafana
Bash + Cron Fetches and ingests latest GitHub data

Data Source: MOH Malaysia GitHub

MOH Malaysia has done an excellent job publishing detailed COVID-19 data in CSV format on their [GitHub repository](https://github.com/MoH-Malaysia/covid19-public). It includes daily case numbers, hospital utilization, deaths, and vaccination stats—granular data broken down by state.

#List of CSV Files Used:

cases_malaysia.csv
cases_state.csv
vax_malaysia.csv
vax_state.csv
hospital.csv
icu.csv
deaths.csv
deaths_state.csv
population.csv

These files are updated regularly and are perfect for automation.

MySQL Database Setup

To store the data efficiently and make it queryable for Grafana, I created several MySQL tables mirroring the structure of the CSV files.

#Table Schema for cases in Malaysia: my_covid_cases

CREATE TABLE my_covid_cases(
id INT NOT NULL AUTO_INCREMENT,
date DATETIME NOT NULL,
cases_new INT NOT NULL,
cases_import INT NOT NULL,
cases_recovered INT NOT NULL,
cases_active INT NOT NULL,
cases_cluster INT NOT NULL,
cases_unvax INT NOT NULL,
cases_pvax INT NOT NULL,
cases_fvax INT NOT NULL,
cases_boost INT NOT NULL,
cases_child INT NOT NULL,
cases_adolescent INT NOT NULL,
cases_adult INT NOT NULL,
cases_elderly INT NOT NULL,
cases_0_4 INT NOT NULL,
cases_5_11 INT NOT NULL,
cases_12_17 INT NOT NULL,
cases_18_29 INT NOT NULL,
cases_30_39 INT NOT NULL,
cases_40_49 INT NOT NULL,
cases_50_59 INT NOT NULL,
cases_60_69 INT NOT NULL,
cases_70_79 INT NOT NULL,
cases_80 INT NOT NULL,
cluster_import INT NULL,
cluster_religious INT NULL,
cluster_community INT NULL,
cluster_highRisk INT NULL,
cluster_education INT NULL,
cluster_detentionCentre INT NULL,
cluster_workplace INT NULL,
c_time DATETIME NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);

A Bash script parses the CSV file and inserts only the latest data into MySQL. I run this via a cron job once a day to keep the data fresh.

Automating Data Extraction

A shell script automates the process:

#!/bin/bash

rootdir="/opt/covid19_MY"
covid_moh_git="https://raw.githubusercontent.com/MoH-Malaysia/covid19-public/main/epidemic/cases_malaysia.csv"
my_covid_file="cases_malaysia.csv"
wget=$(which wget)
db_name="covid19_MY"
table_name="my_covid_cases"
db_user="gadmin"
datafile=/var/lib/mysql-files/cases_malaysia_load.csv
countfile="my_cnt.txt"
extra=/etc/mysql_grafana.cnf

# Get record count
function getcount() {
cd $rootdir/log
mysql --defaults-extra-file=/etc/mysql_grafana.cnf -N << EOF > $countfile
use covid19_MY
select count(*) from my_covid_cases
EOF
echo "1.Current Record Count: $(cat $countfile)"
}

# Cleanup
function cleanup() {
cd $rootdir/data
if [[ -f $my_covid_file ]]; then
rm -f $my_covid_file
echo "2. Remove existing datafile - Completed"
fi
}

# Get the file
function get_file() {
$wget $covid_moh_git 2>/dev/null
echo "3. Download latest datafile from MoH Git Hub - Completed"
}

# Prepare to update new data
function new_data {
cd $rootdir/log
rcount=`cat $countfile`
if [[ $rcount -gt 0 ]]; then
rcount2=(`expr $rcount + 1`)
rcount3="2,$rcount2"
sed "$rcount3"'d' $rootdir/data/$my_covid_file > $datafile
echo "4. Updated Record Count: $(wc -l < $countfile)"
else
cd $rootdir/data
echo "No Data"
cat $my_covid_file > $datafile
fi
}

# Load into database
function load_db() {
mysql --defaults-extra-file=$extra -e "use $db_name" -e "
LOAD DATA INFILE '$datafile'
INTO TABLE $table_name
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '\"'
IGNORE 1 LINES
(date,cases_new,cases_import,cases_recovered,cases_active,@cases_cluster,cases_unvax,@cases_pvax,@cases_fvax,cases_boost,@cases_child,@cases_adolescent,@cases_adult,@cases_elderly,cases_0_4,cases_5_11,cases_12_17,cases_18_29,cases_30_39,cases_40_49,cases_50_59,cases_60_69,cases_70_79,cases_80,@cluster_import,@cluster_religious,@cluster_community,@cluster_highRisk,@cluster_education,@cluster_detentionCentre,@cluster_workplace)
set
cases_cluster = NULLIF(@cases_cluster,''),
cases_pvax = NULLIF(@cases_pvax,''),
cases_fvax = NULLIF(@cases_fvax,''),
cases_child = NULLIF(@cases_child,''),
cases_adolescent = NULLIF(@cases_adolescent,''),
cases_adult = NULLIF(@cases_adult,''),
cases_elderly = NULLIF(@cases_elderly,''),
cluster_import = NULLIF(@cluster_import,''),
cluster_religious = NULLIF(@cluster_religious,''),
cluster_community = NULLIF(@cluster_community,''),
cluster_highRisk = NULLIF(@cluster_highRisk,''),
cluster_education = NULLIF(@cluster_education,''),
cluster_detentionCentre = NULLIF(@cluster_detentionCentre,''),
cluster_workplace = NULLIF(@cluster_workplace,''),
c_time = now();"
echo "5. Update New Record/s to Database - Completed"
}

# MAIN #
getcount
cleanup
get_file
new_data
load_db

Then scheduled the execution with a cron job:

0 6 * * * /opt/covid19_MY/cases.sh

Grafana Visualization

Grafana connects seamlessly to MySQL using the built-in data source plugin. Once connected, I created multiple dashboards to track:

Grafana’s visualization tools make it easy to apply transformations, rolling averages, thresholds, and annotations.

Serving with NGINX

To make the dashboard accessible from my home network, I used NGINX as a reverse proxy in front of Grafana. Here's a basic server block:

server {
  listen 80;
  listen [::]:80 ipv6only=on;
  server_name sn.local;
  root /usr/share/nginx/html;
  index index.html index.htm;

  location /grafana/ {
    proxy_pass http://localhost:3000/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

This lets me access the dashboard via `http://sn.local/grafana` from any device in the network.

Security Considerations

Since this is a home lab project, access is restricted to my local network. However, for those exposing this externally, I strongly recommend:

Using HTTPS
Enabling Grafana authentication
Regular database backups
Rate limiting with NGINX

This project has been a great learning experience—working with real-world data, automating ETL (extract-transform-load) pipelines, and building visual dashboards all within a self-hosted environment. It also keeps me informed with local COVID-19 trends in a way that’s both interactive and informative.

If you’re into data or home labs, I highly recommend trying something similar. With open data and tools like Grafana, the possibilities are endless.


TechE2E Editorial Team

We are a bunch of new and seasoned technologists, brought together by a shared curiosity for how technology shapes the world around us. From fresh perspectives to battle-tested experience, our voices reflect the full spectrum of the tech journey. Through this blog, we aim to break down complex ideas, share real-world insights, and spark meaningful conversations—whether you're just starting out or have been in the field for years.

All author posts

Engage with us

Contribute

Are you a technologist, architect, or industry expert? Share your real-world experiences, lessons learned, and innovations with a wider tech community.

Content Syndication

If you have high-quality content and would like to syndicate it to a wider audience, TechE2E supports content syndication opportunities that align with our editorial standards and end-to-end technology focus.

Advertise

Showcase your solutions to a targeted audience of tech professionals across ASEAN and beyond. Partner with us to amplify your brand and thought leadership.

Get in touch

Privacy Policy | Copyright 2025 TechE2E

Privacy Preference Center