Skip to content

Add a tailnet-only Fly deployment for the web frontend - #30

Merged
jdtw merged 2 commits into
mainfrom
feat/tailnet-frontend
Aug 2, 2026
Merged

jdtw merged 2 commits into
mainfrom
feat/tailnet-frontend

Conversation

@jdtw

@jdtw jdtw commented Aug 1, 2026

Copy link
Copy Markdown
Owner

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.toml declares no services. No [[services]], no [http_service] — Fly allocates no public address, and the app needs no dedicated IP.
  • tailscaled runs 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 or NET_ADMIN.

Some deliberate choices:

  • The image can't be distroless like the server's — tailscaled runs alongside the client, so the entrypoint needs a shell.
  • Tailscale state is 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.
  • The signing key arrives as a base64 secret and is written to /run/links/priv.pb on 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:

  • Image builds clean (158MB); both tailscale 1.98.10 and the client are present and runnable
  • All three guard clauses produce clear errors: missing TS_AUTHKEY, LINKS_PRIVATE_KEY_B64, LINKS_ADDR
  • With everything set, the signing key materializes correctly at /run/links/priv.pb — mode 600, correct decoded content
  • tailscaled starts and reaches NeedsLogin, i.e. ready and waiting on auth

What 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 --server binds 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

jdtw and others added 2 commits August 1, 2026 09:52
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>
@jdtw
jdtw merged commit 105f434 into main Aug 2, 2026
1 check passed
@jdtw jdtw mentioned this pull request Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant