Add a tailnet-only Fly deployment for the web frontend - #30
Merged
Merged
Conversation
The frontend signs with a key that can add, edit and delete any link and performs no authentication of its own, so anyone who can reach it has full control. Today it runs on a VPS bound to all interfaces, which makes its safety a property of that host's firewall rather than of the deployment. This runs it on Fly with no public ingress at all. fly.toml declares no services, so no public address is allocated and no dedicated IP is needed. tailscaled runs in userspace-networking mode, which leaves the container with no externally reachable interface: Tailscale Serve is the only route in, and it accepts connections only from the tailnet. The image cannot be distroless like the server's, since tailscaled runs alongside the client and the entrypoint needs a shell. State is kept in memory rather than on disk, which pairs with an ephemeral auth key so nodes deregister when the machine stops instead of accumulating on every restart. The signing key arrives as a base64 secret and is written to tmpfs, so it never lands in the image or on a volume. The machine cannot scale to zero: Fly wakes stopped machines from its own proxy, and tailnet traffic never traverses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems that only a real deploy surfaced. The dockerfile path in fly.toml is resolved relative to fly.toml itself, not to the build context, so "frontend/Dockerfile" became frontend/frontend/Dockerfile. The build context still has to be the repository root for the Go sources, so it is now passed explicitly: `fly deploy . -c frontend/fly.toml`. `tailscale status` is not a readiness probe. It exits non-zero while the node is stopped or unauthenticated, which is precisely the state tailscaled is in before login, so the wait loop could never succeed. It timed out, set -e exited, and Fly restart-looped the machine. It now waits for the control socket. --state=mem: is incompatible with `serve --https`. Serve provisions a Let's Encrypt certificate and caches it under the state directory, so every TLS handshake failed with "no TailscaleVarRoot". It now uses a state directory on the machine's ephemeral root filesystem. Also correct the README: Serve is not the only route in. In userspace-networking mode tailscaled forwards inbound tailnet connections to local listeners, so the client is reachable directly on its port as well. Both paths are confined to the tailnet, but Serve is not a security boundary and the docs should not imply it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The frontend signs with a key that can add, edit and delete any link, and it performs no authentication of its own — anyone who can reach it has full control. The README already says as much.
Today it runs on a VPS bound to
*:9099, i.e. all interfaces including the host's public IP. Probing from outside timed out, which suggests a provider firewall is covering it, but that makes its safety a property of the host's firewall rather than of the deployment itself.What
Runs it on Fly with no public ingress at all. Two independent things enforce that:
fly.tomldeclares no services. No[[services]], no[http_service]— Fly allocates no public address, and the app needs no dedicated IP.tailscaledruns in userspace-networking mode, so the container has no externally reachable interface. Tailscale Serve is the only route in, and it accepts connections only from the tailnet. This also avoids needing a TUN device orNET_ADMIN.Some deliberate choices:
mem:rather than on disk, which pairs with an ephemeral auth key: nodes deregister when the machine stops instead of piling up on every restart. No volume needed./run/links/priv.pbon tmpfs, so it never lands in the image or a volume.Testing
No Tailscale auth key was available, so this is verified up to the point where one is required:
tailscale1.98.10 and the client are present and runnableTS_AUTHKEY,LINKS_PRIVATE_KEY_B64,LINKS_ADDR/run/links/priv.pb— mode600, correct decoded contentNeedsLogin, i.e. ready and waiting on authWhat is not yet verified: actually joining a tailnet, Serve publishing, and reaching the UI. That needs a real key and a deploy.
Cost
One always-on
shared-cpu-1x/256MB, roughly $2/mo, and no IP charge since there's no public ingress. It cannot scale to zero — Fly wakes stopped machines from its own proxy, and tailnet traffic never traverses it, so a stopped machine is simply unreachable.Known gap
client --serverbinds all interfaces, not just loopback. With no Fly services declared there's no public route, but it is reachable over Fly's private 6PN network from other apps in the same organization. A bind-address flag on the client would close that; left out to keep this change to deployment only.🤖 Generated with Claude Code