A lightweight, self-hosted dashboard that displays Microsoft 365 service health status and active incidents in real time. Pulls data from the Microsoft Graph API using an Azure app registration — no M365 licenses or user accounts required.
- Live service status grid for all M365 services (Exchange, Teams, SharePoint, OneDrive, etc.)
- Active incident panel with expandable update history
- Filter tabs: All / Issues Only / Exchange / Teams / SharePoint / OneDrive
- Auto-refreshes every 60 seconds with a countdown indicator
- Dark / light mode (follows system preference)
- Zero npm dependencies — pure Node.js
- A Linux server (Ubuntu 20.04+, Debian 11+, RHEL/Rocky/CentOS 8+)
- A domain name pointed at your server (for HTTPS via Let's Encrypt)
- An Azure / Microsoft 365 tenant with Global Admin access to create an app registration
Run az-setup.sh on your server. It will:
- Install Azure CLI
- Prompt you to log in via device code (requires Global Admin)
- Create an Entra ID app registration named
m365-health-dashboard - Generate a 2-year self-signed certificate
- Grant
ServiceHealth.Read.AllandServiceMessage.Read.Allpermissions - Write
/opt/m365-dashboard/config.jsonautomatically
sudo bash az-setup.shIf you prefer a client secret over a certificate, skip this step and fill in
config.jsonmanually (see below).
sudo bash setup.shThis will:
- Install Node.js 20 and nginx
- Write all app files to
/opt/m365-dashboard/ - Create a dedicated
m365dashsystem user - Register and start a systemd service (
m365-dashboard) - Optionally configure Let's Encrypt HTTPS (you'll be prompted)
- Go to Entra ID → App registrations → New registration
- Name: anything (e.g.
m365-health-dashboard) - Supported account types: Single tenant
- No redirect URI needed
- After creation, go to API permissions → Add a permission → Microsoft Graph → Application permissions
- Add both:
ServiceHealth.Read.AllServiceMessage.Read.All
- Click Grant admin consent
- Go to Certificates & secrets and create a client secret (copy it immediately)
cp config.example.json config.json
nano config.jsonFill in your tenantId, clientId, and either clientSecret or cert paths.
node server.jsThe dashboard will be available at http://localhost:3000.
config.json supports two authentication modes:
{
"tenantId": "your-tenant-id",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"port": 3000
}{
"tenantId": "your-tenant-id",
"clientId": "your-client-id",
"certPath": "/opt/m365-dashboard/certs/m365dash.crt",
"certKeyPath": "/opt/m365-dashboard/certs/m365dash.key",
"thumbprint": "CERT_SHA1_THUMBPRINT",
"port": 3000
}Never commit
config.jsonto source control. It is listed in.gitignore.
setup.sh configures nginx automatically. If you're setting it up manually, use this config:
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}nginx routes by server_name, so multiple sites on the same server won't conflict as long as each has its own server_name defined. See the nginx docs on virtual hosts.
# View live logs
journalctl -u m365-dashboard -f
# Restart the service
systemctl restart m365-dashboard
# Check status
systemctl status m365-dashboard
# Test nginx config
nginx -t
# Renew Let's Encrypt cert
certbot renew --dry-run
# Rotate the app cert (re-run az-setup.sh)
sudo bash az-setup.sh
systemctl restart m365-dashboard- Credentials are stored server-side only — never exposed to the browser
config.jsonis set to mode640(readable by the service user only)- The
m365dashsystem user has no login shell and no home directory - The systemd service runs with
NoNewPrivileges,PrivateTmp, andProtectSystem=strict - Port 3000 is only accessible via nginx on localhost — block it externally:
ufw deny 3000
MIT