File-based service discovery for Prometheus, on autopilot.
A single self-contained executable that polls your services and continuously writes Prometheus file_sd_configs target files — for blackbox health checks, metrics scraping, OpenTelemetry, and DNS probing.
[
{
"targets": [
"https://app.townsuite.com/healthz/live",
"https://app.townsuite.com/healthz/ready"
],
"labels": {
"job": "app",
"env": "prod"
}
}
]
How it works
Output files
Build a single executable
Publish a self-contained, ready-to-run binary for your target platform. The result is one file — TownSuite.Prometheus.FileSdConfigs — that you drop onto the host.
dotnet publish -c Release -r osx-x64 \ -p:PublishReadyToRun=true \ --self-contained true \ -p:PublishSingleFile=true \ -p:EnableCompressionInSingleFile=true
dotnet publish -c Release -r linux-x64 \ -p:PublishReadyToRun=true \ --self-contained true \ -p:PublishSingleFile=true \ -p:EnableCompressionInSingleFile=true
dotnet publish -c Release -r win-x64 ` -p:PublishReadyToRun=true ` --self-contained true ` -p:PublishSingleFile=true ` -p:EnableCompressionInSingleFile=true
-r) — see the .NET RID catalog for the full list.appsettings.json
Drop an appsettings.json beside the executable. Define global behaviour under AppSettings, then list your sources under Settings or SettingsV2. The JSON your lookup endpoints must return is covered in Source JSON.
{
"AppSettings": {
"DelayInSeconds": 1800,
"UserAgent": "TownSuiteSD/1.0 ...",
"OutputPath": "targets.json",
"OutputPathV2": "targets_v2.json",
"OutputPathPrometheusMetrics": "targets_prometheus_metrics.json",
"OutputPathOpenTelemetry": "targets_open_telemetry_metrics.json",
"OutputPathDns": "targets_dns.json",
"HttpTimeoutInSeconds": 10,
"SkipCertificateValidation": false,
"MaxStaleCycles": 0
},
"Settings": [
{
"LookupUrl": "http host/path or filepath",
"AuthHeader": "basic auth or bearer token",
"AppendPaths": [ "/metrics", "/api/status" ],
"Labels": { "job": "test", "env": "dev" },
"IgnoreList": [ "https://example.townsuite.com" ]
}
],
"SettingsV2": [
{
"ServiceListUrl": "url/path that lists services",
"ServiceDiscoverUrl": "url/path for service details",
"AuthHeader": "basic auth or bearer token",
"IgnoreList": [ "https://example.townsuite.com" ],
"LowercaseLabels": true
}
]
}
AppSettings reference
DelayInSeconds
1800
UserAgent
string
OutputPath
targets.json
OutputPathV2
targets_v2.json
OutputPathPrometheusMetrics
targets_..._metrics.json
OutputPathOpenTelemetry
targets_..._otel.json
OutputPathDns
targets_dns.json
HttpTimeoutInSeconds
10
SkipCertificateValidation
false
MaxStaleCycles
0
When the configuration is wrong
Each output is independent — a path left blank disables just that one and the rest still run. This is measured behaviour, and it applies to any of the output-path settings.
Key omitted, null or blankA number, e.g. 12345An object or arrayMissing or unwritable directorySettingsV2 section missingSettingsV2 empty arrayNull entry, or no ServiceListUrlHttpTimeoutInSeconds omitted or 0Point at a list of hosts and append metric paths. Best for static, known endpoints.
LookupUrl
url or filepath listing base hosts
AuthHeader
sent as the Authorization header
AppendPaths
paths appended to every host
Labels
static labels for each target
IgnoreList
URLs to skip (exact match)
Resolves a service list, then discovers details per service. Best for fleets that change.
ServiceListUrl
url or filepath listing service keys
ServiceDiscoverUrl
per-service detail lookup, key appended
AuthHeader
sent as the Authorization header
IgnoreList
URLs to skip (exact or prefix match)
LowercaseLabels
trim and lowercase every label
What your endpoints return
This is the JSON your own inventory service returns — not appsettings.json. Property names are matched case-insensitively, so BaseUrl, baseUrl and baseurl are all accepted.
1 · ServiceListUrl → the service keys
[ "Service1.Example", "Service2.Example", "Payments.Example" ]
. and only the first segment is used, so Service1.Example becomes the key Service1. That key is appended directly to ServiceDiscoverUrl — which is why that setting normally ends in = or /. A ServiceListUrl that does not start with http is read from disk instead, in the same array format.2 · ServiceDiscoverUrl{key} → the instances
{
"version": "1",
"services": [
{
"name": "Service 1 — primary",
"id": "3f6b2c1e-9a44-4f2f-9c3f-6e5b1c8d0a11",
"labels": {
"env": "prod",
"job": "service1"
},
"attributes": {
"BaseUrl": "https://service1.example.townsuite.com",
"HealthCheck": "/healthz/ready",
"PrometheusMetricsUrl": "/metrics",
"DataCenter": "yyz1"
}
},
{
"name": "Service 1 — secondary",
"id": "9c0a7b55-1d2e-4a88-b0a1-7c2f9e3d4b55",
"labels": { "env": "prod", "job": "service1" },
"attributes": {
"BaseUrl": "https://service1b.example.townsuite.com",
"HealthCheck": "/healthz/ready"
}
}
]
}
Each instance becomes one target group in the output file. An instance without a BaseUrl attribute is skipped.
Copied verbatim onto the Prometheus target. Use them for job, env and anything else you group or alert by.
Recognized attributes
BaseUrl
all outputs
HealthCheck
targets_v2.json
HealthCheck_<suffix>
targets_v2.json
ExtraHealthCheck
targets_v2.json
ExtraHealthCheck_Prefix
targets_v2.json
PrometheusMetricsUrl
targets_prometheus_metrics.json
OpenTelemetryUrl
targets_open_telemetry_metrics.json
attributes is free-form — unrecognized keys such as DataCenter are ignored, so you can return whatever else your inventory tracks. Slashes are normalized when a path is joined to BaseUrl, so https://host/ + /healthz and https://host + healthz both give https://host/healthz.Several health endpoints for one site
Because attributes is a JSON object its keys must be unique — you cannot repeat HealthCheck. There are three ways to list more than one endpoint for a single instance, and they can be combined. All resulting URLs are collected into that instance's single targets array, sorted and de-duplicated.
A fixed list, spelled out in the inventory. Add any suffix after HealthCheck_ — the suffix is a label for you, the tool only matches the prefix and accepts any number of them.
{
"labels": { "Env": "prod", "Job": "service1" },
"attributes": {
"BaseUrl": "https://service1.example.townsuite.com",
"HealthCheck": "/healthz/ready",
"HealthCheck_live": "/healthz/live",
"HealthCheck_database": "/healthz/database",
"HealthCheck_queue": "/healthz/queue"
}
}
[
{
"targets": [
"https://service1.example.townsuite.com/healthz/database",
"https://service1.example.townsuite.com/healthz/live",
"https://service1.example.townsuite.com/healthz/queue",
"https://service1.example.townsuite.com/healthz/ready"
],
"labels": { "Env": "prod", "Job": "service1" }
}
]
A list the site returns itself — use this when the site knows its own endpoints and you would rather not update the inventory every time one is added. The value is a path on the site or an absolute URL.
{
"BaseUrl": "https://service1.example.townsuite.com",
"HealthCheck": "/healthz/ready",
"ExtraHealthCheck": "/healthz/extralookups"
}
["hello/world", "world/hello"]
[
{
"targets": [
"https://service1.example.townsuite.com/healthz/ready",
"https://service1.example.townsuite.com/hello/world",
"https://service1.example.townsuite.com/world/hello"
],
"labels": { "Env": "prod", "Job": "service1" }
}
]
When the returned array holds names rather than full paths, add a prefix that sits between the base URL and each returned endpoint.
{
"BaseUrl": "https://service1.example.townsuite.com",
"HealthCheck": "/healthz/ready",
"ExtraHealthCheck": "/healthz/extralookups",
"ExtraHealthCheck_Prefix": "/healthz/ready/extras"
}
[
{
"targets": [
"https://service1.example.townsuite.com/healthz/ready",
"https://service1.example.townsuite.com/healthz/ready/extras/hello/world",
"https://service1.example.townsuite.com/healthz/ready/extras/world/hello"
],
"labels": { "Env": "prod", "Job": "service1" }
}
]
Extra health checks — the site owns the list
ExtraHealthCheck inverts who owns the list. Rather than the inventory enumerating every endpoint, the site exposes one endpoint that returns the health endpoints it manages, and the tool expands it on every pass — add or remove one in the service and Prometheus follows on the next cycle, with no inventory change and no restart.
Declared by
Request
Response
Each string
app.MapGet("/healthz/extralookups", () => new[] { "/healthz/database", "/healthz/queue", "/healthz/upstream/billing" });
How one pass expands it
- The instance's
HealthCheckandHealthCheck_*values are joined toBaseUrland added first. - The lookup URL is resolved — a value starting with
httpis used verbatim, anything else is joined toBaseUrl. A path asks the site itself what it manages; an absolute URL lets another service answer on its behalf. - Each string in the returned array is joined to
BaseUrl, or toBaseUrl+ExtraHealthCheck_Prefixwhen that attribute is set. - Everything lands in that instance's single
targetsarray, sorted, sharing its labels. Static endpoints are added first, so a discovered endpoint loses any duplicate or substring collision.
Failure behaviour
A non-success status, a transport failure, or a malformed body is logged, and the instance keeps every target already collected from HealthCheck / HealthCheck_*. Only the discovered extras are missing from that pass.
If the instance declared nothing but ExtraHealthCheck, a failed lookup loses the whole instance — so the pass is marked incomplete and the previous file is kept rather than published without it.
An absent() rule is still a useful backstop, since it catches a target disappearing for any reason — including a service legitimately removed from the inventory:
- alert: HealthCheckTargetMissing expr: absent(probe_success{job="health_checks_service_discovery"}) for: 15m
Cost and trust
Request volumeper cycleDelayInSeconds — there is no caching, even when they all point at the same absolute URL.AuthHeaderis forwardedBaseUrl — and any absolute URL you put in ExtraHealthCheck — receives it.Returned pathshost-boundBaseUrl and never used as full URLs, so a compromised lookup cannot redirect probing at an unrelated host — but it can point the probe at any path on its own. IgnoreList suppresses specific paths.Things to watch
Every HealthCheck_* value is appended to that instance's BaseUrl. Endpoints on a different host belong to another services[] entry with its own BaseUrl.
Duplicates are dropped, and so is any URL already contained in a target in the same group. Prefer distinct, fully-specified paths over paths that nest inside one another.
targets separately, so with the relabel rules in the Prometheus section the instance label holds the full probed URL — that is what keeps /healthz/ready and /healthz/database apart in alerts. A single noisy endpoint can be dropped with IgnoreList without removing the whole instance.Temporary failures do not empty the target files
A lost lookup means the targets are unaccounted for, not that there are none. Publishing an emptier file would be worse than publishing nothing: prometheus drops the targets it no longer sees, stops probing them, and an alert on a missing target does not fire — it just goes quiet. So every pass reports whether it was complete, and an incomplete pass does not overwrite the previous file.
What gets written
CompleteIncomplete, previous file existsIncomplete, no previous fileIncomplete past MaxStaleCyclesWhat makes a pass incomplete
ServiceListUrl failsServiceDiscoverUrl failsExtraHealthCheck fails with nothing to fall back onAn unusable source entryCrashing and exits for the service manager to restart.warn: targets_v2.json pass was incomplete, keeping the previous file
so the endpoints stay in prometheus (incomplete pass 3)
Holds the last good files for as long as the lookups keep failing, because going blind is the worse failure. The trade-off: a service genuinely removed from the inventory keeps being probed while the source is broken.
Accepts the incomplete list after that many consecutive failures. 48 with a 30-minute DelayInSeconds gives up after a day of continuous failure.
Run as a service
Keep it running in the background and restart it automatically. Use NSSM on Windows or a systemd unit on Linux.
- Copy the executable to
C:\prometheus\TownSuite.Prometheus.FileSdConfigs - Edit
appsettings.json - Register it with NSSM
nssm install TownSuite.Prometheus.FileSdConfigs C:\prometheus\...\TownSuite.Prometheus.FileSdConfigs.exe nssm set TownSuite.Prometheus.FileSdConfigs AppDirectory C:\prometheus\TownSuite.Prometheus.FileSdConfigs net start TownSuite.Prometheus.FileSdConfigs
- Extract the release to
/opt/TownSuite.Prometheus.FileSdConfigs - Create the unit file below at
/etc/systemd/system/ - Enable & start with
systemctl
[Unit] Description=TownSuite Prometheus FileSdConfigs [Service] ExecStart=/opt/TownSuite.Prometheus.FileSdConfigs/TownSuite.Prometheus.FileSdConfigs Restart=always RestartSec=10 StandardOutput=syslog StandardError=syslog SyslogIdentifier=TownSuite.Prometheus.FileSdConfigs WorkingDirectory=/opt/TownSuite.Prometheus.FileSdConfigs/ [Install] WantedBy=multi-user.target
systemctl enable townsuite-prometheus-filesdconfigs.service systemctl start townsuite-prometheus-filesdconfigs.service
Wire it into Prometheus
Add file_sd_configs jobs to prometheus.yml — one scrapes the discovered metrics endpoints, the other runs health checks through the Blackbox exporter.
targets_prometheus_metrics.json and relabels scheme, address & metrics path from each URL.targets_v2.json and probes each target via the Blackbox exporter at :9115.targets_open_telemetry_metrics.json with the same relabelling — a plain scrape, so an instance appears only if it declares OpenTelemetryUrl.- job_name: metrics_service_discovery scheme: http file_sd_configs: - files: - /opt/TownSuite.Prometheus.FileSdConfigs/targets_prometheus_metrics.json scrape_interval: 60s relabel_configs: - source_labels: [__address__] regex: '^(https?)://([^/]+)(/.*)?' target_label: __scheme__ replacement: '${1}' # ...address & metrics_path relabels follow # OpenTelemetry endpoints — same relabelling, different file - job_name: open_telemetry_service_discovery scheme: http file_sd_configs: - files: - /opt/TownSuite.Prometheus.FileSdConfigs/targets_open_telemetry_metrics.json scrape_interval: 60s relabel_configs: # same scheme / address / metrics_path relabels as above # Health checks via the Blackbox exporter - job_name: health_checks_service_discovery file_sd_configs: - files: - /opt/TownSuite.Prometheus.FileSdConfigs/targets_v2.json metrics_path: /probe params: module: [http_2xx] relabel_configs: - source_labels: [__address__] target_label: __param_target - target_label: __address__ replacement: 127.0.0.1:9115
prometheus receiver instead of Prometheus itself if the collector owns the scrape.