WANwatcher is a small Docker container that periodically checks your public IPv4 and IPv6 addresses against several detection services. When an address changes it can notify you (Discord, Telegram, email, or anything Apprise supports), update DNS records (Cloudflare, DuckDNS, Route53, dyndns2), publish the state over MQTT for Home Assistant, and expose a status API with Prometheus metrics. It is aimed at homelabs and small servers on residential connections where the ISP changes your IP whenever it feels like it.
- IPv4 and IPv6 monitoring, each can be turned off independently
- Multiple IP detection sources, tried in rotating order so one broken service never blocks detection
- Change confirmation: a new IP is verified against a second source before you get notified
- Notifications via Discord webhooks, Telegram bots, SMTP email, and Apprise (100+ services: ntfy, Gotify, Pushover, Slack, Matrix, ...)
- Built-in dynamic DNS updates for Cloudflare, DuckDNS, AWS Route53, and any dyndns2-compatible provider (No-IP, Dynu, ...)
- HTTP status API with
/healthz,/api/status, and Prometheus/metrics - MQTT publishing with Home Assistant auto-discovery
- Startup notice, optional heartbeat, and internet outage detection with a recovery notification
- Optional geographic info for the new IP via ipinfo.io
- Graceful shutdown on SIGTERM, atomic state file writes, retries with backoff, change history in the state file
- Runs as a non-root user (uid 1000), multi-arch image (AMD64 and ARM64)
You need at least one notification method enabled. The minimal Discord setup:
docker run -d \
--name wanwatcher \
--restart unless-stopped \
-e DISCORD_ENABLED="true" \
-e DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" \
-e SERVER_NAME="My Server" \
-v ./data:/data \
-v ./logs:/logs \
noxied/wanwatcher:2.5.0Or with compose:
services:
wanwatcher:
image: noxied/wanwatcher:2.5.0
container_name: wanwatcher
restart: unless-stopped
environment:
SERVER_NAME: "My Server"
CHECK_INTERVAL: "900"
DISCORD_ENABLED: "true"
DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
volumes:
- ./data:/data
- ./logs:/logsNote: the container runs as uid 1000, so the host directories mounted on /data and /logs must be writable by that user:
mkdir -p data logs
sudo chown -R 1000:1000 data logsThe repository's docker-compose.yml lists every option with comments.
Everything is configured through environment variables. Booleans are the string "true" (anything else means false). Lists are comma separated.
| Variable | Default | Description |
|---|---|---|
SERVER_NAME |
WANwatcher Docker |
Name shown in notifications |
BOT_NAME |
WANwatcher |
Display name used by notifiers |
CHECK_INTERVAL |
900 |
Seconds between IP checks |
MONITOR_IPV4 |
true |
Monitor the public IPv4 address |
MONITOR_IPV6 |
true |
Monitor the public IPv6 address |
IP_CHANGE_CONFIRMATION |
true |
Confirm a detected change with a second source before acting on it |
IPINFO_TOKEN |
(empty) | Optional ipinfo.io token for geographic data |
HTTP_TIMEOUT |
10 |
Timeout in seconds for outbound HTTP requests |
IP_DB_FILE |
/data/ipinfo.db |
State file path |
LOG_FILE |
/logs/wanwatcher.log |
Log file path |
LOG_FORMAT |
text |
text for human-readable logs, or json for structured logs (one JSON object per line, UTC timestamps) for log aggregators like Loki or Datadog |
| Variable | Default | Description |
|---|---|---|
DISCORD_ENABLED |
false |
Enable Discord notifications |
DISCORD_WEBHOOK_URL |
(empty) | Webhook URL |
DISCORD_AVATAR_URL |
(empty) | Optional custom avatar; the webhook's own avatar is used when empty |
| Variable | Default | Description |
|---|---|---|
TELEGRAM_ENABLED |
false |
Enable Telegram notifications |
TELEGRAM_BOT_TOKEN |
(empty) | Bot token from @BotFather |
TELEGRAM_CHAT_ID |
(empty) | Chat or channel id |
TELEGRAM_PARSE_MODE |
HTML |
HTML or Markdown |
| Variable | Default | Description |
|---|---|---|
EMAIL_ENABLED |
false |
Enable email notifications |
EMAIL_SMTP_HOST |
(empty) | SMTP server, e.g. smtp.gmail.com |
EMAIL_SMTP_PORT |
587 |
587 for TLS, 465 for SSL |
EMAIL_SMTP_USER |
(empty) | SMTP username |
EMAIL_SMTP_PASSWORD |
(empty) | SMTP password (use an app password for Gmail) |
EMAIL_FROM |
(empty) | Sender address |
EMAIL_TO |
(empty) | Recipients, comma separated |
EMAIL_USE_TLS |
true |
STARTTLS |
EMAIL_USE_SSL |
false |
Implicit SSL (do not enable both TLS and SSL) |
EMAIL_SUBJECT_PREFIX |
[WANwatcher] |
Subject prefix |
| Variable | Default | Description |
|---|---|---|
APPRISE_ENABLED |
false |
Enable Apprise notifications |
APPRISE_URLS |
(empty) | Comma-separated Apprise URLs |
| Variable | Default | Description |
|---|---|---|
DDNS_ENABLED |
false |
Enable DNS updates on IP change |
DDNS_PROVIDER |
(empty) | cloudflare, duckdns, dyndns2, or route53 |
CLOUDFLARE_API_TOKEN |
(empty) | API token with Zone.DNS edit permission |
CLOUDFLARE_ZONE |
(empty) | Zone name, e.g. example.com |
CLOUDFLARE_RECORDS |
(empty) | Records to update, e.g. home.example.com,vpn.example.com |
CLOUDFLARE_PROXIED |
false |
Whether updated records go through the Cloudflare proxy |
CLOUDFLARE_TTL |
1 |
Record TTL; 1 means automatic |
DUCKDNS_TOKEN |
(empty) | DuckDNS account token |
DUCKDNS_DOMAINS |
(empty) | Subdomain names, comma separated |
DYNDNS2_SERVER |
(empty) | Update server, e.g. https://dynupdate.no-ip.com |
DYNDNS2_USERNAME |
(empty) | Username |
DYNDNS2_PASSWORD |
(empty) | Password |
DYNDNS2_HOSTNAMES |
(empty) | Hostnames, comma separated |
ROUTE53_ACCESS_KEY_ID |
(empty) | AWS access key id |
ROUTE53_SECRET_ACCESS_KEY |
(empty) | AWS secret access key |
ROUTE53_HOSTED_ZONE_ID |
(empty) | Hosted zone id, e.g. Z1234567890ABC |
ROUTE53_RECORDS |
(empty) | Records to update, comma separated |
ROUTE53_TTL |
300 |
Record TTL in seconds |
| Variable | Default | Description |
|---|---|---|
API_ENABLED |
false |
Enable the HTTP status API |
API_BIND |
0.0.0.0 |
Bind address |
API_PORT |
8080 |
Port (remember to publish it) |
| Variable | Default | Description |
|---|---|---|
MQTT_ENABLED |
false |
Enable MQTT publishing |
MQTT_HOST |
(empty) | Broker hostname |
MQTT_PORT |
1883 |
Broker port |
MQTT_USERNAME |
(empty) | Username |
MQTT_PASSWORD |
(empty) | Password |
MQTT_CLIENT_ID |
wanwatcher |
Client id |
MQTT_TOPIC_PREFIX |
wanwatcher |
Topic prefix |
MQTT_TLS |
false |
TLS connection to the broker |
MQTT_HA_DISCOVERY |
true |
Publish Home Assistant discovery configs |
MQTT_HA_DISCOVERY_PREFIX |
homeassistant |
HA discovery prefix |
| Variable | Default | Description |
|---|---|---|
NOTIFY_ON_STARTUP |
true |
Send a message when the container starts |
HEARTBEAT_ENABLED |
false |
Periodic "still alive" message |
HEARTBEAT_INTERVAL |
86400 |
Seconds between heartbeats |
OUTAGE_DETECTION_ENABLED |
true |
Notify when connectivity drops and when it returns |
OUTAGE_THRESHOLD |
3 |
Consecutive failed checks before declaring an outage |
| Variable | Default | Description |
|---|---|---|
UPDATE_CHECK_ENABLED |
true |
Check GitHub for new releases |
UPDATE_CHECK_INTERVAL |
86400 |
Seconds between checks |
UPDATE_CHECK_ON_STARTUP |
true |
Also check at startup |
Every sensitive value can be read from a file instead of a plain environment
variable, using the <NAME>_FILE convention. This is how Docker secrets and
Kubernetes secret mounts are meant to be consumed. Set <NAME>_FILE to the
path of a file and its contents are used (trailing whitespace is trimmed); a
direct <NAME> variable, if also set, takes precedence. A _FILE path that
does not exist stops the container at startup with a clear error.
Supported: DISCORD_WEBHOOK_URL_FILE, TELEGRAM_BOT_TOKEN_FILE,
EMAIL_SMTP_PASSWORD_FILE, CLOUDFLARE_API_TOKEN_FILE, DUCKDNS_TOKEN_FILE,
DYNDNS2_PASSWORD_FILE, MQTT_PASSWORD_FILE, IPINFO_TOKEN_FILE,
APPRISE_URLS_FILE.
services:
wanwatcher:
image: noxied/wanwatcher:2.5.0
environment:
DISCORD_ENABLED: "true"
DISCORD_WEBHOOK_URL_FILE: /run/secrets/discord_webhook
secrets:
- discord_webhook
secrets:
discord_webhook:
file: ./secrets/discord_webhook.txtAt least one notifier must be enabled or the container refuses to start.
Create a webhook under Server Settings > Integrations > Webhooks and set DISCORD_ENABLED=true and DISCORD_WEBHOOK_URL. Messages are sent as embeds. To customize the avatar, set it on the webhook in Discord or point DISCORD_AVATAR_URL at a public image.
Create a bot with @BotFather, get your chat id (for example from @userinfobot), send /start to your bot once, then set TELEGRAM_ENABLED, TELEGRAM_BOT_TOKEN, and TELEGRAM_CHAT_ID.
Standard SMTP. For Gmail, enable 2FA and generate an app password, then:
EMAIL_ENABLED: "true"
EMAIL_SMTP_HOST: "smtp.gmail.com"
EMAIL_SMTP_PORT: "587"
EMAIL_SMTP_USER: "you@gmail.com"
EMAIL_SMTP_PASSWORD: "your-app-password"
EMAIL_FROM: "you@gmail.com"
EMAIL_TO: "you@gmail.com"One setting covers everything Apprise supports. Some example URLs:
APPRISE_ENABLED: "true"
# ntfy topic
APPRISE_URLS: "ntfy://ntfy.sh/my-topic"
# multiple services, comma separated
# APPRISE_URLS: "pover://USER_KEY@APP_TOKEN,gotify://gotify.example.com/APP_TOKEN"See the Apprise URL list for every supported service.
When DDNS_ENABLED=true, WANwatcher updates your DNS records whenever the IP changes (and retries failed updates on the next check). One provider at a time, selected by DDNS_PROVIDER.
Cloudflare (token needs Zone.DNS edit permission for the zone):
DDNS_ENABLED: "true"
DDNS_PROVIDER: "cloudflare"
CLOUDFLARE_API_TOKEN: "your-token"
CLOUDFLARE_ZONE: "example.com"
CLOUDFLARE_RECORDS: "home.example.com"DuckDNS:
DDNS_ENABLED: "true"
DDNS_PROVIDER: "duckdns"
DUCKDNS_TOKEN: "your-token"
DUCKDNS_DOMAINS: "myhome"Generic dyndns2 (No-IP, Dynu, and most router-supported providers):
DDNS_ENABLED: "true"
DDNS_PROVIDER: "dyndns2"
DYNDNS2_SERVER: "https://dynupdate.no-ip.com"
DYNDNS2_USERNAME: "user"
DYNDNS2_PASSWORD: "pass"
DYNDNS2_HOSTNAMES: "home.example.com"AWS Route53 (the credentials need route53:ChangeResourceRecordSets on the
hosted zone; signed with SigV4, no AWS SDK is bundled):
DDNS_ENABLED: "true"
DDNS_PROVIDER: "route53"
ROUTE53_ACCESS_KEY_ID: "AKIA..."
ROUTE53_SECRET_ACCESS_KEY: "your-secret"
ROUTE53_HOSTED_ZONE_ID: "Z1234567890ABC"
ROUTE53_RECORDS: "home.example.com"
ROUTE53_TTL: "300"Set API_ENABLED=true and publish the port (-p 8080:8080). Endpoints:
GET /healthzreturns{"status": "ok", ...}(200) when the loop is healthy, or{"status": "stale", ...}(503) if no successful check has happened within a generous multiple ofCHECK_INTERVAL, so a wedged loop is detectableGET /api/statusreturns the full state: current IPs, last check,seconds_since_last_check,check_interval, last change, uptime, and recent change historyGET /metricsreturns Prometheus metrics
curl http://localhost:8080/api/status
curl http://localhost:8080/metricsGeographic data in /api/status (and over MQTT) reflects the most recent IP change, since the lookup only runs when the address changes; it is null until the first change is recorded.
Exported metrics include wanwatcher_checks_total, wanwatcher_check_failures_total, wanwatcher_ip_changes_total, wanwatcher_notifications_total, wanwatcher_ddns_updates_total, wanwatcher_last_change_timestamp_seconds, wanwatcher_last_check_timestamp_seconds, and wanwatcher_up. A Prometheus scrape job pointed at wanwatcher:8080 works as-is; no extra exporter needed.
When the API is enabled, the container healthcheck queries /healthz. Otherwise it verifies that the state file exists, is valid JSON, and was refreshed recently.
With MQTT_ENABLED=true the current state is published as retained messages under the topic prefix (default wanwatcher):
wanwatcher/ipv4,wanwatcher/ipv6,wanwatcher/last_changewanwatcher/state(JSON with IPs, geo data, and server name)wanwatcher/availability(online/offline, also set as the MQTT will)
With MQTT_HA_DISCOVERY=true (the default) Home Assistant discovery configs are published, so a device with WAN IPv4, WAN IPv6, and last-change sensors shows up automatically once HA is connected to the same broker.
Besides IP change notifications, WANwatcher can send:
- a startup notice when the container starts (
NOTIFY_ON_STARTUP, on by default) - a periodic heartbeat so you know the monitor itself is alive (
HEARTBEAT_ENABLED, off by default) - an outage notice after
OUTAGE_THRESHOLDconsecutive failed checks, and a recovery notice when connectivity returns (OUTAGE_DETECTION_ENABLED, on by default; the outage notice is delivered once the connection is back, since nothing can be sent during the outage) - an update notice when a new WANwatcher release is available (
UPDATE_CHECK_ENABLED)
| Mount | Purpose |
|---|---|
/data |
State file with the current IPs and change history (persistent) |
/logs |
Log files |
Both must be writable by uid 1000 (the user the container runs as). Port 8080
only needs publishing when API_ENABLED=true.
The image is built for linux/amd64 and linux/arm64, so it runs on regular
servers as well as a Raspberry Pi 4 or newer, Apple Silicon, and AWS Graviton.
Docker pulls the right variant automatically.
Follow the logs:
docker logs -f wanwatcherCheck the container health (the image ships a healthcheck):
docker inspect --format='{{json .State.Health}}' wanwatcherRead the current state directly:
docker exec wanwatcher cat /data/ipinfo.dbWith API_ENABLED=true you can also poll /api/status and scrape /metrics
(see the Status API section).
The container exits right after starting
- Configuration validation failed. Run
docker logs wanwatcherand read the validation errors. Common causes are no notifier enabled, an invalid webhook URL, or a missing required variable.
Permission denied on /data or /logs (after upgrading to 2.0)
- The container runs as uid 1000. Fix the host directories with
sudo chown -R 1000:1000 ./data ./logs.
Notifications are not arriving
- Failed sends are retried three times with backoff. Check
docker logs wanwatcher | grep -i "notification\|retry"and confirm the credentials.
IPv6 is never detected
- Confirm the host actually has IPv6 connectivity and that
MONITOR_IPV6="true".
DNS records are not updating
- Confirm
DDNS_ENABLED=true, the provider is spelled correctly, and the token or credentials are valid. The logs show the result of each update attempt.
The full guide is in docs/TROUBLESHOOTING.md.
All v1 environment variables keep working and the state file is migrated automatically. The one breaking change: the container now runs as uid 1000, so your /data and /logs volumes must be writable by that user (sudo chown -R 1000:1000 ./data ./logs). See UPGRADING.md for details.
git clone https://github.com/noxied/wanwatcher.git
cd wanwatcher
pip install -r requirements-dev.txt
# tests
pytest tests/ -v --cov
# lint and format
black wanwatcher/ tests/
isort wanwatcher/ tests/
flake8 wanwatcher/
mypy wanwatcher/ --ignore-missing-imports
# run locally
python -m wanwatcher
# build the image
docker build -t wanwatcher:dev .See CONTRIBUTING.md for guidelines, including how to add a notification or DDNS provider.
- CHANGELOG.md for version history
- UPGRADING.md for upgrade instructions
- docs/TROUBLESHOOTING.md for common problems
- SECURITY.md for the security policy
MIT, see LICENSE.
