{"slug": "keep-it-local", "title": "Keep It Local", "summary": "Developer Simon Willison warns against accidentally exposing local apps to the internet, advocating for binding to localhost or private networks like Tailscale by default. He demonstrates how to secure tools such as his Claude Code dashboard clodhopper, air, Python's http.server, and App::HTTPThis by using explicit bind addresses instead of the default 0.0.0.0.", "body_md": "Who wants to talk about networking today? I know I do. Two\naddresses do most of the heavy lifting here: `127.0.0.1`\n\n(aka *localhost* or the\n*loopback* address — only your own machine can reach it) and `0.0.0.0`\n\n(bind to\nthis and you’re reachable on *every* network interface). See\n[Localhost](https://en.wikipedia.org/wiki/Localhost) and\n[0.0.0.0](https://en.wikipedia.org/wiki/0.0.0.0#Binding) for a refresher.\n\n## clodhopper[#](#clodhopper)\n\nYesterday I talked about [clodhopper](/2026/06/29/on-hopping-claudes/), my\npersonal Claude Code dashboard. It collects data from your running agents and\nspins up a read-only app to show their status. By default, it\nbinds to localhost (`127.0.0.1`\n\n), which means I don’t accidentally broadcast my\nworkflows to the world. I also run it on my Tailscale network, which means I can view it on my\nown private network, but the world can’t. I was doing this by interpolating the\nTailscale IP in the startup command: `clodhopper serve --host \"$(tailscale ip -4)\"`\n\n. Today I added a `--tailscale`\n\narg, to make this a touch easier.\n\n```\n# Default: loopback only — only this machine can reach it\nclodhopper serve\n\n# Don't do this on an untrusted network — binds every interface\nCLODHOPPER_HOST=0.0.0.0 clodhopper serve\n\n# Tailnet only — reachable from your tailnet (subject to ACLs), not the LAN\nclodhopper serve --tailscale\n```\n\nSo, that’s all fine, but we are now living in a world where anyone can spin up a custom app on their machine and accidentally broadcast it to the world. Is this bad? Not always, but consider the following:\n\n- your app keeps secrets in\n`.env`\n\n- your app spins up a web server\n`.env`\n\nsomehow ends up in the path that your app is serving- random bot sniffs out your app and fetches\n`.env`\n\n- now you need to rotate your secrets and you may not even be aware that your secrets are in the hands of a bad actor\n\n\"[Localhost](https://www.flickr.com/photos/10768314@N00/4823138926)\" by [Wesley Nitsckie](https://www.flickr.com/photos/nitsckie/) is licensed under [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/).\n\nIdeally you’d restrict access to your resources to only the audiences which\nrequire them. So, defaulting to `localhost`\n\nand then expanding your reach from\nthere is a good way to go. In my case I’ve been enjoying using a\n[Tailscale](https://tailscale.com/) tailnet. Only my own authenticated devices\ncan connect. Internet creeping will have to take place elsewhere, because my\napps are now for my eyes only.\n\nMoving beyond `clodhopper`\n\n, here are ways to apply the same principle to some open source apps.\n\n## air[#](#air)\n\n[air](https://github.com/air-verse/air)runs`full_bin`\n\nthrough a shell, so you can interpolate`tailscale ip -4`\n\nstraight into your app’s host flag in`.air.toml`\n\n— no hardcoded address:\n\n```\n[build]\n# Loopback only\nfull_bin = \"./tmp/main -port 5003 -host 127.0.0.1\"\n\n# Tailnet only — resolved at startup, no hardcoded address\nfull_bin = \"./tmp/main -port 5003 -host $(tailscale ip -4)\"\n```\n\n## Python’s built-in file server[#](#pythons-built-in-file-server)\n\n`http.server`\n\nbinds`0.0.0.0`\n\nby default — pass an explicit`--bind`\n\n.\n\n```\n# Default binds 0.0.0.0 (all interfaces)\npython3 -m http.server 5000\n\n# Loopback only\npython3 -m http.server 5000 --bind 127.0.0.1\n\n# Tailnet only\npython3 -m http.server 5000 --bind \"$(tailscale ip -4)\"\n```\n\n## App::HTTPThis[#](#apphttpthis)\n\n[App::HTTPThis](https://metacpan.org/dist/App-HTTPThis/view/bin/http_this)serves the current directory over HTTP.- Use\n`--host`\n\nto control the bind address.\n\n```\n# Loopback only\nhttp_this --host 127.0.0.1\n\n# Tailnet only\nhttp_this --host \"$(tailscale ip -4)\"\n```\n\n*nota bene*: the current version of `http_this`\n\nbinds to *every* interface\nbut emits a message that implies that it is binding *only* to `127.0.0.1`\n\n.\n\n``` bash\n$ http_this .\nExporting '.', available at:\n   http://127.0.0.1:7007/\n```\n\n😬 Today I opened [#13](https://github.com/davorg-cpan/app-httpthis/pull/13) to\nclarify the behaviour, but maybe take this as a reminder that it’s good to be\nexplicit about the things that really matter, rather than relying on the\ndefaults.", "url": "https://wpnews.pro/news/keep-it-local", "canonical_source": "https://www.olafalders.com/2026/06/30/keep-it-local/", "published_at": "2026-06-30 00:00:00+00:00", "updated_at": "2026-06-30 17:53:31.517521+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-safety"], "entities": ["Tailscale", "Claude Code", "clodhopper", "air", "Python", "App::HTTPThis", "Simon Willison"], "alternates": {"html": "https://wpnews.pro/news/keep-it-local", "markdown": "https://wpnews.pro/news/keep-it-local.md", "text": "https://wpnews.pro/news/keep-it-local.txt", "jsonld": "https://wpnews.pro/news/keep-it-local.jsonld"}}