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:
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 .2That 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:
- 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.
- 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:
# 1. Sockets inside one container's network namespace, no tools needed in the imagesudo nsenter -t "$(docker inspect -f '{{.State.Pid}}' myapp)" -n ss -tupn
# 2. Same idea, but with a toolbox container sharing the target's networkdocker run --rm -it --net container:myapp nicolaka/netshoot ss -tupn
# 3. Watch new connections from the whole host as they happensudo conntrack -E -e NEW
# 4. Packet capture on one Docker bridge, hiding DNS and LAN chatterBR="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:
sudo conntrack -L -s 172.20.0.128The 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.
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.txtThe 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.
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: {}http_port 3128acl allowed_sites dstdomain .acme-v02.api.letsencrypt.org .hooks.example.nethttp_access allow allowed_siteshttp_access deny allThe 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:
SUBNET=172.20.0.0/24sudo iptables -I DOCKER-USER -s $SUBNET -j DROPsudo iptables -I DOCKER-USER -s $SUBNET -j LOG --log-prefix "egress-drop: "sudo iptables -I DOCKER-USER -s $SUBNET -d $SUBNET -j ACCEPTsudo iptables -I DOCKER-USER -s $SUBNET -d 192.168.1.2 -p udp --dport 53 -j ACCEPTsudo iptables -I DOCKER-USER -s $SUBNET -d 192.168.1.2 -p tcp --dport 53 -j ACCEPTsudo iptables -I DOCKER-USER -s $SUBNET -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPTEvaluation 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.
| App | Default | How to turn it off |
|---|---|---|
| Grafana | Usage 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 |
| n8n | Diagnostics on, version notifications on | N8N_DIAGNOSTICS_ENABLED=false, N8N_VERSION_NOTIFICATIONS_ENABLED=false |
| Netdata | Anonymous telemetry on | DISABLE_TELEMETRY=1 (or DO_NOT_TRACK=1) in the container env |
| Next.js based apps | Telemetry on unless opted out | NEXT_TELEMETRY_DISABLED=1 |
| Traefik | Usage 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:
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:
- 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.
- Update checks and image pulls. Pulls happen on the Docker daemon, in the host namespace, not in your containers, so
internal: truedoes not stopdocker compose pull. - Notification webhooks. Anything that pings a chat service or SMTP relay needs an explicit allow rule.
- Time sync. Containers use the host’s clock, so NTP is the host’s job. Do not worry about it per container.
- 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.