Deployment

The app ships as either a published .NET 9 binary (Windows or Linux) or a Docker image. Pick one path.

Prerequisites

  • A SQL Server instance for the monitoring database (the app's own metadata store). 2017 or newer. SQL Express works. Recommended: dedicated database named SqlServerHealthMonitor.
  • A service account that can CREATE DATABASE (first run only) or an empty pre-created database with full read/write rights.
  • For each monitored SQL Server: a low-privilege account with VIEW SERVER STATE
    • VIEW DATABASE STATE + SELECT on msdb.dbo.sysjobs* if you want Agent Jobs visibility.
    • SQL Server 2022 and newer split those permissions. If the instance is 2022+, grant VIEW SERVER PERFORMANCE STATE and VIEW DATABASE PERFORMANCE STATE instead — VIEW SERVER STATE alone leaves the index scans failing with "The VIEW SERVER PERFORMANCE STATE permission was denied" (error 300).
    • The account also needs a user in every database you want scanned. Without one, USE [db] fails immediately (error 916) and the database is reported as skipped on /performance/index-health.
    • Reading the error log (/error-log) needs two grants that VIEW SERVER STATE does not include, plus a user in master — the procedures live there. See the second block below. Without them that one page reports what to grant; nothing else is affected.
    • OS metrics (host CPU, memory and volumes, shown on /storage) are read over WMI from the Windows host, not over the SQL connection. They therefore need a Windows permission rather than a SQL grant — see "OS metrics over WMI" below. Without it only that one card stays empty.
-- On a monitored SQL Server 2022+ instance
GRANT VIEW SERVER PERFORMANCE STATE TO [monitoring_login];
GRANT VIEW ANY DEFINITION TO [monitoring_login];

-- Per database that should be scanned
USE [YourDatabase];
CREATE USER [monitoring_login] FOR LOGIN [monitoring_login];
GRANT VIEW DATABASE PERFORMANCE STATE TO [monitoring_login];  -- 2022+
-- GRANT VIEW DATABASE STATE TO [monitoring_login];           -- 2019 and older

Error log access (/error-log) is separate, because sys.xp_readerrorlog and sys.xp_enumerrorlogs live in master and are not covered by any VIEW ... STATE permission. The login needs a user in master for the grants to have somewhere to land — that is the step most setups miss:

USE master;
CREATE USER [monitoring_login] FOR LOGIN [monitoring_login];
GRANT EXECUTE ON sys.xp_readerrorlog  TO [monitoring_login];   -- read the log
GRANT EXECUTE ON sys.xp_enumerrorlogs TO [monitoring_login];   -- list the archives

securityadmin membership also works and is what most documentation suggests, but it can create logins and reset passwords — considerably more than a monitoring account should hold. The sp_readerrorlog / sp_enumerrorlogs wrappers are the reason that advice exists: they carry an internal securityadmin check which no grant satisfies. The monitor calls the extended procedures underneath them precisely to avoid needing the role.

OS metrics over WMI

The host card on /storage answers the question the DMVs cannot: what else is using this machine. SQL Server reports its own CPU and its own memory, so a backup agent, a virus scan or a second instance shows up there only as waiting — never as a cause.

It is still agentless: nothing is installed on the monitored machine, the monitor asks WMI the way Performance Monitor does. The query runs as the monitor's own service account, and there are no credentials to store anywhere. The cost of that choice is that the account has to be known on the monitored host — a host in another domain or in a workgroup answers "access denied", which the log names as the reason.

On each monitored Windows host, for the monitor's service account:

  • Membership in Performance Monitor Users (and, for remote DCOM, Distributed COM Users).
  • Remote Enable on the root\cimv2 WMI namespace (wmimgmt.msc → WMI Control → Properties → Security).
  • The inbound firewall rule Windows Management Instrumentation (WMI-In) enabled.

Not available, by design, in two situations: Azure SQL Database and Managed Instance have no host of ours to read (the monitor already knows this and skips them), and a monitor running in the Linux container cannot use WMI at all — the card then says so rather than showing zeros. Collection can be switched off entirely with OsMetrics:Enabled = false; retention and the per-host timeout sit in the same section (docs for the defaults: 30 days, 10 seconds).

Windows — bare metal / VM

# As the service account
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Apps\SqlServerHealthMonitor

# First-run config
cd C:\Apps\SqlServerHealthMonitor
notepad appsettings.json
# Set ConnectionStrings:DefaultConnection to your monitoring DB
# Verify SecuritySettings:RequireHttps and SecuritySettings:HttpsPort

# Run interactively to verify
.\SqlServerHealthMonitor.exe

If it starts and logs Now listening on: https://[::]:8443, you're good. Visit https://localhost:8443 (accept the self-signed warning the first time).

As a Windows service

The MSI installer (docs/installer.md) performs all of the steps below for you and is the recommended path. Use the manual commands here only if you prefer to script the deployment yourself.

# Stop the interactive run, then:
sc.exe create "SqlServerHealthMonitor" `
    binPath= "C:\Apps\SqlServerHealthMonitor\SqlServerHealthMonitor.exe" `
    start= auto `
    DisplayName= "SQL Server Health Monitor"
sc.exe description "SqlServerHealthMonitor" "Monitors SQL Server fleet health and alerts on degradations."
sc.exe start "SqlServerHealthMonitor"

The service runs as LocalSystem by default. To run under a domain account (recommended for monitoring DB access via Integrated Security):

sc.exe config "SqlServerHealthMonitor" obj= "DOMAIN\monitor-svc" password= "..."
sc.exe stop "SqlServerHealthMonitor"
sc.exe start "SqlServerHealthMonitor"

Linux — systemd

sudo dotnet publish -c Release -r linux-x64 --self-contained false -o /opt/sshm
sudo useradd --system --no-create-home sshm
sudo chown -R sshm:sshm /opt/sshm

sudo tee /etc/systemd/system/sshm.service <<'EOF'
[Unit]
Description=SQL Server Health Monitor
After=network.target

[Service]
WorkingDirectory=/opt/sshm
ExecStart=/usr/bin/dotnet /opt/sshm/SqlServerHealthMonitor.dll
User=sshm
Restart=on-failure
RestartSec=10
Environment=ASPNETCORE_ENVIRONMENT=Production
SyslogIdentifier=sshm

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now sshm
sudo journalctl -u sshm -f

Docker

docker run -d --name sshm \
    -p 8443:8443 \
    -v sshm-keys:/root/.local/share/SqlServerHealthMonitor \
    -e ASPNETCORE_ENVIRONMENT=Production \
    -e ConnectionStrings__DefaultConnection="Server=db;Database=SqlServerHealthMonitor;User Id=sshm;Password=...;TrustServerCertificate=true" \
    sqlserverhealthmonitor:latest

Recommended: keep the -v sshm-keys:/root/.local/share/SqlServerHealthMonitor volume mount. Without it the DataProtection keys regenerate on every container restart, which signs every user out and re-issues the self-signed certificate. Your encrypted columns are not affected — they are protected with the fixed Encryption:Key, not with the key ring. See backup-and-restore.

Behind a reverse proxy (Traefik / nginx)

When TLS is terminated by the proxy, disable the app's HTTPS binding:

// appsettings.Production.json
{
  "SecuritySettings": {
    "RequireHttps": false,
    "BindHttps": false
  }
}

…and have the proxy talk plain HTTP to the container on port 8080.

HTTPS and certificates

Default behavior: on first start, a self-signed RSA-2048 cert is generated and saved to %LOCALAPPDATA%\SqlServerHealthMonitor\Certs\sshm-server.pfx (Windows) or ~/.local/share/SqlServerHealthMonitor/Certs/sshm-server.pfx (Linux). The startup log shows the exact path with a "REPLACE FOR PRODUCTION" warning.

To use your own cert (CA-issued or internal CA):

{
  "SecuritySettings": {
    "BindHttps": true,
    "HttpsPort": 8443,
    "CertificatePath": "C:\\certs\\sshm.pfx",
    "CertificatePassword": "..."
  }
}

To regenerate the self-signed cert (e.g. after the hostname changed): delete the .pfx and restart. A new one is generated automatically with current SANs.

Initial admin account

On first start the Login page detects that no admin exists yet and redirects to /Account/Setup. Pick a strong username + password — these become the first admin who can then create other users at /Settings/Users.

If appsettings.json:InitialAdmin:Password is set, that's used instead of the setup flow (a warning is logged if it looks weak or is a known placeholder). If InitialAdmin exists but no password is set, a strong one is generated and written to INITIAL_ADMIN_PASSWORD.txt in the app's content root — the password is deliberately not written to the application log. Read that file, sign in, change the password, then delete the file. (If the file can't be written, the password is logged as a fallback.)

After login: enable 2FA at /Account/Security. Recommended for every admin.

Secrets & secure configuration

Keep secrets out of appsettings.json (it's in source control). Any configuration value can be supplied via an environment variable using __ (double underscore) for nesting — these override the JSON files automatically:

Secret Environment variable
Database connection ConnectionStrings__DefaultConnection
Monitoring database ConnectionStrings__MonitoringDatabase
Initial admin password InitialAdmin__Password
OIDC client secret OidcSettings__ClientSecret
License public key Licensing__PublicKeyBase64
# Docker
docker run -e ConnectionStrings__DefaultConnection="Server=db;Database=SQLSpa;User Id=svc;Password=…;TrustServerCertificate=true" …

# systemd unit
Environment=ConnectionStrings__DefaultConnection=Server=db;Database=SQLSpa;…

Notes:

  • Production guard: the app refuses to start if ConnectionStrings:DefaultConnection is empty in the Production environment — set it before first run.
  • Local development: use dotnet user-secrets (the project already has a UserSecretsId) instead of editing appsettings.Development.json.
  • Notification channel secrets (Email/Teams/PagerDuty) are stored encrypted in the database, not in config. Configure them under Settings → Notifications. Any values in appsettings are only used to seed the DB once on first run.
  • Encryption:Key is what encrypts the stored connection strings and notification secrets (AES-256-GCM). Leave it unset to use the built-in default, or set your own 32-byte base64 key — and then back that key up with the database, because nothing can decrypt those columns without it (see backup-and-restore.md).
  • DataProtection keys cover the framework key ring only: auth cookies, antiforgery tokens, the self-signed certificate. Losing that folder costs a re-login, not data.