Skip to content
Go back

Catch Your Self-Hosted Apps Phoning Home

By KingPin 13 min read
Catch Your Self-Hosted Apps Phoning Home
Contents

You Self-Hosted for Privacy. Your Containers Didn’t Get the Memo.

Self-hosted does not mean silent. Plenty of the apps in your homelab check for updates, send anonymous usage stats, load fonts from a CDN, or fetch avatars from a third party, and they do it on a schedule you never agreed to. You moved your photos off a big cloud and then handed a smaller, quieter stream of metadata to a dozen other companies.

The fix has an order, and the order matters. First you watch: DNS query logs tell you who talks to what, per client. Second you block at the network, so an app cannot call out even if it wants to. Third you flip the app’s own opt-outs, which is the polite version of what you already enforced. Skip straight to the opt-outs and you are trusting the exact software you were auditing. That is like asking the forklift driver to sign off on their own forklift inspection.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/self-hosted-apps-phoning-home

Step 1: Watch, Using DNS Query Logs

Almost every outbound call starts with a DNS lookup. A resolver that logs queries per client gives you a free, always-on audit trail. Pi-hole and AdGuard Home both do this. Pi-hole v6 exposes its Query Log through the FTL API at /api/queries, and AdGuard Home has a query log you can filter by client in its web UI.

Run one of them as a container on the same Docker network as your apps. Here is AdGuard Home with a fixed address on a user-defined network:

compose.yaml
services:
adguard:
image: adguard/adguardhome
restart: unless-stopped
networks:
apps:
ipv4_address: 172.20.0.2
volumes:
- ./adguard/work:/opt/adguardhome/work
- ./adguard/conf:/opt/adguardhome/conf
ports:
- "3000:3000" # setup wizard; pick 3000 as the admin port so this mapping keeps working
whoami:
image: traefik/whoami
dns:
- 172.20.0.2
networks:
- apps
networks:
apps:
ipam:
config:
- subnet: 172.20.0.0/24
ip_range: 172.20.0.128/25 # dynamic IPs stay clear of .2

That dns: line is where most people get burned, so here is the gotcha in full.

The Per-Container Attribution Gotcha

On a user-defined network, a container does not talk to your resolver directly. It talks to Docker’s embedded DNS server at 127.0.0.11. Docker’s docs say that server forwards external lookups to the DNS servers configured on the host. If you set dns: on the container, the embedded server forwards to that address instead.

So what source IP does your resolver see? I tested this on Docker 29 with a tcpdump container as the resolver. When the resolver sits on the same user-defined network, the query arrives from the client container’s own IP. The Docker source code explains why: the embedded resolver dials the upstream server from inside the container’s network namespace, so the container’s address goes on the packet.

Two cases break that, and both collapse everything into one client in your logs:

  1. The resolver lives off the Docker host (for example a Pi-hole on another LAN box). The packet leaves through the host’s uplink and gets masqueraded, so the resolver sees the Docker host’s IP for every container. A resolver container on a different Docker network is worse: Docker’s network isolation drops the traffic, so lookups fail outright.
  2. The host’s resolver is a loopback address (for example a stub resolver on the host). Docker dials loopback upstreams from the host namespace, so the source is the host.

Containers on the default bridge network skip the embedded server and get a copy of the host’s /etc/resolv.conf, so they follow the host’s resolver settings.

The practical rule: put the resolver on the same user-defined network as the apps, give it a static address, and point dns: at it. Now AdGuard Home shows 172.20.0.128 asking for stats.example.com, and you know which container it was. Give your clients names in the AdGuard Home clients settings (or in Pi-hole’s client tracking) and the log reads like a sentence.

Let it run for a few days, then read the log sorted by client. You are looking for domains you do not recognize, repeated lookups every few hours (update checkers), and anything that resolves right at container start (telemetry pings).

Step 2: Watch Live Connections

DNS shows intent. Some apps skip DNS entirely with hardcoded IPs, or use DNS over HTTPS inside the app. For those, look at the actual sockets.

Four ways, from lightest to heaviest:

Terminal window
# 1. Sockets inside one container's network namespace, no tools needed in the image
sudo nsenter -t "$(docker inspect -f '{{.State.Pid}}' myapp)" -n ss -tupn
# 2. Same idea, but with a toolbox container sharing the target's network
docker run --rm -it --net container:myapp nicolaka/netshoot ss -tupn
# 3. Watch new connections from the whole host as they happen
sudo conntrack -E -e NEW
# 4. Packet capture on one Docker bridge, hiding DNS and LAN chatter
BR="br-$(docker network inspect -f '{{.Id}}' myproject_default | cut -c1-12)"
sudo tcpdump -ni "$BR" 'not port 53 and not dst net 172.20.0.0/24'

Use ss for “what is connected right now” and conntrack -E for “what opened a connection while I wasn’t looking”. To see one container’s history, filter by its IP:

Terminal window
sudo conntrack -L -s 172.20.0.128

The 2 AM version of this: an app looks idle, you run conntrack -E -e NEW, and a line scrolls past every 30 seconds to an address you have never seen. Now you have a destination to look up and a container to blame.

Step 3: Block at the Network

Logs are evidence. Blocking is policy. You have three tools, and you should pick the lightest one that does the job.

Option A: an internal network

A network created with internal: true has no route out. Docker’s docs say containers on an internal network can talk to each other but not to any other network. Be aware that containers can still reach the gateway address, and the host can reach any container IP directly.

compose.yaml
services:
db:
image: postgres:17
networks: [backend]
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg
secrets: [pg]
app:
image: ghcr.io/example/app:latest
networks: [backend, frontend]
networks:
backend:
internal: true
frontend: {}
secrets:
pg:
file: ./pg_password.txt

The database cannot phone anyone, because it only lives on backend. The app straddles both networks, so it can still reach out through frontend.

I ran this the obvious way: a container on an internal network with wget http://example.com. It failed with bad address, and nslookup returned SERVFAIL. Internal networks cut DNS too, because the embedded resolver cannot reach an upstream. That is usually what you want. It also means an app on an internal network cannot resolve anything outside its network, including your AdGuard Home container if that is on a different network. Put your resolver on the internal network too if you want to keep logging attempts.

Option B: an egress proxy with an allowlist

Some apps need exactly one outbound destination (a webhook target, an ACME server, a package mirror). Run a proxy that sits on both networks and allows only those domains. The app lives on the internal network and sends HTTP(S) traffic through the proxy.

compose.yaml
services:
egress:
image: ubuntu/squid
volumes:
- ./squid.conf:/etc/squid/squid.conf:ro
networks: [internal_net, outbound]
app:
image: ghcr.io/example/app:latest
environment:
HTTP_PROXY: http://egress:3128
HTTPS_PROXY: http://egress:3128
NO_PROXY: localhost,127.0.0.1,db
http_proxy: http://egress:3128 # curl and friends only read lowercase
https_proxy: http://egress:3128
no_proxy: localhost,127.0.0.1,db
networks: [internal_net]
networks:
internal_net:
internal: true
outbound: {}
squid.conf
http_port 3128
acl allowed_sites dstdomain .acme-v02.api.letsencrypt.org .hooks.example.net
http_access allow allowed_sites
http_access deny all

The proxy only helps for software that honors proxy env vars. Anything that opens raw sockets simply fails, which for this purpose is the right result. The proxy log also gives you a per-domain record of every call the app tried.

Option C: a DOCKER-USER rule

For a whole project or subnet, use the DOCKER-USER chain. Docker’s docs describe it as a placeholder for user-defined rules processed before Docker’s own forwarding rules. Packets arrive there after DNAT, so you match on container addresses.

Because -I puts each rule at the top, add them in reverse order of how you want them evaluated:

Terminal window
SUBNET=172.20.0.0/24
sudo iptables -I DOCKER-USER -s $SUBNET -j DROP
sudo iptables -I DOCKER-USER -s $SUBNET -j LOG --log-prefix "egress-drop: "
sudo iptables -I DOCKER-USER -s $SUBNET -d $SUBNET -j ACCEPT
sudo iptables -I DOCKER-USER -s $SUBNET -d 192.168.1.2 -p udp --dport 53 -j ACCEPT
sudo iptables -I DOCKER-USER -s $SUBNET -d 192.168.1.2 -p tcp --dport 53 -j ACCEPT
sudo iptables -I DOCKER-USER -s $SUBNET -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

Evaluation order is now: allow replies on connections that already exist, allow DNS to a LAN resolver, allow traffic inside the subnet, log everything else, drop it. That first rule matters. Without it, the reply packets for your published ports (source: the container) hit the DROP and every web UI on that subnet stops answering. Run journalctl -k | grep egress-drop and you have a list of every attempted call. Two limits: these rules cover IPv4 only (repeat them with ip6tables if your Docker networks have IPv6), and they vanish on reboot unless you save them with iptables-save or your distro’s persistence package.

Check docker info | grep -i firewall before you rely on this. Docker 29.0.0 introduced an experimental nftables backend, selected with "firewall-backend": "nftables" in daemon.json. Docker’s docs state that with the nftables backend the daemon does not add the jump to DOCKER-USER, so rules there are ignored (a jump left over from an earlier iptables run keeps working until reboot). Write your own nftables table and chain instead. On the default iptables backend, the rules above apply as written.

Step 4: Flip the App Opt-Outs

Now that the network enforces the rules, the opt-outs are cleanup. They also stop the app from logging failed connection errors every hour. Here are the ones I confirmed against vendor docs in October 2026.

AppDefaultHow to turn it off
GrafanaUsage stats on (reporting_enabled defaults to true, sent to stats.grafana.org)GF_ANALYTICS_REPORTING_ENABLED=false, plus GF_ANALYTICS_CHECK_FOR_UPDATES=false and GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
n8nDiagnostics on, version notifications onN8N_DIAGNOSTICS_ENABLED=false, N8N_VERSION_NOTIFICATIONS_ENABLED=false
NetdataAnonymous telemetry onDISABLE_TELEMETRY=1 (or DO_NOT_TRACK=1) in the container env
Next.js based appsTelemetry on unless opted outNEXT_TELEMETRY_DISABLED=1
TraefikUsage stats off; version check on (checkNewVersion defaults to true)--global.checkNewVersion=false (and --global.sendAnonymousUsage=false if you want it spelled out)

As a compose snippet:

compose.yaml
services:
grafana:
image: grafana/grafana
environment:
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
n8n:
image: n8nio/n8n
environment:
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
netdata:
image: netdata/netdata
environment:
DISABLE_TELEMETRY: "1"

Two caveats. The Grafana env names come from Grafana’s documented GF_<SECTION>_<KEY> rule, and the docs page does not list those exact strings, so test them after restart by watching the DNS log for stats.grafana.org. And Traefik takes the same settings as TRAEFIK_-prefixed environment variables (for example TRAEFIK_GLOBAL_CHECKNEWVERSION=false), or as the CLI flags above.

An opt-out you did not verify is a hope. Confirm each by watching the query log: the domain should stop showing up.

What Breaks When You Block Things

You will break things. Plan for these:

  1. ACME certificate renewal. Let’s Encrypt validation needs outbound access to the CA. Allowlist it through the proxy, or keep your reverse proxy on a network with egress.
  2. Update checks and image pulls. Pulls happen on the Docker daemon, in the host namespace, not in your containers, so internal: true does not stop docker compose pull.
  3. Notification webhooks. Anything that pings a chat service or SMTP relay needs an explicit allow rule.
  4. Time sync. Containers use the host’s clock, so NTP is the host’s job. Do not worry about it per container.
  5. Fonts, avatars, map tiles. Browser-side loads come from your laptop, not the server, so the container’s DNS log will never show them. Check those in your browser’s network tab.

Start in log-only mode: add the LOG rule without the DROP, run a week, and read the results. Then add the drop. Your 2 AM self will thank you for the week of evidence.

The Short Version

Audit with DNS logs, using a resolver on the same Docker network so each container gets its own identity. Cross-check with conntrack -E. Block with internal: true by default, a proxy for the few allowed exceptions, and a DOCKER-USER rule for whole subnets. Then set the opt-outs and verify them by watching the logs go quiet.

Common Questions

How do I block a Docker container from the internet?

Attach the container only to a network defined with internal: true in Compose. Docker then gives that network no route out, so containers on it reach each other but not external hosts. DNS lookups for outside names also fail. If the container must stay on a normal network, add a DOCKER-USER iptables rule that drops its subnet.

Why does every container show the same IP in my Pi-hole or AdGuard Home log?

The resolver runs on another machine, so Docker masquerades the queries to the Docker host’s IP. Put the resolver container on the same user-defined network, give it a static address, and set dns: on each app container. The resolver then sees each container’s own address.

Does Docker’s DOCKER-USER chain exist with the nftables backend?

No. Docker’s documentation states that with the nftables backend the daemon does not create the jump to DOCKER-USER, so rules in that chain are ignored. The nftables backend arrived in Docker Engine 29.0.0 and is experimental. Check docker info for the active firewall backend, and write your own nftables table and chain if you enable it.

Does Netdata send telemetry by default?

Yes. Netdata collects anonymous usage information by default. In Docker, set DISABLE_TELEMETRY=1 (or DO_NOT_TRACK=1) in the container environment. On a manual install, create an empty .opt-out-from-anonymous-statistics file in the Netdata config directory, which is /etc/netdata/ on standard Linux. Restart the agent afterwards.

Does an internal Docker network stop image pulls?

No. The Docker daemon pulls images from the host’s network namespace, not from inside your containers. An internal: true network only restricts the containers attached to it. Pulls, and any registry login the daemon does, keep working normally. Blocking pulls takes a host firewall rule or a registry mirror.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
AppArmor Profiles for Docker, Step by Step
Next Post
Btrfs Send/Receive: Incremental Backups

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts