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 STATEVIEW DATABASE STATE+SELECTonmsdb.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 STATEandVIEW DATABASE PERFORMANCE STATEinstead —VIEW SERVER STATEalone 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 thatVIEW SERVER STATEdoes not include, plus a user inmaster— 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\cimv2WMI 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:DefaultConnectionis empty in theProductionenvironment — set it before first run. - Local development: use
dotnet user-secrets(the project already has a UserSecretsId) instead of editingappsettings.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:Keyis 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 (seebackup-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.