/ guides / tcp-port-and-dns-monitoring
TCP port and DNS monitoring: checks below HTTP
Watch databases, mail servers and SSH with TCP port checks, catch a database port left open to the internet, and alert when a DNS record changes.
Updated
TCP port monitoring checks, on a schedule, that a port on a host accepts a connection, or that it refuses one. It covers the services an HTTP check cannot reach: databases, mail servers, SSH, message queues, game servers. Fettle is uptime monitoring for developers, and its TCP monitor watches one port expected open or closed, or a list of up to 20 ports that must all stay closed. Its DNS monitor alerts when a record stops resolving or stops returning the value you expect.
This guide covers when a port or DNS check is the right tool, the exact rules each check follows, and what a passing TCP check does not prove.
When is an HTTP check not enough?
An HTTP check is not enough when the thing that can break does not speak HTTP. A database, an SMTP relay, a Redis instance or an SSH bastion listens on a raw TCP port, and a DNS record has no URL at all. A port check or a DNS check watches those directly instead of hoping the website notices.
Some concrete cases:
- A database or cache behind the app. The site's health endpoint may keep answering from a cache while Postgres refuses connections. A check on the database port tells the two failures apart. If the database should not be reachable from the internet at all, flip the check: watch that the port stays closed (see the exposed-ports section below).
- Mail. An SMTP server on port 25 or 587 fails without any web page changing. So does an MX record that points somewhere else after a DNS migration.
- SSH and admin access. Port 22 on a bastion host is the thing you need most when everything else is down, and the last thing anyone checks.
- Game servers and custom protocols. A TCP listener on a non-standard port has nothing for an HTTP check to request. UDP-only services are out of scope: Fettle's port check is TCP only.
- A DNS record that must not change. An A record for your apex, the NS records of your zone, a domain-verification TXT record. A registrar transfer, a stray Terraform apply or a hijacked account can change any of them, and the site may keep working for hours on cached answers.
For the HTTP side of the same stack, the API health check guide covers status codes, keywords and request bodies. Certificates have their own monitor type, covered in SSL expiry monitoring.
What does a TCP port check actually prove?
A TCP check proves that the host completed a TCP connection on that port: the three-way handshake defined in RFC 9293 succeeded within the timeout. That is all it proves. Fettle closes the connection right away. It sends no bytes, reads no banner, does not log in and runs no query.
That gap matters. A Postgres server that accepts connections but rejects every login because
max_connections is exhausted still passes a port check. So does a Redis instance that answers
the handshake but is stuck loading a large dataset, or a load balancer that accepts the connection
on behalf of a backend pool with nothing healthy behind it. A port check catches the process being
gone, the host being down, a firewall change and a routing failure. It does not catch a service
that is listening but broken.
When the service also exposes an HTTP health endpoint, monitor that too, and keep the port check for the layer below it. A failing port check next to a failing HTTP check points at the network or the host; a passing port check next to a failing HTTP check points at the application.
When a port that should be open is not, the incident records why, for example that the connection was refused or timed out. Response time on an open-port check is the time the connection took to establish.
How do you monitor a TCP port with Fettle?
Create a monitor with type set to TCP, a bare hostname or public IP as the target, and the
port in config. expected_state is open by default. The target takes no :port and no path:
the port lives in config, and a host:port target is rejected.
curl -s -X POST https://api.fettle.sh/v1/monitors \
-H "Authorization: Bearer fettle_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "db.example.com Postgres",
"type": "TCP",
"target": "db.example.com",
"interval_seconds": 300,
"timeout_seconds": 10,
"regions": ["eu-central"],
"config": {
"port": 5432,
"expected_state": "open"
}
}'
A single-port check can run from more than one region, starting on Plus, which allows up to 2 regions per monitor. Regional consensus applies as it does for HTTP: the monitor goes down, and alerts, only when every region sees the failure. The multi-region monitoring guide explains why that matters for false alarms.
Set expected_state to closed to invert the check: it passes when the connection fails and
fails when the port answers. One caveat: in single-port mode any failed connection counts as
closed, including a host that is switched off or unreachable. A closed check on its own cannot
tell "port firewalled" from "server gone". Pair it with an open check on a port that should
answer, such as 22 or 443, on the same host.
How do you catch a database port open to the internet?
Use one TCP monitor with a ports list and expected_state set to closed. Fettle dials every
listed port on each check, and the check fails if any of them accepts a connection, naming every
port that answered. This catches a database, cache or admin panel published to the whole internet
by a firewall change or a container port mapping.
The container case is common. Docker publishes a mapped port on all host addresses by default, and
its documentation warns that
publishing container ports is insecure by default.
On hosts using ufw, Docker's
packet filtering docs say
published-port traffic is routed before the firewall rules apply. A -p 5432:5432 added during
debugging can expose Postgres while ufw status still looks correct. A check from outside the host
sees what an attacker sees.
Fettle ships a starting list. The API returns it from GET /v1/monitors/presets/exposed-ports, and
the MCP server exposes the same list as the get_exposed_ports_preset tool. It covers PostgreSQL
(5432), MySQL / MariaDB (3306), Redis (6379), MongoDB (27017), RabbitMQ (5672 and the management UI
on 15672), Elasticsearch (9200), Memcached (11211), the unencrypted Docker API (2375) and Portainer
(9000). Trim it to what could run on the box, or add your own ports:
curl -s -X POST https://api.fettle.sh/v1/monitors \
-H "Authorization: Bearer fettle_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "app-1 exposed ports",
"type": "TCP",
"target": "app-1.example.com",
"interval_seconds": 3600,
"timeout_seconds": 10,
"regions": ["eu-central"],
"config": {
"ports": [5432, 3306, 6379, 27017, 9200, 11211, 2375],
"expected_state": "closed"
}
}'
The rules for a port list, all enforced by the API:
- Between 1 and 20 distinct ports. Duplicates are dropped and the list is stored sorted.
expected_statemust beclosed; a list of ports expected open is rejected.- Exactly one region. Whether a port is reachable from the internet does not depend on where you ask from, so more regions add cost and no signal.
- An interval of at least 300 seconds, on every plan. Hourly is plenty: an exposed port is a configuration mistake, not a flapping outage.
- The whole list counts as one monitor against your plan.
Port-list mode reads the network more strictly than single-port mode. Each port gets at most five seconds (less if the monitor's timeout is shorter). A refused connection, an unreachable host or a silent timeout, which is what a firewall that drops packets produces, all count as closed. A port that could not be checked at all, for example because the hostname did not resolve, does not read as closed: the check reports an error, and an error counts as a failure. A broken check never looks like a clean one.
With an MCP client such as Claude or Codex connected, the same setup is one request in plain language:
Watch app-1.example.com for exposed database ports. Use the exposed-ports
preset, drop the ports we don't run there, and check hourly.
The agent reads the preset, builds the list and creates the monitor. Connecting Claude Code or Codex to Fettle is covered in the AI agents guide.
How do you monitor a DNS record?
Create a monitor with type set to DNS, the domain as the target, and record_type in
config: one of A, AAAA, CNAME, MX, TXT or NS. Without expected_value, the check
passes whenever the lookup returns at least one answer. With it, at least one answer must equal
the expected value, or the monitor goes down.
curl -s -X POST https://api.fettle.sh/v1/monitors \
-H "Authorization: Bearer fettle_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "example.com mail exchanger",
"type": "DNS",
"target": "example.com",
"interval_seconds": 900,
"timeout_seconds": 10,
"regions": ["eu-central"],
"config": {
"record_type": "MX",
"expected_value": "mail.example.com"
}
}'
A DNS monitor takes exactly one region. Record correctness is a property of the domain, and providers that answer differently by location would make regions legitimately disagree and open false incidents.
How does the DNS expected value match?
The match is "any answer equals the expected value", not "the answer set is exactly this". Extra records do not fail the check; a missing expected value does. NXDOMAIN or a resolver error fails it too, as does an MX, NS or TXT lookup with no records. The comparison depends on the record type:
| Record type | What is compared | How |
|---|---|---|
A, AAAA | Every address of the host, IPv4 and IPv6 together | Equal, case-insensitive |
CNAME | The canonical name at the end of the CNAME chain | Case-insensitive, trailing dot ignored |
MX | Each mail host name, without the priority | Case-insensitive, trailing dot ignored |
NS | Each nameserver host name | Case-insensitive, trailing dot ignored |
TXT | Each TXT record as one string | Exact, case-sensitive |
Some details follow from that table:
AandAAAAlook up the same thing: every address the host has. AnAmonitor with no expected value passes on an IPv6-only host. To pin a specific address, setexpected_value, and write an IPv6 address in its shortest form (2001:db8::1, not2001:0db8:0:0:0:0:0:1), because addresses are compared as text.- A CNAME lookup on a name without a CNAME returns the name itself, so pin
expected_valueto catch a removed CNAME. CNAMEcompares the final name, after every hop. Ifwwwpoints toedge.example.net, which points tolb-7.example.net, the value to expect islb-7.example.net.MXtakesmail.example.com, not10 mail.example.com.- A TXT record split into several strings is joined into one before the comparison, following Go's resolver behaviour. Paste the whole record. Spaces and case count, since tokens like verification codes are case-sensitive.
- The target must be a plain hostname: names with an underscore label, such as
_dmarcor a DKIM selector under_domainkey, are not accepted as targets today.
The lookup goes through a public recursive resolver, not straight to your authoritative nameservers. That is the view your users get, caching included: after a change, the monitor sees the new answer on roughly the same schedule as everyone else.
What happens with private or unresolvable targets?
Fettle checks public infrastructure only. localhost, a private address (10.x, 192.168.x and
the like), a loopback, link-local or CGNAT address is rejected when you create the monitor. If a
public hostname later resolves to a private address, or stops resolving, a TCP check reports an
error instead of connecting, and that error counts as a failure.
Internal services that only exist on a private network are out of reach for an outside check. For a job on such a network, a heartbeat inverts the direction: the job calls Fettle, so nothing needs to be reachable from outside.
Where port and DNS checks fit
Port and DNS checks sit under the HTTP checks most teams start with. The uptime monitoring overview covers how the check types fit together, and the guides hub has the rest of this series. The quickstart walks through creating an API key and reading monitor state back. Monitor counts, intervals and regions per plan are on the pricing page.
Questions
- Does a TCP port check prove my database or Redis is healthy?
- No. A TCP check proves that something accepted a connection on that port. It does not log in, run a query or speak the service's protocol, so a process that accepts connections but cannot serve them still passes.
- How do I get alerted if a database port is open to the internet?
- Create one Fettle TCP monitor on the server's public address with a ports list, such as 5432, 3306 and 6379, and expected_state closed. If any listed port accepts a connection, the check fails and names every port that answered.
- How does Fettle's DNS monitor compare the expected value?
- It passes when at least one answer equals the expected value. Names are compared case-insensitively with a trailing dot ignored. TXT records must match exactly, case included. A and AAAA lookups return every address of the host.
Start monitoring in a minute
The free plan checks up to 20 monitors as often as every 3 minutes from 1 region, and emails you when one breaks. See pricing for the paid intervals, or follow the quickstart to do it from the API.